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