Overhaul system health section with job trigger controls and live timestamps

Rebuild the background jobs card as a sortable table showing each job's
schedule, last run time (live-updating via a new useTimeAgo hook), last
duration, and a manual trigger button backed by a new POST
/api/admin/jobs/trigger route. Extract StatusDot into a shared
component. Add cronToHuman() to display schedule patterns as readable
strings (e.g. "Every 6h", "Daily at 03:00"). Replace static
formatDistanceToNow calls throughout the health section with a
LiveTimeAgo component that refreshes every 30 seconds. Also swap a
handful of section icons for better visual matches across settings cards.
This commit is contained in:
2026-03-05 11:52:37 -05:00
parent b474d07d97
commit 107eb9a9e7
18 changed files with 2698 additions and 260 deletions
-18
View File
@@ -59,28 +59,10 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
}
}, [provider, label, setConnections]);
const handleToggle = useCallback(
async (enabled: boolean) => {
const previous = connections;
setConnections((prev) =>
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
);
try {
await saveWebhookConnection(provider, enabled);
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
} catch {
setConnections(previous);
toast.error(`Failed to update ${label}`);
}
},
[provider, label, connections, setConnections],
);
return {
connection,
handleConnect,
handleDelete,
handleRegenerateToken,
handleToggle,
};
}
+27 -2
View File
@@ -51,6 +51,7 @@ function schedule(name: string, cron: string, handler: () => Promise<void>) {
name,
new Cron(cron, { name, protect: true }, async () => {
log.info(`Running job: ${name}`);
const startMs = performance.now();
const run = db
.insert(cronRuns)
.values({ jobName: name, status: "running", startedAt: new Date() })
@@ -58,16 +59,19 @@ function schedule(name: string, cron: string, handler: () => Promise<void>) {
.get();
try {
await handler();
const durationMs = Math.round(performance.now() - startMs);
db.update(cronRuns)
.set({ status: "success", finishedAt: new Date() })
.set({ status: "success", finishedAt: new Date(), durationMs })
.where(eq(cronRuns.id, run.id))
.run();
log.info(`Completed job: ${name}`);
log.info(`Completed job: ${name} (${durationMs}ms)`);
} catch (err) {
const durationMs = Math.round(performance.now() - startMs);
db.update(cronRuns)
.set({
status: "error",
finishedAt: new Date(),
durationMs,
errorMessage: err instanceof Error ? err.message : String(err),
})
.where(eq(cronRuns.id, run.id))
@@ -78,6 +82,27 @@ function schedule(name: string, cron: string, handler: () => Promise<void>) {
);
}
/** Get schedule metadata for all registered jobs */
export function getJobSchedules(): {
jobName: string;
pattern: string;
nextRunAt: string | null;
}[] {
return Array.from(jobs.entries()).map(([name, cron]) => ({
jobName: name,
pattern: cron.getPattern() ?? "",
nextRunAt: cron.nextRun()?.toISOString() ?? null,
}));
}
/** Manually trigger a job by name. Returns false if job not found. */
export async function triggerJob(name: string): Promise<boolean> {
const job = jobs.get(name);
if (!job) return false;
await job.trigger();
return true;
}
function getLibraryTitleIds(): string[] {
const rows = db
.select({ titleId: userTitleStatus.titleId })
+1
View File
@@ -350,6 +350,7 @@ export const cronRuns = sqliteTable(
}).notNull(),
startedAt: int("startedAt", { mode: "timestamp" }).notNull(),
finishedAt: int("finishedAt", { mode: "timestamp" }),
durationMs: int("durationMs"),
errorMessage: text("errorMessage"),
},
(table) => [
+14 -2
View File
@@ -32,6 +32,8 @@ export interface SystemHealthData {
};
jobs: {
jobName: string;
cronPattern: string | null;
nextRunAt: string | null;
lastRunAt: string | null;
lastDurationMs: number | null;
lastStatus: "running" | "success" | "error" | null;
@@ -143,6 +145,12 @@ async function getTmdbHealth(): Promise<SystemHealthData["tmdb"]> {
}
function getJobsHealth(): SystemHealthData["jobs"] {
// Lazy-import to avoid circular dependency issues at module level
const { getJobSchedules } =
require("@/lib/cron") as typeof import("@/lib/cron");
const schedules = getJobSchedules();
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
return JOB_NAMES.map((jobName) => {
const latest = db
.select()
@@ -153,14 +161,18 @@ function getJobsHealth(): SystemHealthData["jobs"] {
.get();
const isCurrentlyRunning = latest?.status === "running";
const schedule = scheduleMap.get(jobName);
let lastDurationMs: number | null = null;
if (latest?.finishedAt && latest.startedAt) {
// Prefer the in-memory durationMs column; fall back to timestamp diff
let lastDurationMs: number | null = latest?.durationMs ?? null;
if (lastDurationMs === null && latest?.finishedAt && latest.startedAt) {
lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime();
}
return {
jobName,
cronPattern: schedule?.pattern ?? null,
nextRunAt: schedule?.nextRunAt ?? null,
lastRunAt: latest?.startedAt?.toISOString() ?? null,
lastDurationMs,
lastStatus: (latest?.status as "running" | "success" | "error") ?? null,