mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user