mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
feat(admin): add cache purge controls to settings
Add `purgeMetadataCache` and `purgeImageCache` procedures that let admins free disk space on demand. Metadata purge removes un-enriched stub titles not in any user's library plus orphaned person records; image purge deletes all cached TMDB files from disk. Expose both as POST endpoints under `/admin/cache/` via the oRPC contract, implement the core logic in a new `@sofa/core/cache` module, and surface them in a new "Danger Zone" section on the Settings page with individual confirmation dialogs and a combined "Purge all" action.
This commit is contained in:
@@ -8,6 +8,10 @@ import {
|
||||
listBackups,
|
||||
restoreFromBackup,
|
||||
} from "@sofa/core/backup";
|
||||
import {
|
||||
purgeImageCache as purgeImagesFn,
|
||||
purgeMetadataCache as purgeMetadataFn,
|
||||
} from "@sofa/core/cache";
|
||||
import { getSetting, setSetting } from "@sofa/core/settings";
|
||||
import { isTelemetryEnabled } from "@sofa/core/telemetry";
|
||||
import {
|
||||
@@ -159,3 +163,13 @@ export const triggerJob = os.admin.triggerJob
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// ─── Purge ────────────────────────────────────────────────────
|
||||
|
||||
export const purgeMetadataCache = os.admin.purgeMetadataCache
|
||||
.use(admin)
|
||||
.handler(() => purgeMetadataFn());
|
||||
|
||||
export const purgeImageCache = os.admin.purgeImageCache
|
||||
.use(admin)
|
||||
.handler(async () => purgeImagesFn());
|
||||
|
||||
@@ -81,6 +81,8 @@ export const implementedRouter = {
|
||||
telemetry: admin.telemetry,
|
||||
toggleTelemetry: admin.toggleTelemetry,
|
||||
triggerJob: admin.triggerJob,
|
||||
purgeMetadataCache: admin.purgeMetadataCache,
|
||||
purgeImageCache: admin.purgeImageCache,
|
||||
},
|
||||
account: {
|
||||
updateName: account.updateName,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { client, orpc } from "@/lib/orpc/client";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function CacheSection() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateHealth = () =>
|
||||
queryClient.invalidateQueries({ queryKey: orpc.system.health.key() });
|
||||
|
||||
const purgeMetadata = useMutation(
|
||||
orpc.admin.purgeMetadataCache.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Purged ${data.deletedTitles} stale title${data.deletedTitles !== 1 ? "s" : ""} and ${data.deletedPersons} orphaned person${data.deletedPersons !== 1 ? "s" : ""}`,
|
||||
);
|
||||
invalidateHealth();
|
||||
},
|
||||
onError: () => toast.error("Failed to purge metadata cache"),
|
||||
}),
|
||||
);
|
||||
|
||||
const purgeImages = useMutation(
|
||||
orpc.admin.purgeImageCache.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Deleted ${data.deletedFiles.toLocaleString()} file${data.deletedFiles !== 1 ? "s" : ""}, freed ${formatBytes(data.freedBytes)}`,
|
||||
);
|
||||
invalidateHealth();
|
||||
},
|
||||
onError: () => toast.error("Failed to purge image cache"),
|
||||
}),
|
||||
);
|
||||
|
||||
const purgeAll = useMutation({
|
||||
mutationFn: () =>
|
||||
Promise.all([
|
||||
client.admin.purgeMetadataCache(),
|
||||
client.admin.purgeImageCache(),
|
||||
]),
|
||||
onSuccess: ([metaResult, imageResult]) => {
|
||||
toast.success(
|
||||
`Purged ${metaResult.deletedTitles} title${metaResult.deletedTitles !== 1 ? "s" : ""}, ${metaResult.deletedPersons} person${metaResult.deletedPersons !== 1 ? "s" : ""}, ${imageResult.deletedFiles.toLocaleString()} file${imageResult.deletedFiles !== 1 ? "s" : ""} (${formatBytes(imageResult.freedBytes)} freed)`,
|
||||
);
|
||||
invalidateHealth();
|
||||
},
|
||||
onError: () => toast.error("Failed to purge caches"),
|
||||
});
|
||||
|
||||
const disabled =
|
||||
purgeMetadata.isPending || purgeImages.isPending || purgeAll.isPending;
|
||||
|
||||
return (
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconTrash aria-hidden={true} className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle>Cache management</CardTitle>
|
||||
<CardDescription>
|
||||
Free up disk space by clearing cached metadata and images
|
||||
</CardDescription>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{/* Purge metadata */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" disabled={disabled} />
|
||||
}
|
||||
>
|
||||
{purgeMetadata.isPending ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<IconDatabase aria-hidden={true} />
|
||||
)}
|
||||
{purgeMetadata.isPending ? "Purging…" : "Purge metadata"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Purge metadata cache?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete un-enriched stub titles that aren't in any
|
||||
user's library and clean up orphaned person records. Deleted
|
||||
titles will be re-imported if accessed again.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => purgeMetadata.mutate()}
|
||||
>
|
||||
Purge metadata
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Purge images */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" disabled={disabled} />
|
||||
}
|
||||
>
|
||||
{purgeImages.isPending ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<IconPhoto aria-hidden={true} />
|
||||
)}
|
||||
{purgeImages.isPending ? "Purging…" : "Purge images"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Purge image cache?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all cached TMDB images from disk. Images
|
||||
will be re-downloaded automatically as needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => purgeImages.mutate()}
|
||||
>
|
||||
Purge images
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Purge all */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button variant="destructive" size="sm" disabled={disabled} />
|
||||
}
|
||||
>
|
||||
{purgeAll.isPending ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<IconTrash aria-hidden={true} />
|
||||
)}
|
||||
{purgeAll.isPending ? "Purging…" : "Purge all"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Purge all caches?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all un-enriched stub titles and all cached
|
||||
images from disk. Everything will be re-imported and
|
||||
re-downloaded as needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => purgeAll.mutate()}
|
||||
>
|
||||
Purge all
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDatabaseExport,
|
||||
IconServer2,
|
||||
IconShieldLock,
|
||||
@@ -9,6 +10,7 @@ import { BackupRestoreSection } from "@/components/settings/backup-restore-secti
|
||||
import { BackupScheduleSection } from "@/components/settings/backup-schedule-section";
|
||||
import { BackupSection } from "@/components/settings/backup-section";
|
||||
import { ChangePasswordSection } from "@/components/settings/change-password-section";
|
||||
import { CacheSection } from "@/components/settings/danger-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { RegistrationSection } from "@/components/settings/registration-section";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
@@ -154,6 +156,25 @@ function SettingsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconAlertTriangle
|
||||
aria-hidden={true}
|
||||
className="size-4 text-destructive"
|
||||
/>
|
||||
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-[10px] text-primary">
|
||||
Admin only
|
||||
</span>
|
||||
</div>
|
||||
<Card className="border-l-2 border-l-primary/30">
|
||||
<CacheSection />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsShell>
|
||||
|
||||
Reference in New Issue
Block a user