feat(storage): confirm the count shown, and say a backup or a missing install id held the cleanup

Confirm sends the count the admin saw and waits for its answer; a stale
count reloads the state. A running backup is named as a skip reason, a
missing installation id and a stale count as errors, and the switch's
hint no longer promises removal right away: deleted files go a day
later, once no undo can bring them back. Strings in all 28 languages.
This commit is contained in:
Maurice
2026-11-01 18:14:49 +01:00
parent 10ad64d170
commit 9d2ffc91af
40 changed files with 250 additions and 81 deletions
+3 -2
View File
@@ -7,8 +7,9 @@ export const storageReclaimApi = {
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then((r) => r.data),
/** Starts a report-only check in the background (202); its report arrives through the admin state. */
check: (): Promise<{ started: true }> => apiClient.post('/admin/storage/reclaim/run').then((r) => r.data),
confirm: (category: StorageReclaimConfirmRequest['category']): Promise<StorageReclaimState> =>
/** `unreferenced` is the count the admin was shown: the server refuses when its own count has moved on. */
confirm: (request: StorageReclaimConfirmRequest): Promise<StorageReclaimState> =>
apiClient
.post('/admin/storage/reclaim/confirm', { category } satisfies StorageReclaimConfirmRequest)
.post('/admin/storage/reclaim/confirm', request satisfies StorageReclaimConfirmRequest)
.then((r) => r.data),
};
@@ -1262,7 +1262,7 @@ describe('AdminStoragePanel', () => {
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' }));
await waitFor(() => expect(confirm).toEqual({ category: 'places', unreferenced: 3 }));
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
await waitFor(() => expect(runs).toBe(1));
// The check runs in the background: the panel shows it running until the state says otherwise.
@@ -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, reclaimSaving, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
} = useStoragePanel()
// Open/closed only: the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh rather than reading the
@@ -634,7 +634,8 @@ export default function AdminStoragePanel(): React.ReactElement {
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
onConfirm={(category) => void handleReclaimConfirm(category)}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
@@ -17,7 +17,9 @@ interface Props {
saving: boolean;
onCheck: () => void;
onDelete: (enabled: boolean) => void;
onConfirm: (category: ReclaimView['lines'][number]['category']) => void;
/** The category whose confirmation is on its way. */
confirming: ReclaimView['lines'][number]['category'] | null;
onConfirm: (request: { category: ReclaimView['lines'][number]['category']; unreferenced: number }) => void;
}
/**
@@ -32,6 +34,7 @@ export default function StorageReclaimSection({
saving,
onCheck,
onDelete,
confirming,
onConfirm,
}: Props): React.ReactElement {
const { t, locale } = useTranslation();
@@ -130,8 +133,12 @@ export default function StorageReclaimSection({
type="button"
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
onClick={() => onConfirm(line.category)}
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
disabled={confirming === line.category}
>
{confirming === line.category && (
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
)}
{t('storage.reclaim.confirm')}
</button>
</div>
@@ -94,7 +94,14 @@ describe('reclaimView', () => {
lastRun: report({ avatars: category({ skipped: 'foreign_install', unreferenced: { objects: 9, bytes: 9 } }) }),
});
expect(view.lines).toEqual([
{ category: 'avatars', freeable: none, waiting: none, confirmationRequired: false, skipped: 'foreign_install' },
{
category: 'avatars',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 9,
skipped: 'foreign_install',
},
]);
expect(view.waiting).toEqual(none);
expect(view.lines.some((line) => line.category === 'backups')).toBe(false);
@@ -123,7 +130,7 @@ describe('reclaimView', () => {
lastRun: report({ places: category({ objects: 10, confirmationRequired: true }) }),
});
expect(view.lines).toEqual([
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, skipped: null },
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, unreferenced: 0, skipped: null },
]);
});
@@ -24,6 +24,8 @@ interface ReclaimCategoryLine {
* backlog, say, rather than a second installation in the same bucket).
*/
confirmationRequired: boolean;
/** Everything the check found unused in the category, the count a confirmation is given for. */
unreferenced: number;
/** Why the category was left alone, or stopped part way; null when it was checked through. */
skipped: Exclude<StorageReclaimCategoryReport['skipped'], 'not_reclaimed'> | null;
}
@@ -89,6 +91,7 @@ function lineOf(
freeable,
waiting,
confirmationRequired: report.confirmationRequired,
unreferenced: report.unreferenced.objects,
skipped: report.skipped,
};
const quiet = freeable.objects === 0 && waiting.objects === 0 && !line.confirmationRequired && line.skipped === null;
@@ -81,7 +81,7 @@ export interface StorageAdmin {
/** 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>
confirmReclaim: (request: StorageReclaimConfirmRequest) => 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>
/**
@@ -425,15 +425,18 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
}, [genericError, setReclaim])
const confirmReclaim = useCallback(
async (category: StorageReclaimConfirmRequest['category']): Promise<ReclaimFailure | null> => {
async (request: StorageReclaimConfirmRequest): Promise<ReclaimFailure | null> => {
try {
setReclaim(await storageReclaimApi.confirm(category))
setReclaim(await storageReclaimApi.confirm(request))
return null
} catch (err: unknown) {
return reclaimFailureOf(err, genericError)
const failure = reclaimFailureOf(err, genericError)
// The count moved on since it was shown: show the current one before the admin confirms again.
if ('code' in failure && failure.code === 'reclaim_confirm_stale') void refreshState().catch(() => {})
return failure
}
},
[genericError, setReclaim],
[genericError, setReclaim, refreshState],
)
return {
@@ -316,9 +316,10 @@ describe('useStoragePanel: unreferenced files', () => {
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaimChecking).toBe(true);
await act(async () => {
await result.current.handleReclaimConfirm('places');
await result.current.handleReclaimConfirm({ category: 'places', unreferenced: 3 });
});
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith('places');
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith({ category: 'places', unreferenced: 3 });
expect(result.current.reclaimConfirming).toBeNull();
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
});
@@ -184,8 +184,12 @@ export function useStoragePanel() {
setReclaimSaving(false);
if (failure) toast.error(reclaimError(failure));
};
const handleReclaimConfirm = async (category: StorageReclaimConfirmRequest['category']) => {
const failure = await admin.confirmReclaim(category);
// The category whose confirmation is on its way, so its button waits for the answer.
const [reclaimConfirming, setReclaimConfirming] = useState<StorageReclaimConfirmRequest['category'] | null>(null);
const handleReclaimConfirm = async (request: StorageReclaimConfirmRequest) => {
setReclaimConfirming(request.category);
const failure = await admin.confirmReclaim(request);
setReclaimConfirming(null);
if (failure) toast.error(reclaimError(failure));
else toast.success(t('storage.reclaim.confirmed'));
};
@@ -401,6 +405,7 @@ export function useStoragePanel() {
reclaim,
reclaimChecking,
reclaimSaving,
reclaimConfirming,
handleReclaimCheck,
handleReclaimDelete,
handleReclaimConfirm,
@@ -173,7 +173,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
const {
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
reclaim, reclaimChecking, reclaimSaving, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
} = useStoragePanel()
const [categoryPicker, setCategoryPicker] = useState<StorageCategory | null>(null)
@@ -571,7 +571,8 @@ export default function MAdminStoragePanel(): React.ReactElement {
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
onConfirm={(category) => void handleReclaimConfirm(category)}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
@@ -15,7 +15,9 @@ interface Props {
saving: boolean;
onCheck: () => void;
onDelete: (enabled: boolean) => void;
onConfirm: (category: ReclaimView['lines'][number]['category']) => void;
/** The category whose confirmation is on its way. */
confirming: ReclaimView['lines'][number]['category'] | null;
onConfirm: (request: { category: ReclaimView['lines'][number]['category']; unreferenced: number }) => void;
}
/** Unreferenced files on the phone's storage panel, off the view model StorageReclaimSection shows on desktop. */
@@ -25,6 +27,7 @@ export default function MStorageReclaimCard({
saving,
onCheck,
onDelete,
confirming,
onConfirm,
}: Props): React.ReactElement {
const { t, locale } = useTranslation();
@@ -62,7 +65,7 @@ export default function MStorageReclaimCard({
: t('storage.reclaim.never')}
</p>
{reclaim.stopped && (
<p role="note" className="mt-1 font-geist text-m-muted" style={fs(10)}>
<p role="note" className="mt-1 font-geist text-[color:var(--m-st-pending)]" style={fs(10)}>
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
</p>
)}
@@ -86,7 +89,7 @@ export default function MStorageReclaimCard({
{t(`storage.category.${line.category}`)}
</p>
{line.skipped && (
<p className="font-geist text-m-muted" style={fs(10)}>
<p className="font-geist text-[color:var(--m-st-pending)]" style={fs(10)}>
{t(`storage.reclaim.skip.${line.skipped}`)}
</p>
)}
@@ -105,7 +108,11 @@ export default function MStorageReclaimCard({
<p className="min-w-0 flex-1 font-geist text-m-muted" style={fs(10)}>
{t('storage.reclaim.confirmNeeded')}
</p>
<MAdminButton variant="ghost" onClick={() => onConfirm(line.category)}>
<MAdminButton
variant="ghost"
busy={confirming === line.category}
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
>
{t('storage.reclaim.confirm')}
</MAdminButton>
</div>