mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user