commit 348996a430126178141007a3ea851630b5e24a68
parent 21fc5d95ce0753073c9ade1c26824e21e2e0a787
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 18 Jun 2026 22:04:35 -0400
Add persistent /jobs filters and persist archived-job metadata
The jobs screen now has a client-side filter bar (by kind, by status, and a
text search over id/channel/video) whose state persists in localStorage under
"ytdlp-tb:jobs-filters". The noisy refresh-report report-regen job is hidden by
default; a Reset button and a "Showing N of M · K hidden" summary keep the
active filtering obvious. Filters apply post-mount so the first paint matches
the server HTML (no hydration mismatch).
Separately, each job now writes a small <id>.meta.json sidecar next to its log
capturing kind/channel/video/status/timings. The in-memory registry keeps only
the 100 most-recent finished jobs and is wiped on restart, so once a job is
evicted listAllJobs could only call it "archived" with a bare id. The sidecar
lets archived jobs keep showing their real metadata and duration. Writes are
best-effort and never delay or break a job; Clear archived removes the sidecars
alongside the logs.
Covered by editor/e2e/jobs-filters.spec.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 654 insertions(+), 133 deletions(-)
diff --git a/common/jobs/jobMeta.ts b/common/jobs/jobMeta.ts
@@ -0,0 +1,77 @@
+import path from "node:path";
+import { readFile, writeFile } from "node:fs/promises";
+import type { Paths } from "../lib/paths";
+import type { JobRecord, JobStatus } from "./registry";
+
+// A serializable sidecar (`<id>.meta.json`) written alongside each job's
+// `<id>.log`. The in-memory registry keeps only the 100 most-recent finished
+// jobs and is wiped on restart, so once a job is evicted its `.log` is all
+// that's left and listJobs can only call it "archived". The sidecar preserves
+// the identifying/terminal fields so an archived job still shows its kind,
+// channel, status, duration, etc. Both writes are best-effort and must never
+// break a job (see writeJobMeta).
+export type JobMeta = {
+ id: string;
+ kind: string;
+ queueKey: string;
+ channelSlug?: string;
+ videoId?: string;
+ status: JobStatus;
+ queuedAt: number;
+ startedAt?: number;
+ endedAt?: number;
+ exitCode?: number;
+};
+
+export function metaPath(paths: Paths, id: string): string {
+ return path.join(paths.jobsDir, `${id}.meta.json`);
+}
+
+// Serialize the meta subset of a record and write it. Non-throwing in the
+// spirit of ignoreFileStreamErrors: a failed sidecar write must never delay or
+// break the job, so errors are swallowed.
+export async function writeJobMeta(
+ paths: Paths,
+ record: JobRecord,
+): Promise<void> {
+ try {
+ const meta: JobMeta = {
+ id: record.id,
+ kind: record.kind,
+ queueKey: record.queueKey,
+ channelSlug: record.channelSlug,
+ videoId: record.videoId,
+ status: record.status,
+ queuedAt: record.queuedAt,
+ startedAt: record.startedAt,
+ endedAt: record.endedAt,
+ exitCode: record.exitCode,
+ };
+ await writeFile(metaPath(paths, record.id), JSON.stringify(meta), "utf8");
+ } catch {
+ /* best-effort: a failed sidecar write must never break a job */
+ }
+}
+
+// Read a job's sidecar, returning null on any error (missing file, malformed
+// JSON, or a payload missing the minimal identifying fields).
+export async function readJobMeta(
+ paths: Paths,
+ id: string,
+): Promise<JobMeta | null> {
+ let raw: string;
+ try {
+ raw = await readFile(metaPath(paths, id), "utf8");
+ } catch {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object") return null;
+ const m = parsed as Record<string, unknown>;
+ if (typeof m.id !== "string" || typeof m.kind !== "string") return null;
+ return parsed as JobMeta;
+ } catch {
+ return null;
+ }
+}
diff --git a/common/jobs/listJobs.ts b/common/jobs/listJobs.ts
@@ -2,6 +2,7 @@ import path from "node:path";
import { readdir, stat, readFile, rm } from "node:fs/promises";
import type { Paths } from "../lib/paths";
import { getRegistry, type JobRecord, type JobStatus } from "./registry";
+import { metaPath, readJobMeta } from "./jobMeta";
export type JobListEntry = {
id: string;
@@ -19,6 +20,12 @@ export type JobListEntry = {
logSize: number;
};
+const TERMINAL_STATUSES: ReadonlySet<JobStatus> = new Set([
+ "done",
+ "failed",
+ "cancelled",
+]);
+
// Merge in-memory registry entries with any leftover .log files from
// previous server lifetimes. Anything not in the registry is treated as
// "archived" — we only know that it once ran, not whether it succeeded.
@@ -68,16 +75,40 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> {
logSize,
});
} else {
- out.push({
- id,
- status: "archived",
- queuedAt: mtime,
- startedAt: mtime,
- endedAt: mtime,
- inRegistry: false,
- logPath,
- logSize,
- });
+ // Not in the registry: recover what we can from the sidecar so an evicted
+ // or post-restart job still shows its kind/channel/status/duration. With
+ // no sidecar (old logs), fall back to the mtime-based minimal entry. A
+ // non-terminal sidecar status means the job isn't actually live (it's not
+ // in the registry), so report it as "archived".
+ const meta = await readJobMeta(paths, id);
+ if (meta) {
+ out.push({
+ id,
+ kind: meta.kind,
+ channelSlug: meta.channelSlug,
+ videoId: meta.videoId,
+ queueKey: meta.queueKey,
+ status: TERMINAL_STATUSES.has(meta.status) ? meta.status : "archived",
+ queuedAt: meta.queuedAt,
+ startedAt: meta.startedAt,
+ endedAt: meta.endedAt,
+ exitCode: meta.exitCode,
+ inRegistry: false,
+ logPath,
+ logSize,
+ });
+ } else {
+ out.push({
+ id,
+ status: "archived",
+ queuedAt: mtime,
+ startedAt: mtime,
+ endedAt: mtime,
+ inRegistry: false,
+ logPath,
+ logSize,
+ });
+ }
}
}
@@ -135,6 +166,8 @@ export async function clearArchivedLogs(paths: Paths): Promise<number> {
const id = file.replace(/\.log$/, "");
if (live.has(id)) continue;
await rm(path.join(paths.jobsDir, file), { force: true });
+ // Drop the sidecar too, but only count deleted .log files.
+ await rm(metaPath(paths, id), { force: true });
deleted++;
}
return deleted;
diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts
@@ -15,6 +15,7 @@ import {
requestChannelSnapshot,
shouldRequestSnapshot,
} from "./snapshotScheduler";
+import { writeJobMeta } from "./jobMeta";
// Mark a job's channel report dirty so the debounced scheduler regenerates the
// snapshot — called both on each completed sub-operation and on the job's
@@ -180,6 +181,8 @@ export async function runManagedCommand(
fileStream?.end();
safe.safeClose();
requestSnapshotOnFinish(id, opts);
+ // Persist terminal state (status/endedAt/exitCode now set by finalize).
+ void writeJobMeta(opts.paths, record);
});
};
@@ -189,6 +192,9 @@ export async function runManagedCommand(
};
registry.enqueue(record, { start, onCancel });
+ // Persist queued/running identity up front so a mid-run crash still leaves a
+ // sidecar; the .finally above rewrites it with the terminal state.
+ void writeJobMeta(opts.paths, record);
return { ok: true, jobId: id, stream };
}
@@ -282,6 +288,8 @@ export async function runManagedFunction(
fileStream?.end();
safe.safeClose();
requestSnapshotOnFinish(id, opts);
+ // Persist terminal state (status/endedAt/exitCode now set by finalize).
+ void writeJobMeta(opts.paths, record);
});
};
@@ -291,5 +299,8 @@ export async function runManagedFunction(
};
registry.enqueue(record, { start, onCancel });
+ // Persist queued/running identity up front so a mid-run crash still leaves a
+ // sidecar; the .finally above rewrites it with the terminal state.
+ void writeJobMeta(opts.paths, record);
return { ok: true, jobId: id, stream };
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **The Jobs screen has persistent filters, and archived jobs keep their details.** A filter bar atop `/jobs` lets you hide jobs by **kind** and **status** and **search** by id / channel / video; the choices persist in `localStorage` and, by default, hide the noisy `refresh-report` report-regen job so the list shows the work you actually triggered (a **Reset filters** button restores the defaults, and a `Showing N of M · K hidden` summary makes the active filtering obvious). Separately, each job now writes a small `<id>.meta.json` sidecar next to its log capturing its kind, channel, video, status, and timings — so once the in-memory registry evicts it (it keeps only the 100 most-recent finished jobs) or the server restarts, an **archived** job still shows that metadata instead of a bare id. The sidecar writes are best-effort and never delay or break a job, and **Clear archived** removes the sidecars along with the logs. See `common/jobs/jobMeta.ts`.
- **The Workers page can save the current arrangement as a launch default, and "Stop & keep progress" now takes the worker out of rotation.** A **Set as default** button (top of `/workers`) snapshots which workers are enabled right now; on the next server launch the pool starts exactly those workers enabled and **every other worker disabled** — including workers added later that aren't in the saved set. This persists the otherwise-transient runtime on/off state across restarts without touching `settings.json` (it's a small `transcripts/.workers/defaults.json` the pool reads on first use). Once a default is saved the button reads **Update default** and each included worker shows a small **default** badge; the default governs the launch-time seed only, so editing a worker's Enabled flag in Settings still takes effect as before. Separately, **Stop & keep progress** now drains the worker after finishing the current window (it ends *disabled*) instead of leaving it enabled to immediately grab the next video — matching **Drain**, which already ended disabled. See `common/jobs/workerDefaults.ts`.
- **Downloads stop and stay blocked when free disk space runs low.** A new **Settings → Minimum free disk space (GB)** floor (default **5 GB**; set **0** to disable) guards every download against filling the disk. When free space on the transcripts data directory is at or below the floor, a download job is **prevented from starting** — the channel/import action returns a clear "Low disk space: X free, Y required" error instead of queuing — and a **running batch stops between videos**: the in-flight download finishes, no new ones start, and the batch ends cleanly (status *done*, partial progress preserved) rather than crashing into an out-of-space error mid-file. Free space is measured natively (`statfs`, no new dependency) and the check **fails open** — if it can't read the filesystem, downloads proceed rather than being wrongly blocked. The monitor widget (and `/api/jobs/active`) gained a compact disk indicator showing free space, which turns red and reads "downloads paused" when below the floor (and stays visible even when idle, so it explains why nothing is downloading). `store-playlist` (which writes no media) is not gated. See `common/lib/diskSpace.ts`.
- **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, an **Open popup** button that launches the widget in a chrome-less `window.open` popup (a tab-less window) at the selected preview size, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too.
diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx
@@ -0,0 +1,289 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import type { JobListEntry } from "yt-dlp-transcript-common/jobs/listJobs";
+import { CancelJobButton } from "./CancelJobButton";
+import {
+ clearJobsFilters,
+ defaultJobsFilters,
+ loadJobsFilters,
+ saveJobsFilters,
+ type JobsFilterState,
+} from "../jobsFilterStorage";
+
+function fmtDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ const s = ms / 1000;
+ if (s < 60) return `${s.toFixed(1)}s`;
+ const m = s / 60;
+ return `${m.toFixed(1)}m`;
+}
+
+function statusColor(status: string): string {
+ switch (status) {
+ case "queued":
+ return "bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200";
+ case "running":
+ return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
+ case "done":
+ return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
+ case "failed":
+ return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
+ case "cancelled":
+ return "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200";
+ default:
+ return "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
+ }
+}
+
+// The jobs list table with a persistent, client-side filter bar. Hydration
+// safety: the server renders every job (filters === null), and stored filters
+// are only applied after mount in an effect — so the first client paint matches
+// the server HTML and refresh-report rows don't flicker in before being hidden.
+export function JobsTable({ jobs }: { jobs: JobListEntry[] }) {
+ // null until mount → render everything (matches server HTML).
+ const [filters, setFilters] = useState<JobsFilterState | null>(null);
+
+ useEffect(() => {
+ setFilters(loadJobsFilters());
+ }, []);
+
+ function update(next: JobsFilterState): void {
+ setFilters(next);
+ saveJobsFilters(next);
+ }
+
+ function toggleKind(kind: string): void {
+ if (!filters) return;
+ const hidden = filters.hiddenKinds.includes(kind)
+ ? filters.hiddenKinds.filter((k) => k !== kind)
+ : [...filters.hiddenKinds, kind];
+ update({ ...filters, hiddenKinds: hidden });
+ }
+
+ function toggleStatus(status: string): void {
+ if (!filters) return;
+ const hidden = filters.hiddenStatuses.includes(status)
+ ? filters.hiddenStatuses.filter((s) => s !== status)
+ : [...filters.hiddenStatuses, status];
+ update({ ...filters, hiddenStatuses: hidden });
+ }
+
+ function reset(): void {
+ clearJobsFilters();
+ setFilters(defaultJobsFilters());
+ }
+
+ const kinds = useMemo(() => {
+ const set = new Set<string>();
+ for (const j of jobs) if (j.kind) set.add(j.kind);
+ return Array.from(set).sort();
+ }, [jobs]);
+
+ const statuses = useMemo(() => {
+ const set = new Set<string>();
+ for (const j of jobs) set.add(j.status);
+ return Array.from(set).sort();
+ }, [jobs]);
+
+ const visible = useMemo(() => {
+ if (!filters) return jobs;
+ const q = filters.search.trim().toLowerCase();
+ return jobs.filter((j) => {
+ if (j.kind && filters.hiddenKinds.includes(j.kind)) return false;
+ if (filters.hiddenStatuses.includes(j.status)) return false;
+ if (q) {
+ const hay = [j.id, j.channelSlug, j.videoId]
+ .filter((x): x is string => !!x)
+ .join(" ")
+ .toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ return true;
+ });
+ }, [jobs, filters]);
+
+ const hiddenCount = jobs.length - visible.length;
+
+ return (
+ <div className="flex flex-col gap-3">
+ <div className="flex flex-col gap-2 text-sm">
+ {kinds.length > 0 && (
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="text-xs uppercase tracking-wide text-zinc-500">
+ kind
+ </span>
+ {kinds.map((kind) => {
+ const hidden = filters?.hiddenKinds.includes(kind) ?? false;
+ return (
+ <button
+ key={kind}
+ type="button"
+ onClick={() => toggleKind(kind)}
+ className={`px-2 py-0.5 rounded text-xs border ${
+ hidden
+ ? "border-zinc-300 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600 line-through"
+ : "border-zinc-400 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-800 text-zinc-800 dark:text-zinc-200"
+ }`}
+ >
+ {kind}
+ </button>
+ );
+ })}
+ </div>
+ )}
+ {statuses.length > 0 && (
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="text-xs uppercase tracking-wide text-zinc-500">
+ status
+ </span>
+ {statuses.map((status) => {
+ const hidden = filters?.hiddenStatuses.includes(status) ?? false;
+ return (
+ <button
+ key={status}
+ type="button"
+ onClick={() => toggleStatus(status)}
+ className={`px-2 py-0.5 rounded text-xs uppercase tracking-wide border ${
+ hidden
+ ? "border-zinc-300 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600 line-through"
+ : statusColor(status)
+ }`}
+ >
+ {status}
+ </button>
+ );
+ })}
+ </div>
+ )}
+ <div className="flex flex-wrap items-center gap-3">
+ <input
+ type="search"
+ value={filters?.search ?? ""}
+ onChange={(e) =>
+ filters && update({ ...filters, search: e.target.value })
+ }
+ placeholder="Search id / channel / video…"
+ aria-label="Search jobs"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ <span className="text-xs text-zinc-500">
+ Showing {visible.length} of {jobs.length}
+ {hiddenCount > 0 && ` · ${hiddenCount} hidden`}
+ </span>
+ <button
+ type="button"
+ onClick={reset}
+ className="px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ Reset filters
+ </button>
+ </div>
+ </div>
+
+ <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full">
+ <thead className="bg-zinc-100 dark:bg-zinc-900">
+ <tr>
+ <th className="text-left font-medium px-3 py-2">ID</th>
+ <th className="text-left font-medium px-3 py-2">Kind</th>
+ <th className="text-left font-medium px-3 py-2">Channel</th>
+ <th className="text-left font-medium px-3 py-2">Video</th>
+ <th className="text-left font-medium px-3 py-2">Queue</th>
+ <th className="text-left font-medium px-3 py-2">Status</th>
+ <th className="text-left font-medium px-3 py-2">Started</th>
+ <th className="text-left font-medium px-3 py-2">Duration</th>
+ <th className="text-right font-medium px-3 py-2 whitespace-nowrap">Log size</th>
+ <th className="px-3 py-2"></th>
+ </tr>
+ </thead>
+ <tbody>
+ {visible.map((j) => {
+ const startedAt = j.startedAt ?? j.queuedAt;
+ const dur = j.endedAt
+ ? j.endedAt - startedAt
+ : j.startedAt
+ ? Date.now() - j.startedAt
+ : 0;
+ return (
+ <tr
+ key={j.id}
+ className="border-t border-zinc-200 dark:border-zinc-800"
+ >
+ <td className="px-3 py-2 font-mono text-xs">
+ <Link
+ href={`/jobs/${j.id}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {j.id}
+ </Link>
+ </td>
+ <td className="px-3 py-2">{j.kind ?? "—"}</td>
+ <td className="px-3 py-2 font-mono text-xs">
+ {j.channelSlug ? (
+ <Link
+ href={`/channels/${j.channelSlug}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {j.channelSlug}
+ </Link>
+ ) : (
+ "—"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs">
+ {j.channelSlug && j.videoId ? (
+ <Link
+ href={`/channels/${j.channelSlug}/videos/${encodeURIComponent(j.videoId)}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {j.videoId}
+ </Link>
+ ) : (
+ "—"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs">
+ {j.queueKey === undefined
+ ? "—"
+ : j.queueKey === ""
+ ? "(immediate)"
+ : j.queueKey}
+ </td>
+ <td className="px-3 py-2">
+ <span
+ className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
+ >
+ {j.status}
+ </span>
+ </td>
+ <td
+ className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap"
+ suppressHydrationWarning
+ >
+ {new Date(startedAt).toLocaleString()}
+ </td>
+ <td
+ className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap"
+ suppressHydrationWarning
+ >
+ {fmtDuration(dur)}
+ </td>
+ <td className="px-3 py-2 text-xs text-zinc-500 text-right whitespace-nowrap">
+ {j.logSize.toLocaleString()} B
+ </td>
+ <td className="px-3 py-2 text-right">
+ {(j.status === "running" || j.status === "queued") && (
+ <CancelJobButton jobId={j.id} />
+ )}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/jobs/jobsFilterStorage.ts b/editor/app/jobs/jobsFilterStorage.ts
@@ -0,0 +1,92 @@
+// localStorage layer for the /jobs screen's client-side filter bar. Strict
+// parser — any malformed/wrong-version value resets to defaults, mirroring
+// common/components/exportFilterStorage.ts. The defaults hide the noisy
+// refresh-report kind, so the table reads as the work you actually triggered.
+
+const KEY = "ytdlp-tb:jobs-filters";
+const VERSION = 1;
+
+// Kinds hidden out of the box. refresh-report is the debounced report-regen job
+// that fires after almost every action, so it dominates the list otherwise.
+export const DEFAULT_HIDDEN_KINDS = ["refresh-report"];
+
+export type JobsFilterState = {
+ v: 1;
+ hiddenKinds: string[];
+ hiddenStatuses: string[];
+ search: string;
+};
+
+export function defaultJobsFilters(): JobsFilterState {
+ return {
+ v: VERSION,
+ hiddenKinds: [...DEFAULT_HIDDEN_KINDS],
+ hiddenStatuses: [],
+ search: "",
+ };
+}
+
+function isStringArray(v: unknown): v is string[] {
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
+}
+
+function parseStored(raw: string): JobsFilterState | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (!parsed || typeof parsed !== "object") return null;
+ const r = parsed as Record<string, unknown>;
+ if (r.v !== VERSION) return null;
+ if (!isStringArray(r.hiddenKinds)) return null;
+ if (!isStringArray(r.hiddenStatuses)) return null;
+ if (typeof r.search !== "string") return null;
+ return {
+ v: VERSION,
+ hiddenKinds: r.hiddenKinds.slice(),
+ hiddenStatuses: r.hiddenStatuses.slice(),
+ search: r.search,
+ };
+}
+
+export function loadJobsFilters(): JobsFilterState {
+ if (typeof window === "undefined") return defaultJobsFilters();
+ let raw: string | null = null;
+ try {
+ raw = window.localStorage.getItem(KEY);
+ } catch {
+ return defaultJobsFilters();
+ }
+ if (raw === null) return defaultJobsFilters();
+ const parsed = parseStored(raw);
+ if (!parsed) {
+ // Reset on malformed payloads per spec.
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+ return defaultJobsFilters();
+ }
+ return parsed;
+}
+
+export function saveJobsFilters(state: JobsFilterState): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(KEY, JSON.stringify(state));
+ } catch {
+ /* quota/serialization errors are non-fatal */
+ }
+}
+
+export function clearJobsFilters(): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx
@@ -1,39 +1,13 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { ClearArchivedButton } from "./components/ClearArchivedButton";
-import { CancelJobButton } from "./components/CancelJobButton";
+import { JobsTable } from "./components/JobsTable";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Jobs" };
-function fmtDuration(ms: number): string {
- if (ms < 1000) return `${ms}ms`;
- const s = ms / 1000;
- if (s < 60) return `${s.toFixed(1)}s`;
- const m = s / 60;
- return `${m.toFixed(1)}m`;
-}
-
-function statusColor(status: string): string {
- switch (status) {
- case "queued":
- return "bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200";
- case "running":
- return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
- case "done":
- return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
- case "failed":
- return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
- case "cancelled":
- return "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200";
- default:
- return "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
- }
-}
-
export default async function JobsPage() {
const jobs = await listAllJobs(getPaths());
return (
@@ -47,102 +21,7 @@ export default async function JobsPage() {
No jobs have run yet.
</p>
) : (
- <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
- <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full">
- <thead className="bg-zinc-100 dark:bg-zinc-900">
- <tr>
- <th className="text-left font-medium px-3 py-2">ID</th>
- <th className="text-left font-medium px-3 py-2">Kind</th>
- <th className="text-left font-medium px-3 py-2">Channel</th>
- <th className="text-left font-medium px-3 py-2">Video</th>
- <th className="text-left font-medium px-3 py-2">Queue</th>
- <th className="text-left font-medium px-3 py-2">Status</th>
- <th className="text-left font-medium px-3 py-2">Started</th>
- <th className="text-left font-medium px-3 py-2">Duration</th>
- <th className="text-right font-medium px-3 py-2 whitespace-nowrap">Log size</th>
- <th className="px-3 py-2"></th>
- </tr>
- </thead>
- <tbody>
- {jobs.map((j) => {
- const startedAt = j.startedAt ?? j.queuedAt;
- const dur = j.endedAt
- ? j.endedAt - startedAt
- : j.startedAt
- ? Date.now() - j.startedAt
- : 0;
- return (
- <tr
- key={j.id}
- className="border-t border-zinc-200 dark:border-zinc-800"
- >
- <td className="px-3 py-2 font-mono text-xs">
- <Link
- href={`/jobs/${j.id}`}
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- {j.id}
- </Link>
- </td>
- <td className="px-3 py-2">{j.kind ?? "—"}</td>
- <td className="px-3 py-2 font-mono text-xs">
- {j.channelSlug ? (
- <Link
- href={`/channels/${j.channelSlug}`}
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- {j.channelSlug}
- </Link>
- ) : (
- "—"
- )}
- </td>
- <td className="px-3 py-2 font-mono text-xs">
- {j.channelSlug && j.videoId ? (
- <Link
- href={`/channels/${j.channelSlug}/videos/${encodeURIComponent(j.videoId)}`}
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- {j.videoId}
- </Link>
- ) : (
- "—"
- )}
- </td>
- <td className="px-3 py-2 font-mono text-xs">
- {j.queueKey === undefined
- ? "—"
- : j.queueKey === ""
- ? "(immediate)"
- : j.queueKey}
- </td>
- <td className="px-3 py-2">
- <span
- className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
- >
- {j.status}
- </span>
- </td>
- <td className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap">
- {new Date(startedAt).toLocaleString()}
- </td>
- <td className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap">
- {fmtDuration(dur)}
- </td>
- <td className="px-3 py-2 text-xs text-zinc-500 text-right whitespace-nowrap">
- {j.logSize.toLocaleString()} B
- </td>
- <td className="px-3 py-2 text-right">
- {(j.status === "running" || j.status === "queued") && (
- <CancelJobButton jobId={j.id} />
- )}
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
+ <JobsTable jobs={jobs} />
)}
</div>
);
diff --git a/editor/e2e/jobs-filters.spec.ts b/editor/e2e/jobs-filters.spec.ts
@@ -0,0 +1,139 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { resetData, resolvePath } from "./helpers";
+
+// The /jobs screen reads `.log` files in TRANSCRIPTS_DIR/.jobs and enriches any
+// not in the in-memory registry from a `<id>.meta.json` sidecar (see
+// common/jobs/jobMeta.ts + listJobs.ts). resetData() wipes the dir and resets
+// the registry, so writing a log + sidecar pair gives a deterministic
+// "archived" row — exercising both the metadata-read path and giving rows with
+// known kinds/statuses for the client-side filter bar.
+const JOBS_DIR = "test-transcripts/.jobs";
+
+type SeedJob = {
+ id: string;
+ kind: string;
+ status: "done" | "failed" | "cancelled";
+ channelSlug?: string;
+ videoId?: string;
+ queueKey: string;
+ queuedAt: number;
+ startedAt?: number;
+ endedAt?: number;
+ exitCode?: number;
+};
+
+async function seedJob(job: SeedJob): Promise<void> {
+ const dir = resolvePath(JOBS_DIR);
+ await mkdir(dir, { recursive: true });
+ // listAllJobs enumerates `.log` files; the sidecar enriches each one.
+ await writeFile(resolvePath(`${JOBS_DIR}/${job.id}.log`), `log for ${job.id}\n`);
+ await writeFile(
+ resolvePath(`${JOBS_DIR}/${job.id}.meta.json`),
+ JSON.stringify(job),
+ );
+}
+
+function rowById(page: import("@playwright/test").Page, id: string) {
+ return page.getByRole("row").filter({ hasText: id });
+}
+
+test("archived jobs keep their metadata from the sidecar", async ({ page }) => {
+ await resetData();
+ await seedJob({
+ id: "meta-sync-1",
+ kind: "sync",
+ status: "done",
+ channelSlug: "alpha",
+ queueKey: "alpha",
+ queuedAt: 1000,
+ startedAt: 1000,
+ endedAt: 3000,
+ exitCode: 0,
+ });
+
+ await page.goto("/jobs");
+ const row = rowById(page, "meta-sync-1");
+ // Kind, status and the real duration all come from the sidecar — without it
+ // an archived row would show no kind and a 0ms (mtime-based) duration.
+ await expect(row).toContainText("sync");
+ await expect(row).toContainText("done");
+ await expect(row).toContainText("2.0s");
+ await expect(row.getByRole("link", { name: "alpha" })).toHaveAttribute(
+ "href",
+ "/channels/alpha",
+ );
+});
+
+test("filters hide refresh-report by default, persist, and reset", async ({
+ page,
+}) => {
+ await resetData();
+ await seedJob({
+ id: "rr-1",
+ kind: "refresh-report",
+ status: "done",
+ channelSlug: "alpha",
+ queueKey: "alpha",
+ queuedAt: 3000,
+ startedAt: 3000,
+ endedAt: 3500,
+ exitCode: 0,
+ });
+ await seedJob({
+ id: "sync-1",
+ kind: "sync",
+ status: "done",
+ channelSlug: "beta",
+ queueKey: "beta",
+ queuedAt: 2000,
+ startedAt: 2000,
+ endedAt: 2400,
+ exitCode: 0,
+ });
+ await seedJob({
+ id: "wh-1",
+ kind: "whisper-video",
+ status: "failed",
+ channelSlug: "beta",
+ videoId: "v1",
+ queueKey: "beta",
+ queuedAt: 1000,
+ startedAt: 1000,
+ endedAt: 1800,
+ exitCode: 1,
+ });
+
+ await page.goto("/jobs");
+
+ // Default: refresh-report hidden, the rest shown, summary reflects 1 hidden.
+ await expect(rowById(page, "rr-1")).toHaveCount(0);
+ await expect(rowById(page, "sync-1")).toBeVisible();
+ await expect(rowById(page, "wh-1")).toBeVisible();
+ await expect(page.getByText(/1 hidden/)).toBeVisible();
+
+ // Reveal refresh-report via its (struck-through) kind chip.
+ await page.getByRole("button", { name: "refresh-report" }).click();
+ await expect(rowById(page, "rr-1")).toBeVisible();
+
+ // Choice persists across a reload (localStorage).
+ await page.reload();
+ await expect(rowById(page, "rr-1")).toBeVisible();
+
+ // Status filter: hiding "failed" drops the whisper-video row.
+ await page.getByRole("button", { name: "failed" }).click();
+ await expect(rowById(page, "wh-1")).toHaveCount(0);
+ await expect(rowById(page, "sync-1")).toBeVisible();
+
+ // Search narrows to a channel: only the alpha (refresh-report) job matches.
+ const search = page.getByLabel("Search jobs");
+ await search.fill("alpha");
+ await expect(rowById(page, "rr-1")).toBeVisible();
+ await expect(rowById(page, "sync-1")).toHaveCount(0);
+
+ // Reset restores defaults: refresh-report hidden again, failed shown, no query.
+ await page.getByRole("button", { name: "Reset filters" }).click();
+ await expect(rowById(page, "rr-1")).toHaveCount(0);
+ await expect(rowById(page, "wh-1")).toBeVisible();
+ await expect(search).toHaveValue("");
+});