mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
9dd58fe32f
Deleting a journey left its uploaded photos behind for good, and deleting a list queued neither its cover nor its place images. An object with a key the store refuses no longer fails every night's pass, and the admin view keeps the counts of a category skipped for a running backup.
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
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 {
|
|
objects: number;
|
|
bytes: number;
|
|
}
|
|
|
|
/** What one category of the last check has to say, when it has anything to say. */
|
|
interface ReclaimCategoryLine {
|
|
category: (typeof STORAGE_RECLAIM_CATEGORIES)[number];
|
|
/** Unused for a week or more: what the nightly run deletes once deletion is on. */
|
|
freeable: ReclaimCount;
|
|
/** Unused for less than a week: these get their week first. */
|
|
waiting: ReclaimCount;
|
|
/**
|
|
* 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;
|
|
/** 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;
|
|
}
|
|
|
|
/** 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;
|
|
/** Why the last check stopped before the end; null when it ran through. */
|
|
stopped: StorageReclaimReport['stopped'];
|
|
freeable: ReclaimCount;
|
|
waiting: ReclaimCount;
|
|
/** 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[];
|
|
}
|
|
|
|
const NONE: ReclaimCount = { objects: 0, bytes: 0 };
|
|
|
|
function add(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
|
|
return { objects: a.objects + b.objects, bytes: a.bytes + b.bytes };
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
type SkipReason = StorageReclaimCategoryReport['skipped'];
|
|
|
|
/**
|
|
* The reasons the pass gives when it left a category before judging it, so its
|
|
* counts mean nothing. Every other reason (a backup running, a failed delete,
|
|
* an interrupted pass) comes after the judgement and keeps its figures; a new
|
|
* reason counts by default rather than hiding what the pass found.
|
|
*/
|
|
const NEVER_JUDGED: ReadonlySet<SkipReason> = new Set<SkipReason>([
|
|
'sources_missing',
|
|
'shared_location',
|
|
'foreign_install',
|
|
'owner_unknown',
|
|
'list_failed',
|
|
'source_failed',
|
|
]);
|
|
|
|
/**
|
|
* One category of the last check as a line, or null when it has nothing to
|
|
* say. A category the pass stopped in still counts what it found; one it
|
|
* never judged counts nothing.
|
|
*/
|
|
function lineOf(
|
|
category: ReclaimCategoryLine['category'],
|
|
report: StorageReclaimCategoryReport | undefined
|
|
): ReclaimCategoryLine | null {
|
|
if (!report || report.skipped === 'not_reclaimed') return null;
|
|
const judged = !NEVER_JUDGED.has(report.skipped);
|
|
// What was deleted on this run no longer waits to be freed.
|
|
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
|
|
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
|
|
const line = {
|
|
category,
|
|
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;
|
|
return quiet ? null : line;
|
|
}
|
|
|
|
/**
|
|
* 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, not running and never checked.
|
|
*/
|
|
export function reclaimView(state: StorageReclaimState | null): ReclaimView {
|
|
const run = state?.lastRun ?? null;
|
|
const lines = STORAGE_RECLAIM_CATEGORIES.flatMap((category) => lineOf(category, run?.categories[category]) ?? []);
|
|
const freeable = lines.reduce((sum, line) => add(sum, line.freeable), NONE);
|
|
const waiting = lines.reduce((sum, line) => add(sum, line.waiting), NONE);
|
|
const deleteRun = state?.lastDeleteRun ?? null;
|
|
return {
|
|
deleteEnabled: state?.deleteEnabled ?? false,
|
|
running: state?.running ?? false,
|
|
checkedAt: run?.finishedAt ?? null,
|
|
stopped: run?.stopped ?? null,
|
|
freeable,
|
|
waiting,
|
|
freed: freedBy(deleteRun),
|
|
freedAt: deleteRun?.finishedAt ?? null,
|
|
lines,
|
|
};
|
|
}
|