Show disabled jobs properly in admin health dashboard

- Backup and update check jobs show as "disabled" (not "succeeded") when
  their respective settings are off
- Disabled jobs excluded from "x of y jobs healthy" count, sorted to
  bottom of list, with schedule/next-run hidden and trigger button disabled
- Active jobs sorted by next run time
- "Never run" uses amber dot to differentiate from gray "disabled" dot
- Extract RefreshButton component and add it to all three health cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:02:04 -05:00
co-authored by Claude Opus 4.6
parent fb24b25d34
commit a263a6536a
2 changed files with 95 additions and 48 deletions
@@ -161,22 +161,7 @@ export function SystemHealthCards() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<Tooltip> <RefreshButton isValidating={isValidating} onRefresh={refresh} />
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label="Refresh system health"
onClick={() => refresh()}
disabled={isValidating}
/>
}
>
{isValidating ? <Spinner /> : <IconRefresh />}
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</div> </div>
</CardContent> </CardContent>
@@ -273,24 +258,31 @@ export function SystemHealthCards() {
</Card> </Card>
{/* ── Card 2: Background Jobs ── */} {/* ── Card 2: Background Jobs ── */}
<BackgroundJobsCard jobs={data.jobs} onRefresh={refresh} /> <BackgroundJobsCard
jobs={data.jobs}
isValidating={isValidating}
onRefresh={refresh}
/>
{/* ── Card 3: Storage ── */} {/* ── Card 3: Storage ── */}
<Card className="border-l-2 border-l-primary/30"> <Card className="border-l-2 border-l-primary/30">
<CardContent> <CardContent>
<div className="flex items-start gap-3"> <div className="flex items-center justify-between gap-4">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10"> <div className="flex items-start gap-3">
<IconDatabase <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
aria-hidden={true} <IconDatabase
className="size-4 text-primary" aria-hidden={true}
/> className="size-4 text-primary"
</div> />
<div> </div>
<CardTitle>Storage</CardTitle> <div>
<CardDescription> <CardTitle>Storage</CardTitle>
Image cache and backup disk usage <CardDescription>
</CardDescription> Image cache and backup disk usage
</CardDescription>
</div>
</div> </div>
<RefreshButton isValidating={isValidating} onRefresh={refresh} />
</div> </div>
</CardContent> </CardContent>
@@ -360,12 +352,41 @@ export function SystemHealthCards() {
); );
} }
function RefreshButton({
isValidating,
onRefresh,
}: {
isValidating: boolean;
onRefresh: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label="Refresh system health"
onClick={onRefresh}
disabled={isValidating}
/>
}
>
{isValidating ? <Spinner /> : <IconRefresh />}
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
);
}
/** Background Jobs card with table layout and manual trigger */ /** Background Jobs card with table layout and manual trigger */
function BackgroundJobsCard({ function BackgroundJobsCard({
jobs, jobs,
isValidating,
onRefresh, onRefresh,
}: { }: {
jobs: SystemHealthData["jobs"]; jobs: SystemHealthData["jobs"];
isValidating: boolean;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const [triggeringJob, setTriggeringJob] = useState<string | null>(null); const [triggeringJob, setTriggeringJob] = useState<string | null>(null);
@@ -392,24 +413,39 @@ function BackgroundJobsCard({
} }
}; };
const healthyCount = jobs.filter((j) => j.lastStatus === "success").length; const sortedJobs = [...jobs].sort((a, b) => {
// Disabled jobs go to the bottom
if (a.disabled !== b.disabled) return a.disabled ? 1 : -1;
// Active jobs sorted by next run time (soonest first, null last)
if (!a.nextRunAt && !b.nextRunAt) return 0;
if (!a.nextRunAt) return 1;
if (!b.nextRunAt) return -1;
return new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime();
});
const activeJobs = jobs.filter((j) => !j.disabled);
const healthyCount = activeJobs.filter(
(j) => j.lastStatus === "success",
).length;
return ( return (
<Card className="border-l-2 border-l-primary/30"> <Card className="border-l-2 border-l-primary/30">
<CardContent> <CardContent>
<div className="flex items-start gap-3"> <div className="flex items-center justify-between gap-4">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10"> <div className="flex items-start gap-3">
<IconCalendarCheck <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
aria-hidden={true} <IconCalendarCheck
className="size-4 text-primary" aria-hidden={true}
/> className="size-4 text-primary"
</div> />
<div> </div>
<CardTitle>Background jobs</CardTitle> <div>
<CardDescription> <CardTitle>Background jobs</CardTitle>
{healthyCount} of {jobs.length} jobs healthy <CardDescription>
</CardDescription> {healthyCount} of {activeJobs.length} jobs healthy
</CardDescription>
</div>
</div> </div>
<RefreshButton isValidating={isValidating} onRefresh={onRefresh} />
</div> </div>
</CardContent> </CardContent>
<CardContent className="border-t border-border/30 px-0 pt-0 pb-0"> <CardContent className="border-t border-border/30 px-0 pt-0 pb-0">
@@ -434,7 +470,7 @@ function BackgroundJobsCard({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{jobs.map((job) => { {sortedJobs.map((job) => {
const isTriggering = triggeringJob === job.jobName; const isTriggering = triggeringJob === job.jobName;
const isRunning = job.isCurrentlyRunning || isTriggering; const isRunning = job.isCurrentlyRunning || isTriggering;
@@ -446,10 +482,12 @@ function BackgroundJobsCard({
{/* Job name + status */} {/* Job name + status */}
<TableCell className="pl-5"> <TableCell className="pl-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isRunning ? ( {job.disabled ? (
<StatusDot status="inactive" label="Disabled" />
) : isRunning ? (
<Spinner className="size-2.5" /> <Spinner className="size-2.5" />
) : job.lastStatus === null ? ( ) : job.lastStatus === null ? (
<StatusDot status="inactive" label="Never run" /> <StatusDot status="warn" label="Never run" />
) : job.lastStatus === "success" ? ( ) : job.lastStatus === "success" ? (
<StatusDot status="ok" label="Last run succeeded" /> <StatusDot status="ok" label="Last run succeeded" />
) : ( ) : (
@@ -553,7 +591,7 @@ function BackgroundJobsCard({
size="icon" size="icon"
aria-label="Trigger job" aria-label="Trigger job"
className="size-6" className="size-6"
disabled={isRunning} disabled={isRunning || job.disabled}
onClick={() => handleTrigger(job.jobName)} onClick={() => handleTrigger(job.jobName)}
/> />
} }
+11 -2
View File
@@ -5,6 +5,8 @@ import { db } from "@/lib/db/client";
import { cronRuns, episodes, titles, user } from "@/lib/db/schema"; import { cronRuns, episodes, titles, user } from "@/lib/db/schema";
import { listBackups } from "@/lib/services/backup"; import { listBackups } from "@/lib/services/backup";
import { imageCacheEnabled } from "@/lib/services/image-cache"; import { imageCacheEnabled } from "@/lib/services/image-cache";
import { getSetting } from "@/lib/services/settings";
import { isUpdateCheckEnabled } from "@/lib/services/update-check";
const DATA_DIR = process.env.DATA_DIR || "./data"; const DATA_DIR = process.env.DATA_DIR || "./data";
const DATABASE_URL = const DATABASE_URL =
@@ -39,6 +41,7 @@ export interface SystemHealthData {
lastStatus: "running" | "success" | "error" | null; lastStatus: "running" | "success" | "error" | null;
lastError: string | null; lastError: string | null;
isCurrentlyRunning: boolean; isCurrentlyRunning: boolean;
disabled: boolean;
}[]; }[];
imageCache: { imageCache: {
enabled: boolean; enabled: boolean;
@@ -175,15 +178,21 @@ function getJobsHealth(): SystemHealthData["jobs"] {
lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime(); lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime();
} }
const disabled =
(jobName === "scheduledBackup" &&
getSetting("scheduledBackups") !== "true") ||
(jobName === "updateCheck" && !isUpdateCheckEnabled());
return { return {
jobName, jobName,
cronPattern: schedule?.pattern ?? null, cronPattern: disabled ? null : (schedule?.pattern ?? null),
nextRunAt: schedule?.nextRunAt ?? null, nextRunAt: disabled ? null : (schedule?.nextRunAt ?? null),
lastRunAt: latest?.startedAt?.toISOString() ?? null, lastRunAt: latest?.startedAt?.toISOString() ?? null,
lastDurationMs, lastDurationMs,
lastStatus: (latest?.status as "running" | "success" | "error") ?? null, lastStatus: (latest?.status as "running" | "success" | "error") ?? null,
lastError: latest?.errorMessage ?? null, lastError: latest?.errorMessage ?? null,
isCurrentlyRunning, isCurrentlyRunning,
disabled,
}; };
}); });
} }