Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Acommon/jobs/jobMeta.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/listJobs.ts | 53+++++++++++++++++++++++++++++++++++++++++++----------
Mcommon/jobs/streamCommand.ts | 11+++++++++++
Meditor/CHANGELOG.md | 1+
Aeditor/app/jobs/components/JobsTable.tsx | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/jobs/jobsFilterStorage.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/page.tsx | 125++-----------------------------------------------------------------------------
Aeditor/e2e/jobs-filters.spec.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(""); +});