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:
@@ -372,27 +372,59 @@ export async function delete_ae_obj_id__event_file({
|
||||
}: {
|
||||
api_cfg: any;
|
||||
event_file_id: string;
|
||||
// Providing hosted_file_id enables full cleanup: removes the hosted_file_link,
|
||||
// then deletes the hosted_file record and physical file if no other links remain.
|
||||
// Must be done BEFORE deleting the event_file record so the backend can still
|
||||
// resolve the link_to_id via Redis.
|
||||
// Providing hosted_file_id enables full cleanup of the physical file and
|
||||
// hosted_file record. The /links fetch is done first because it calls
|
||||
// get_id_random() on each linked object, which populates Redis. Without
|
||||
// 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;
|
||||
params?: key_val;
|
||||
try_cache?: boolean;
|
||||
log_lvl?: number;
|
||||
}) {
|
||||
if (hosted_file_id) {
|
||||
await api.delete_hosted_file({
|
||||
api_cfg,
|
||||
hosted_file_id,
|
||||
link_to_type: 'event_file',
|
||||
link_to_id: event_file_id,
|
||||
rm_orphan: true,
|
||||
params: { method: 'delete' },
|
||||
log_lvl
|
||||
});
|
||||
// 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({
|
||||
api_cfg,
|
||||
hosted_file_id,
|
||||
rm_orphan: true,
|
||||
params: { method: 'delete' },
|
||||
log_lvl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete the event_file record after the hosted_file cleanup.
|
||||
const result = await api.delete_ae_obj({
|
||||
api_cfg,
|
||||
obj_type: 'event_file',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Unlink,
|
||||
X
|
||||
} 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));
|
||||
|
||||
// ── 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>
|
||||
|
||||
<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}
|
||||
<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">
|
||||
{results.length} file{results.length !== 1 ? 's' : ''}
|
||||
{#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' : ''}
|
||||
{/if}
|
||||
{#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}
|
||||
· <span class="tabular-nums">{fmt_size(total_size)}</span> total
|
||||
</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}
|
||||
<button
|
||||
type="button"
|
||||
@@ -544,7 +622,7 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results as file (file.hosted_file_id)}
|
||||
{#each displayed_results as file (file.hosted_file_id)}
|
||||
<tr
|
||||
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
|
||||
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}
|
||||
<span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span>
|
||||
{/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>
|
||||
</td>
|
||||
<td class="px-3 py-2 tabular-nums">{fmt_size(file.size)}</td>
|
||||
|
||||
Reference in New Issue
Block a user