fix(files): reliable hosted_file cleanup + orphan detection in /core/files/

Event file delete (Pres Mgmt):
- Re-implement cleanup using /links pre-fetch before delete_hosted_file calls.
  The /links endpoint calls get_id_random() per link which populates Redis.
  Without this, redis_lookup_id_random('event_file', id) raises 404 in the
  delete handler → silent skip → physical file never removed.
  Now mirrors the same pattern used by the /core/files/ admin page delete.

/core/files/ admin page:
- Add orphan check mode: "Check Orphans" button batch-fetches links for all
  visible results in parallel (reusing links_map cache), then filters table
  to show only files with zero links.
- Orphan files get a warning badge in the filename column.
- Results header toggles to show "N orphans of M" when filter is active.
- Unlink icon imported from lucide for orphan UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-18 18:02:04 -04:00
parent 94b3dd84af
commit f3c6580b69
2 changed files with 133 additions and 18 deletions

View File

@@ -372,27 +372,59 @@ export async function delete_ae_obj_id__event_file({
}: { }: {
api_cfg: any; api_cfg: any;
event_file_id: string; event_file_id: string;
// Providing hosted_file_id enables full cleanup: removes the hosted_file_link, // Providing hosted_file_id enables full cleanup of the physical file and
// then deletes the hosted_file record and physical file if no other links remain. // hosted_file record. The /links fetch is done first because it calls
// Must be done BEFORE deleting the event_file record so the backend can still // get_id_random() on each linked object, which populates Redis. Without
// resolve the link_to_id via Redis. // this pre-fetch, redis_lookup_id_random('event_file', id) raises 404
// in the delete handler — the cleanup is silently skipped.
hosted_file_id?: string; hosted_file_id?: string;
params?: key_val; params?: key_val;
try_cache?: boolean; try_cache?: boolean;
log_lvl?: number; log_lvl?: number;
}) { }) {
if (hosted_file_id) { if (hosted_file_id) {
// Step 1: Fetch links — populates Redis for all linked object IDs.
let file_links: { link_to_type: string; link_to_id_random: string | null }[] = [];
try {
const links_result = await api.get_object({
api_cfg,
endpoint: `/v3/action/hosted_file/${hosted_file_id}/links`,
log_lvl: 0
});
file_links = links_result?.data ?? links_result ?? [];
} catch (e) {
if (log_lvl) console.warn('[delete_event_file] /links fetch failed:', e);
}
// Step 2: Remove each link. rm_orphan=true on the last one triggers
// physical file + hosted_file record cleanup if nothing else uses it.
if (file_links.length > 0) {
for (let i = 0; i < file_links.length; i++) {
const lnk = file_links[i];
const is_last = i === file_links.length - 1;
await api.delete_hosted_file({
api_cfg,
hosted_file_id,
link_to_type: lnk.link_to_type,
link_to_id: lnk.link_to_id_random ?? undefined,
rm_orphan: is_last,
params: { method: 'delete' },
log_lvl
});
}
} else {
// No links recorded — attempt direct orphan cleanup.
await api.delete_hosted_file({ await api.delete_hosted_file({
api_cfg, api_cfg,
hosted_file_id, hosted_file_id,
link_to_type: 'event_file',
link_to_id: event_file_id,
rm_orphan: true, rm_orphan: true,
params: { method: 'delete' }, params: { method: 'delete' },
log_lvl log_lvl
}); });
} }
}
// Step 3: Delete the event_file record after the hosted_file cleanup.
const result = await api.delete_ae_obj({ const result = await api.delete_ae_obj({
api_cfg, api_cfg,
obj_type: 'event_file', obj_type: 'event_file',

View File

@@ -18,6 +18,7 @@ import {
RefreshCw, RefreshCw,
Search, Search,
Trash2, Trash2,
Unlink,
X X
} from '@lucide/svelte'; } from '@lucide/svelte';
@@ -336,6 +337,53 @@ function fmt_date(val: string | Date | null | undefined) {
} }
let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0)); let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
// ── Orphan check ──────────────────────────────────────────────────────────────
// A file is a confirmed orphan once links_map has been populated for it AND the
// links array is empty. links_map is a SvelteMap so this derived stays reactive.
let orphan_checking = $state(false);
let orphan_filter = $state(false);
let orphan_ids = $derived(
new Set(
results
.filter((f) => links_map.has(f.hosted_file_id) && (links_map.get(f.hosted_file_id)?.length ?? 1) === 0)
.map((f) => f.hosted_file_id)
)
);
let displayed_results = $derived(
orphan_filter ? results.filter((f) => orphan_ids.has(f.hosted_file_id)) : results
);
async function check_all_for_orphans() {
orphan_checking = true;
orphan_filter = false;
// Fetch links for all visible results in parallel; skip already-cached entries.
await Promise.all(
results.map(async (file) => {
const id = file.hosted_file_id;
if (links_map.has(id)) return;
links_loading.set(id, true);
try {
const result = await api.get_object({
api_cfg: $ae_api,
endpoint: `/v3/action/hosted_file/${id}/links`,
log_lvl: 0
});
links_map.set(id, result?.data ?? result ?? []);
// Also resolve nav URLs for any links found
await Promise.all((result?.data ?? result ?? []).map((lnk: any) => resolve_link_url(lnk.link_to_type, lnk.link_to_id_random)));
} catch {
links_map.set(id, []);
} finally {
links_loading.delete(id);
}
})
);
orphan_filter = true;
orphan_checking = false;
}
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -460,15 +508,45 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
{#if results.length > 0} {#if results.length > 0}
<div class="card preset-tonal-surface border-surface-500/10 border shadow-xl"> <div class="card preset-tonal-surface border-surface-500/10 border shadow-xl">
<div class="border-surface-500/20 flex items-center justify-between border-b px-4 py-2"> <div class="border-surface-500/20 flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
<span class="text-xs font-bold opacity-60"> <span class="text-xs font-bold opacity-60">
{#if orphan_filter}
<span class="text-warning-600 dark:text-warning-400">
{orphan_ids.size} orphan{orphan_ids.size !== 1 ? 's' : ''} of {results.length}
</span>
{:else}
{results.length} file{results.length !== 1 ? 's' : ''} {results.length} file{results.length !== 1 ? 's' : ''}
{/if}
{#if results.length === page_limit} {#if results.length === page_limit}
<span class="opacity-50"> (may be more — increase per-page or paginate)</span> <span class="opacity-50"> (may be more)</span>
{/if} {/if}
· <span class="tabular-nums">{fmt_size(total_size)}</span> total · <span class="tabular-nums">{fmt_size(total_size)}</span> total
</span> </span>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- Orphan check -->
{#if orphan_filter}
<button
type="button"
class="btn btn-sm preset-tonal-warning text-xs"
onclick={() => { orphan_filter = false; }}
title="Show all files">
<Unlink size={12} class="mr-1" />
{orphan_ids.size} orphan{orphan_ids.size !== 1 ? 's' : ''} — Show All
</button>
{:else}
<button
type="button"
class="btn btn-sm preset-tonal-surface text-xs"
onclick={check_all_for_orphans}
disabled={orphan_checking}
title="Check all visible files for missing links (orphan detection)">
{#if orphan_checking}
<LoaderCircle size={12} class="mr-1 animate-spin" /> Checking…
{:else}
<Unlink size={12} class="mr-1" /> Check Orphans
{/if}
</button>
{/if}
{#if page_offset > 0} {#if page_offset > 0}
<button <button
type="button" type="button"
@@ -544,7 +622,7 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each results as file (file.hosted_file_id)} {#each displayed_results as file (file.hosted_file_id)}
<tr <tr
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200" class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
class:opacity-50={!file.enable}> class:opacity-50={!file.enable}>
@@ -560,6 +638,11 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
{#if file.hide} {#if file.hide}
<span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span> <span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span>
{/if} {/if}
{#if orphan_ids.has(file.hosted_file_id)}
<span class="badge preset-tonal-warning ml-1 text-[9px]" title="No links found — safe to delete">
<Unlink size={9} class="mr-0.5" /> orphan
</span>
{/if}
</div> </div>
</td> </td>
<td class="px-3 py-2 tabular-nums">{fmt_size(file.size)}</td> <td class="px-3 py-2 tabular-nums">{fmt_size(file.size)}</td>