feat(storage): show running checks, confirmations and the nightly result in Admin > Storage

The panels follow the reworked reclaim state: Check now starts a check
in the background and the panel polls until it is done, a category with
an unusually large share of unused files offers Confirm, Freed comes
from the last run that deleted, a stopped check says why and no longer
reads as nothing found, the switch waits for its answer and a poll that
was in flight cannot flip it back, and refusals name their reason in
the operator's language. Strings updated in all 28 languages.
This commit is contained in:
Maurice
2026-11-01 16:25:38 +01:00
parent d17457de56
commit e0982e708f
41 changed files with 874 additions and 288 deletions
@@ -14,9 +14,10 @@ const S3_MASKED = {
/** A last check with three covers a week old and two younger ones; every other category never reclaimed. */
function reclaimReport(): StorageReclaimReport {
const categories = Object.fromEntries(
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, { objects: 0, unreferenced: { objects: 0, bytes: 0 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, skipped: 'not_reclaimed' as const }]),
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, { objects: 0, unreferenced: { objects: 0, bytes: 0 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: 'not_reclaimed' as const }]),
) as StorageReclaimReport['categories'];
categories.covers = { objects: 40, unreferenced: { objects: 5, bytes: 5000 }, eligible: { objects: 3, bytes: 3000 }, deleted: { objects: 0, bytes: 0 }, skipped: null };
categories.covers = { objects: 40, unreferenced: { objects: 5, bytes: 5000 }, eligible: { objects: 3, bytes: 3000 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: null };
categories.places = { objects: 10, unreferenced: { objects: 3, bytes: 300 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: true, skipped: null };
return { startedAt: Date.now() - 60_000, finishedAt: Date.now() - 30_000, mode: 'report', stopped: null, categories };
}
@@ -1228,27 +1229,54 @@ describe('AdminStoragePanel', () => {
expect(within(row).getByRole('button', { name: 'Sync now' })).toBeInTheDocument();
});
it('FE-ADMIN-STOR-051: unreferenced files: the last check shows, the switch and the check go to the server', async () => {
it('FE-ADMIN-STOR-051: unreferenced files: the last check shows, the switch, the check and a confirmation go to the server', async () => {
const lastRun = reclaimReport();
let put: unknown = null;
let run: unknown = null;
let runs = 0;
let confirm: unknown = null;
server.use(
http.put('/api/admin/storage/reclaim', async ({ request }) => {
put = await request.json();
return HttpResponse.json({ deleteEnabled: true, lastRun });
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
}),
http.post('/api/admin/storage/reclaim/run', async ({ request }) => {
run = await request.json();
return HttpResponse.json(lastRun);
http.post('/api/admin/storage/reclaim/run', () => {
runs += 1;
return HttpResponse.json({ started: true }, { status: 202 });
}),
http.post('/api/admin/storage/reclaim/confirm', async ({ request }) => {
confirm = await request.json();
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
}),
);
await renderPanel(baseState({ reclaim: { deleteEnabled: false, lastRun } }));
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun, lastDeleteRun: null } }));
// Once a check started, the state the panel polls says it runs.
server.use(
http.get('/api/admin/storage', () =>
HttpResponse.json(baseState({ reclaim: { deleteEnabled: true, running: runs > 0, lastRun, lastDeleteRun: null } })),
),
);
expect(screen.getByText('Unreferenced files')).toBeInTheDocument();
expect(within(screen.getByTestId('storage-reclaim-covers')).getByText(/Can be freed/)).toBeInTheDocument();
expect(within(screen.getByTestId('storage-reclaim-places')).getByText(/More than a fifth/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete unreferenced files' }));
await waitFor(() => expect(put).toEqual({ deleteEnabled: true }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete unreferenced files' })).toHaveAttribute('aria-pressed', 'true'));
fireEvent.click(within(screen.getByTestId('storage-reclaim-places')).getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(confirm).toEqual({ category: 'places' }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(run).toEqual({ reportOnly: true }));
await waitFor(() => expect(runs).toBe(1));
// The check runs in the background: the panel shows it running until the state says otherwise.
await waitFor(() => expect(screen.getByText('Checking…')).toBeInTheDocument());
});
it('FE-ADMIN-STOR-051b: a refused check names the reason in the operator\'s language', async () => {
server.use(
http.post('/api/admin/storage/reclaim/run', () =>
HttpResponse.json({ error: 'busy', code: 'reclaim_busy' }, { status: 409 }),
),
);
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null } }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(screen.getByText('Another storage task is running. Try again in a moment.')).toBeInTheDocument());
});
});
@@ -97,7 +97,7 @@ export default function AdminStoragePanel(): React.ReactElement {
const {
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
closeMigratePrompt, migrationQueue, handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
reclaim, reclaimChecking, handleReclaimCheck, handleReclaimDelete,
reclaim, reclaimChecking, reclaimSaving, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
} = useStoragePanel()
// Open/closed only: the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh rather than reading the
@@ -633,6 +633,8 @@ export default function AdminStoragePanel(): React.ReactElement {
<StorageReclaimSection
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
onConfirm={(category) => void handleReclaimConfirm(category)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
@@ -11,17 +11,29 @@ import type { ReclaimCount, ReclaimView } from './reclaimModel';
interface Props {
reclaim: ReclaimView;
/** A check was asked for or is running. */
checking: boolean;
/** The switch is on its way to the server. */
saving: boolean;
onCheck: () => void;
onDelete: (enabled: boolean) => void;
onConfirm: (category: ReclaimView['lines'][number]['category']) => void;
}
/**
* Unreferenced files on the desktop storage panel: what the last check found,
* the switch that lets the nightly run delete them, and a check on demand. The
* phone shows the same through MStorageReclaimCard, off the same view model.
* the switch that lets the nightly run delete them, a check on demand and the
* confirmation a category with an unusually large share of unused files waits
* for. The phone shows the same through MStorageReclaimCard, off the same view model.
*/
export default function StorageReclaimSection({ reclaim, checking, onCheck, onDelete }: Props): React.ReactElement {
export default function StorageReclaimSection({
reclaim,
checking,
saving,
onCheck,
onDelete,
onConfirm,
}: Props): React.ReactElement {
const { t, locale } = useTranslation();
const count = (c: ReclaimCount) => t('storage.usage.line', { count: c.objects, size: formatBytes(c.bytes) });
const checked = reclaim.checkedAt !== null;
@@ -44,7 +56,7 @@ export default function StorageReclaimSection({ reclaim, checking, onCheck, onDe
) : (
<ScanSearch size={14} strokeWidth={2.2} />
)}
{t('storage.reclaim.check')}
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
</button>
}
>
@@ -52,11 +64,13 @@ export default function StorageReclaimSection({ reclaim, checking, onCheck, onDe
<SettingRow
label={t('storage.reclaim.delete')}
hint={t('storage.reclaim.deleteHint')}
dimmed={saving}
control={
<ToggleSwitch
on={reclaim.deleteEnabled}
onToggle={() => onDelete(!reclaim.deleteEnabled)}
label={t('storage.reclaim.delete')}
disabled={saving}
/>
}
/>
@@ -68,45 +82,59 @@ export default function StorageReclaimSection({ reclaim, checking, onCheck, onDe
</p>
{reclaim.stopped && (
<p role="note" className="m-0 mt-1.5 leading-snug text-warning" style={fs(11.5)}>
{t('storage.reclaim.stopped')}
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
</p>
)}
{checked && (
<div className="mt-2.5 grid grid-cols-1 gap-2 sm:grid-cols-3">
<Figure label={t('storage.reclaim.freeable')} value={count(reclaim.freeable)} />
<Figure label={t('storage.reclaim.waiting')} value={count(reclaim.waiting)} />
<Figure label={t('storage.reclaim.deleted')} value={count(reclaim.deleted)} />
<Figure label={t('storage.reclaim.deleted')} value={count(reclaim.freed)} />
</div>
)}
{checked && reclaim.lines.length === 0 && (
{checked && !reclaim.stopped && reclaim.lines.length === 0 && (
<SettingsHint className="mt-2.5">{t('storage.reclaim.nothing')}</SettingsHint>
)}
{reclaim.lines.length > 0 && (
<ul className="m-0 mt-3 flex list-none flex-col gap-1.5 p-0">
<ul className="m-0 mt-3 flex list-none flex-col gap-2 p-0">
{reclaim.lines.map((line) => (
<li
key={line.category}
data-testid={`storage-reclaim-${line.category}`}
className="flex flex-wrap items-center gap-x-2.5 gap-y-1"
className="flex flex-col gap-1"
>
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>
{t(`storage.category.${line.category}`)}
</span>
{line.skipped ? (
<StatusPill tone="warning">{t(`storage.reclaim.skip.${line.skipped}`)}</StatusPill>
) : (
<>
{line.freeable.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.freeable')}: {count(line.freeable)}
</span>
)}
{line.waiting.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.waiting')}: {count(line.waiting)}
</span>
)}
</>
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>
{t(`storage.category.${line.category}`)}
</span>
{line.freeable.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.freeable')}: {count(line.freeable)}
</span>
)}
{line.waiting.objects > 0 && (
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{t('storage.reclaim.waiting')}: {count(line.waiting)}
</span>
)}
{line.skipped && (
<StatusPill tone="warning">{t(`storage.reclaim.skip.${line.skipped}`)}</StatusPill>
)}
</div>
{line.confirmationRequired && (
<div className="flex flex-wrap items-center gap-2.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5">
<p className="m-0 min-w-0 flex-1 basis-56 leading-snug text-warning" style={fs(11.5)}>
{t('storage.reclaim.confirmNeeded')}
</p>
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
onClick={() => onConfirm(line.category)}
>
{t('storage.reclaim.confirm')}
</button>
</div>
)}
</li>
))}
@@ -1,4 +1,4 @@
// FE-ADMIN-STOR-RECLAIM-001 to -005: the reclaim report as both storage panels show it.
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
import { reclaimView } from './reclaimModel';
@@ -6,33 +6,38 @@ import { reclaimView } from './reclaimModel';
const none = { objects: 0, bytes: 0 };
function category(patch: Partial<StorageReclaimCategoryReport> = {}): StorageReclaimCategoryReport {
return { objects: 0, unreferenced: none, eligible: none, deleted: none, skipped: null, ...patch };
return {
objects: 0,
unreferenced: none,
eligible: none,
deleted: none,
confirmationRequired: false,
skipped: null,
...patch,
};
}
function report(
patch: Partial<Record<(typeof STORAGE_RECLAIM_CATEGORIES)[number], StorageReclaimCategoryReport>>
patch: Partial<Record<(typeof STORAGE_RECLAIM_CATEGORIES)[number], StorageReclaimCategoryReport>>,
mode: StorageReclaimReport['mode'] = 'report'
): StorageReclaimReport {
const categories = Object.fromEntries(
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, category({ skipped: 'not_reclaimed' })])
) as StorageReclaimReport['categories'];
return {
startedAt: 1_000,
finishedAt: 2_000,
mode: 'report',
stopped: null,
categories: { ...categories, ...patch },
};
return { startedAt: 1_000, finishedAt: 2_000, mode, stopped: null, categories: { ...categories, ...patch } };
}
describe('reclaimView', () => {
it('FE-ADMIN-STOR-RECLAIM-001: no state reads as deletion off and never checked', () => {
it('FE-ADMIN-STOR-RECLAIM-001: no state reads as deletion off, not running and never checked', () => {
expect(reclaimView(null)).toEqual({
deleteEnabled: false,
running: false,
checkedAt: null,
stopped: false,
stopped: null,
freeable: none,
waiting: none,
deleted: none,
freed: none,
freedAt: null,
lines: [],
});
});
@@ -40,6 +45,8 @@ describe('reclaimView', () => {
it('FE-ADMIN-STOR-RECLAIM-002: freeable is what is a week old, waiting what is younger, summed over categories', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({
covers: category({
objects: 40,
@@ -56,39 +63,71 @@ describe('reclaimView', () => {
expect(view.lines.map((line) => line.category)).toEqual(['covers', 'places']);
});
it('FE-ADMIN-STOR-RECLAIM-003: what the run deleted is no longer freeable, and counts as freed', () => {
const view = reclaimView({
deleteEnabled: true,
lastRun: report({
it('FE-ADMIN-STOR-RECLAIM-003: freed comes from the last run with deletion on, which a check on demand does not replace', () => {
const deleting = report(
{
files: category({
unreferenced: { objects: 2, bytes: 20 },
eligible: { objects: 2, bytes: 20 },
deleted: { objects: 2, bytes: 20 },
}),
}),
},
'delete'
);
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: { ...deleting, finishedAt: 900 },
lastRun: report({}),
});
expect(view.freed).toEqual({ objects: 2, bytes: 20 });
expect(view.freedAt).toBe(900);
expect(view.freeable).toEqual(none);
expect(view.deleted).toEqual({ objects: 2, bytes: 20 });
expect(view.lines).toEqual([]);
});
it('FE-ADMIN-STOR-RECLAIM-004: a skipped category shows its reason and counts toward nothing, one never reclaimed shows nothing', () => {
const view = reclaimView({
deleteEnabled: false,
lastRun: report({
avatars: category({ skipped: 'too_many_unreferenced', unreferenced: { objects: 9, bytes: 9 } }),
}),
running: false,
lastDeleteRun: null,
lastRun: report({ avatars: category({ skipped: 'foreign_install', unreferenced: { objects: 9, bytes: 9 } }) }),
});
expect(view.lines).toEqual([
{ category: 'avatars', freeable: none, waiting: none, skipped: 'too_many_unreferenced' },
{ category: 'avatars', freeable: none, waiting: none, confirmationRequired: false, skipped: 'foreign_install' },
]);
expect(view.waiting).toEqual(none);
expect(view.lines.some((line) => line.category === 'backups')).toBe(false);
});
it('FE-ADMIN-STOR-RECLAIM-005: a check stopped by a restore says so', () => {
expect(
reclaimView({ deleteEnabled: false, lastRun: { ...report({}), stopped: 'database_reopened' } }).stopped
).toBe(true);
it('FE-ADMIN-STOR-RECLAIM-005: a pass that stopped says why, and a category it stopped in keeps its partial figures', () => {
const view = reclaimView({
deleteEnabled: false,
running: false,
lastDeleteRun: null,
lastRun: {
...report({ covers: category({ skipped: 'interrupted', unreferenced: { objects: 4, bytes: 40 } }) }),
stopped: 'failed',
},
});
expect(view.stopped).toBe('failed');
expect(view.waiting).toEqual({ objects: 4, bytes: 40 });
expect(view.lines[0]).toMatchObject({ category: 'covers', skipped: 'interrupted' });
});
it('FE-ADMIN-STOR-RECLAIM-006: a category waiting for a confirmation shows even with nothing old enough yet', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({ places: category({ objects: 10, confirmationRequired: true }) }),
});
expect(view.lines).toEqual([
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, skipped: null },
]);
});
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
});
});
@@ -1,4 +1,9 @@
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimState } from '@trek/shared';
import {
STORAGE_RECLAIM_CATEGORIES,
type StorageReclaimCategoryReport,
type StorageReclaimReport,
type StorageReclaimState,
} from '@trek/shared';
/** A number of files and their size, as the report counts them. */
export interface ReclaimCount {
@@ -13,22 +18,32 @@ interface ReclaimCategoryLine {
freeable: ReclaimCount;
/** Unused for less than a week: these get their week first. */
waiting: ReclaimCount;
/** Why the category was left alone; null when it was checked. */
/**
* An unusually large share of the category looks unused: nothing in it is
* deleted until an admin confirms that this is expected (a cleaned-up
* backlog, say, rather than a second installation in the same bucket).
*/
confirmationRequired: boolean;
/** Why the category was left alone, or stopped part way; null when it was checked through. */
skipped: Exclude<StorageReclaimCategoryReport['skipped'], 'not_reclaimed'> | null;
}
/** The admin's view of the reclaim pass: the switch and what the last check found. */
export interface ReclaimView {
deleteEnabled: boolean;
/** A check, nightly or on demand, is running now. */
running: boolean;
/** When the last check finished, ms; null before the first one. */
checkedAt: number | null;
/** The last check stopped early, because a restore swapped the database under it. */
stopped: boolean;
/** Why the last check stopped before the end; null when it ran through. */
stopped: StorageReclaimReport['stopped'];
freeable: ReclaimCount;
waiting: ReclaimCount;
/** What the last check deleted itself (only a nightly run with deletion on deletes). */
deleted: ReclaimCount;
/** Only the categories with something to free, something waiting, or a reason they were skipped. */
/** What the last run with deletion on removed: a check on demand only reports, so it never resets this. */
freed: ReclaimCount;
/** When that run finished, ms; null when nothing ever ran with deletion on. */
freedAt: number | null;
/** Only the categories with something to free, something waiting, something to confirm, or a reason they were skipped. */
lines: ReclaimCategoryLine[];
}
@@ -42,40 +57,57 @@ function minus(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
return { objects: Math.max(0, a.objects - b.objects), bytes: Math.max(0, a.bytes - b.bytes) };
}
function freedBy(run: StorageReclaimReport | null): ReclaimCount {
if (!run) return NONE;
return STORAGE_RECLAIM_CATEGORIES.reduce(
(sum, category) => add(sum, run.categories[category]?.deleted ?? NONE),
NONE
);
}
/**
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off and never checked.
* as deletion off, not running and never checked.
*/
export function reclaimView(state: StorageReclaimState | null): ReclaimView {
const run = state?.lastRun ?? null;
const lines: ReclaimCategoryLine[] = [];
let freeable = NONE;
let waiting = NONE;
let deleted = NONE;
for (const category of STORAGE_RECLAIM_CATEGORIES) {
const report = run?.categories[category];
if (!report || report.skipped === 'not_reclaimed') continue;
// A skipped category frees nothing, whatever it counted: only its reason is shown.
if (report.skipped !== null) {
lines.push({ category, freeable: NONE, waiting: NONE, skipped: report.skipped });
continue;
}
// What was deleted on this run no longer waits to be freed.
const due = minus(report.eligible, report.deleted);
const young = minus(report.unreferenced, report.eligible);
freeable = add(freeable, due);
waiting = add(waiting, young);
deleted = add(deleted, report.deleted);
if (due.objects > 0 || young.objects > 0) lines.push({ category, freeable: due, waiting: young, skipped: null });
// A category the pass stopped in, or that waits for a confirmation, still
// counts what it found; one it never judged counts nothing.
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
if (judged) {
freeable = add(freeable, due);
waiting = add(waiting, young);
}
const counts = judged ? { freeable: due, waiting: young } : { freeable: NONE, waiting: NONE };
if (
counts.freeable.objects > 0 ||
counts.waiting.objects > 0 ||
report.confirmationRequired ||
report.skipped !== null
) {
lines.push({ category, ...counts, confirmationRequired: report.confirmationRequired, skipped: report.skipped });
}
}
const deleteRun = state?.lastDeleteRun ?? null;
return {
deleteEnabled: state?.deleteEnabled ?? false,
running: state?.running ?? false,
checkedAt: run?.finishedAt ?? null,
stopped: run?.stopped != null,
stopped: run?.stopped ?? null,
freeable,
waiting,
deleted,
freed: freedBy(deleteRun),
freedAt: deleteRun?.finishedAt ?? null,
lines,
};
}
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
import type {
StorageAdminState,
StorageBackend,
StorageConfig,
StorageConfigPut,
StorageReclaimConfirmRequest,
StorageTestResponse,
} from '@trek/shared'
import { adminApi } from '../../../api/client'
import { storageReclaimApi } from '../../../api/storageReclaim'
import { getApiErrorMessage, type ApiError } from '../../../types'
@@ -7,6 +14,16 @@ import { settingsDocumentOf } from './storageModel'
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
/** A refused reclaim call: the server's code when it sent one the panel can name, its message otherwise. */
export type ReclaimFailure = { code: string } | { message: string }
function reclaimFailureOf(err: unknown, fallback: string): ReclaimFailure {
const code = (err as ApiError | null)?.response?.data as { code?: unknown } | undefined
return typeof code?.code === 'string' && code.code.startsWith('reclaim_')
? { code: code.code }
: { message: getApiErrorMessage(err, fallback) }
}
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
@@ -60,9 +77,11 @@ export interface StorageAdmin {
cancelMigration: (category: string) => Promise<string | null>
refreshStats: () => Promise<string | null>
/** Switches the nightly deletion of unreferenced files on or off; null on success. */
setReclaimDelete: (enabled: boolean) => Promise<string | null>
/** Runs a check now that only reports; null on success. */
checkReclaim: () => Promise<string | null>
setReclaimDelete: (enabled: boolean) => Promise<ReclaimFailure | null>
/** Starts a check that only reports, in the background; the state polls until it is done. Null on success. */
checkReclaim: () => Promise<ReclaimFailure | null>
/** Lets the nightly run delete in a category where an unusually large share looks unused. Null on success. */
confirmReclaim: (category: StorageReclaimConfirmRequest['category']) => Promise<ReclaimFailure | null>
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
refreshState: () => Promise<void>
/**
@@ -276,7 +295,8 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
useEffect(() => {
if (
!state?.backfills.some((b) => b.status === 'running') &&
!state?.migrations.some((m) => m.status === 'running')
!state?.migrations.some((m) => m.status === 'running') &&
!state?.reclaim?.running
)
return
const iv = setInterval(() => {
@@ -361,33 +381,60 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
}
}, [genericError])
/** Writes the reclaim part of the state the way refreshStats writes usage. */
/**
* Writes the reclaim part of the state. It counts as a deliberate state
* application, so a poll GET that was in flight before it is dropped instead
* of putting the switch back where it was.
*/
const setReclaim = useCallback((reclaim: NonNullable<StorageAdminState['reclaim']>) => {
const prev = stateRef.current
if (!prev) return
stateSeq.current += 1
const next = { ...prev, reclaim }
stateRef.current = next
setState(next)
}, [])
const setReclaimDelete = useCallback(async (enabled: boolean): Promise<string | null> => {
const setReclaimDelete = useCallback(
async (enabled: boolean): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.setDelete(enabled))
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
}
},
[genericError, setReclaim],
)
const checkReclaim = useCallback(async (): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.setDelete(enabled))
await storageReclaimApi.check()
// Running from here on: the poll above picks the report up once the pass is done.
const current = stateRef.current?.reclaim
setReclaim({
deleteEnabled: current?.deleteEnabled ?? false,
running: true,
lastRun: current?.lastRun ?? null,
lastDeleteRun: current?.lastDeleteRun ?? null,
})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
return reclaimFailureOf(err, genericError)
}
}, [genericError, setReclaim])
const checkReclaim = useCallback(async (): Promise<string | null> => {
try {
const lastRun = await storageReclaimApi.check()
setReclaim({ deleteEnabled: stateRef.current?.reclaim?.deleteEnabled ?? false, lastRun })
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
}, [genericError, setReclaim])
const confirmReclaim = useCallback(
async (category: StorageReclaimConfirmRequest['category']): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.confirm(category))
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
}
},
[genericError, setReclaim],
)
return {
state,
@@ -411,6 +458,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
refreshStats,
setReclaimDelete,
checkReclaim,
confirmReclaim,
refreshState,
discardDraft,
}
@@ -1,4 +1,4 @@
// FE-ADMIN-STOR-PANEL-001 to -019: the storage panel logic behind both admin shells.
// FE-ADMIN-STOR-PANEL-001 to -020: the storage panel logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
@@ -60,6 +60,7 @@ function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): Stor
refreshStats: vi.fn(async () => null),
setReclaimDelete: vi.fn(async () => null),
checkReclaim: vi.fn(async () => null),
confirmReclaim: vi.fn(async () => null),
refreshState: vi.fn(async () => {}),
discardDraft: vi.fn(async () => {}),
};
@@ -289,20 +290,38 @@ describe('migrationPromptLine', () => {
describe('useStoragePanel: unreferenced files', () => {
it('FE-ADMIN-STOR-PANEL-018: the reclaim switch and the check go through the admin hook, a failure as a toast', async () => {
fake.admin = makeAdmin(baseState());
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce('busy');
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce({ code: 'reclaim_busy' });
vi.mocked(fake.admin.setReclaimDelete).mockResolvedValueOnce({ message: 'disk full' });
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.handleReclaimDelete(true);
});
expect(fake.admin.setReclaimDelete).toHaveBeenCalledWith(true);
// A failure without a code the panel knows shows the server's message.
expect(toast.error).toHaveBeenCalledWith('disk full');
expect(result.current.reclaimSaving).toBe(false);
await act(async () => {
await result.current.handleReclaimCheck();
});
expect(fake.admin.checkReclaim).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledWith('busy');
// A code the panel knows is shown in the operator's language.
expect(toast.error).toHaveBeenCalledWith('storage.reclaim.error.reclaim_busy');
expect(result.current.reclaimChecking).toBe(false);
});
it('FE-ADMIN-STOR-PANEL-020: confirming a category goes through the admin hook and says so; a running check keeps the button busy', async () => {
const state = baseState();
state.reclaim = { deleteEnabled: true, running: true, lastRun: null, lastDeleteRun: null };
fake.admin = makeAdmin(state);
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaimChecking).toBe(true);
await act(async () => {
await result.current.handleReclaimConfirm('places');
});
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith('places');
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
});
it('FE-ADMIN-STOR-PANEL-019: a server without the reclaim state reads as deletion off and never checked', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
@@ -7,6 +7,7 @@ import type {
StorageConfig,
StorageConfigPut,
StorageMigrationStatus,
StorageReclaimConfirmRequest,
} from '@trek/shared';
import { useTranslation } from '../../../i18n';
@@ -32,7 +33,7 @@ import {
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel';
import { useStorageAdmin } from './useStorageAdmin';
import { useStorageAdmin, type ReclaimFailure } from './useStorageAdmin';
/** Display-name mapper for joined category lists: the raw id renders only in the badge. */
export const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
@@ -166,18 +167,30 @@ export function useStoragePanel() {
if (error) toast.error(error);
};
const [reclaimChecking, setReclaimChecking] = useState(false);
// The click itself, until the server answered; the pass that follows shows through `reclaim.running`.
const [reclaimStarting, setReclaimStarting] = useState(false);
const [reclaimSaving, setReclaimSaving] = useState(false);
const reclaimError = (failure: ReclaimFailure) =>
'code' in failure ? t(`storage.reclaim.error.${failure.code}`) : failure.message;
const handleReclaimCheck = async () => {
setReclaimChecking(true);
const error = await admin.checkReclaim();
setReclaimChecking(false);
if (error) toast.error(error);
setReclaimStarting(true);
const failure = await admin.checkReclaim();
setReclaimStarting(false);
if (failure) toast.error(reclaimError(failure));
};
const handleReclaimDelete = async (enabled: boolean) => {
const error = await admin.setReclaimDelete(enabled);
if (error) toast.error(error);
setReclaimSaving(true);
const failure = await admin.setReclaimDelete(enabled);
setReclaimSaving(false);
if (failure) toast.error(reclaimError(failure));
};
const handleReclaimConfirm = async (category: StorageReclaimConfirmRequest['category']) => {
const failure = await admin.confirmReclaim(category);
if (failure) toast.error(reclaimError(failure));
else toast.success(t('storage.reclaim.confirmed'));
};
const reclaim = reclaimView(admin.state?.reclaim ?? null);
const reclaimChecking = reclaimStarting || reclaim.running;
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return;
@@ -387,8 +400,10 @@ export function useStoragePanel() {
handleRefreshStats,
reclaim,
reclaimChecking,
reclaimSaving,
handleReclaimCheck,
handleReclaimDelete,
handleReclaimConfirm,
handleStartBackfill,
handleCancelBackfill,
loaded,