Archilyzer · Source

archilyzer

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

commit cb15e06f6d360b5189159de2e7b3a1c1da1af1d1
parent 67b405fc53ebd0af57a6693486243d57494b2943
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 18 Jun 2026 14:39:26 -0400

Add read-only monitor widget and builder

A compact, chrome-less /widget page shows worker status and active-job
progress with no sidebar or controls, sized to sit in a small pinned
window or an <iframe>. It reuses the existing /api/jobs/active and
/api/workers endpoints (server-rendered initial paint, then live poll)
and is configured entirely via GET params (jobs, workers, channel, poll,
compact, titles, idle).

A new Monitor page (/widget/builder), under the sidebar's Pool group,
exposes those params as form controls, builds a copyable link, and
live-previews the real widget in a sized iframe.

The root layout renders its sidebar/command-palette/auto-refresh through
a small AppFrame client wrapper that hides them on exactly /widget (the
builder keeps the normal shell). The per-worker payload builder shared by
the Workers page and /api/workers is extracted to buildWorkersPayload()
so the widget reuses it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 2+-
Meditor/app/api/workers/route.ts | 53++++++-----------------------------------------------
Aeditor/app/components/AppFrame.tsx | 36++++++++++++++++++++++++++++++++++++
Meditor/app/layout.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Aeditor/app/widget/builder/components/WidgetBuilder.tsx | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/widget/builder/page.tsx | 24++++++++++++++++++++++++
Aeditor/app/widget/components/MonitorWidget.tsx | 348+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/widget/lib/config.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/widget/page.tsx | 31+++++++++++++++++++++++++++++++
Aeditor/app/workers/buildWorkers.ts | 41+++++++++++++++++++++++++++++++++++++++++
Meditor/app/workers/page.tsx | 14+++-----------
Aeditor/e2e/widget.spec.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 879 insertions(+), 93 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## [Unreleased] -- **The channel video selector can bulk-delete directories and clear failure markers, and its action bar is now an action picker.** The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions. +- **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, 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. The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions. - **Audio-integrity checks now pause the download while they run, cutting re-downloaded bytes and HTTP 429 risk.** With audio-integrity checking enabled, the downloader periodically snapshots the in-progress `.part` and validates it with ffmpeg. Previously yt-dlp was only paused for the brief *copy* of that snapshot and then resumed immediately, so it kept downloading throughout the (longer) ffmpeg probe — and if the probe came back malformed, every byte pulled during the probe, plus everything back to the last good checkpoint, was discarded and had to be re-fetched. That wasted, repeated fetching is a prime driver of rate-limit (429) responses. Now yt-dlp stays suspended (SIGSTOP) across the whole probe and only resumes on a clean verdict; on a corrupt verdict it's killed while still stopped and rolled back, having downloaded zero throwaway bytes. The trade-off is a briefly idle source connection during each probe (probes are seconds; if a held connection is ever dropped, yt-dlp's own `-c` resume recovers on the next launch). This is the new default; a per-channel **Resume during probe (legacy)** checkbox (channel editor → Audio-integrity checking, `audioCheck.resumeDuringProbe` in `config.json`) restores the old resume-immediately behavior for comparison, and the `AUDIO_CHECK_RESUME_DURING_PROBE` env var overrides it for one-off runs. - **Channels can sync automatically on a schedule.** Each channel gained an **Auto-sync** setting (channel editor → Source): *Default* (inherit the global cadence), *Off*, or a concrete interval (every 10m / 30m / hourly / 6h / 12h / daily / weekly), stored as `syncIntervalMinutes` in `config.json`. Inspired by the Laravel scheduler, a single lightweight cron heartbeat (`pnpm sync:tick`, an ~30-line client) POSTs to the editor's new `/api/scheduler/tick`, and the **server** decides which channels are due — a channel is due when `now − lastSyncedAt ≥ its interval`, so a missed tick (server down, machine asleep) simply runs at the next one with no catch-up storm. All work runs **inside the editor** through the existing job queue and per-channel lock, so a scheduled sync can't collide with a manual **Sync** click, shows up live on `/jobs`, and feeds the same transcription worker pool — no second process, no new file locks. Global controls live in **Settings → Sync scheduler**: a master **enable** (off by default), a **default interval**, a **max concurrent syncs** cap (a tick queues at most `cap − running` channels, most-overdue first, rolling the rest to the next tick — which both bounds load and staggers a large due-batch so it doesn't hit the source all at once), an optional **quiet-hours** window, and **failure backoff** (after N consecutive failures a channel waits `base·2^(N-1)` minutes, capped, before retrying). Channels already marked **Exclude from sync** never auto-sync. A new **Schedule** page (`/scheduler`) shows each channel's interval, last sync, next-due time, last outcome, and any active backoff, plus a recent-ticks log and a **Run scheduler now** button; the same data is at `GET /api/scheduler/status`. The cron client targets the editor's port (3001) by default and is hardenable with a `SYNC_TICK_TOKEN` bearer token for installs that expose the editor — see `SCHEDULED_SYNC.md`. - **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders. diff --git a/editor/app/api/workers/route.ts b/editor/app/api/workers/route.ts @@ -1,53 +1,12 @@ import { NextResponse } from "next/server"; -import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { buildWorkersPayload } from "../../workers/buildWorkers"; export const dynamic = "force-dynamic"; -// Live worker status for the Workers page. Polled ~1s by WorkersView. Joins the -// pool's per-worker slot/state summary with the registry's in-flight transcribe -// tasks (which carry workerId) so each card can show what it's currently running. +// Live worker status for the Workers page and the read-only monitor widget. +// Polled ~1s by WorkersView/MonitorWidget. Joins the pool's per-worker +// slot/state summary with the registry's in-flight transcribe tasks (which +// carry workerId) so each card can show what it's currently running. export async function GET() { - const pool = getWorkerPool(); - const summary = pool.summary(); - - // Collect running transcribe tasks grouped by their worker, with enough detail - // for the Workers page to show what each worker is doing right now. - const byWorker = new Map< - string, - { - id: string; - label: string; - fraction?: number; - detail?: string; - startedAt: number; - channelSlug?: string; - }[] - >(); - for (const job of getRegistry().list()) { - if (job.status !== "running" || !job.tasks) continue; - for (const t of job.tasks) { - if (t.kind !== "transcribe" || !t.workerId) continue; - const list = byWorker.get(t.workerId) ?? []; - list.push({ - id: t.id, - label: t.label, - fraction: t.fraction, - detail: t.detail, - startedAt: t.startedAt, - channelSlug: job.channelSlug, - }); - byWorker.set(t.workerId, list); - } - } - - const workers = summary.map((w) => ({ - ...w, - tasks: byWorker.get(w.id) ?? [], - // True when the in-flight transcription can be stopped into a partial result - // (parakeet). Drives the "Stop & keep partial" button on the Workers page. - canStopPartial: pool.canStopPartial(w.id), - })); - - return NextResponse.json({ paused: pool.isPaused(), workers }); + return NextResponse.json(buildWorkersPayload()); } diff --git a/editor/app/components/AppFrame.tsx b/editor/app/components/AppFrame.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +// The root layout always renders the sidebar nav, command palette, and +// auto-refresh. The read-only monitor widget (/widget) is meant to be embedded +// in a small pinned window or iframe, so it needs none of that chrome. Since the +// App Router root layout can't be skipped, this client wrapper hides the chrome +// on exactly that route and renders a bare, full-bleed main instead. +// +// Matched exactly on "/widget" so the builder (/widget/builder) keeps the shell. +export function AppFrame({ + sidebar, + extras, + children, +}: { + sidebar: ReactNode; + extras: ReactNode; + children: ReactNode; +}) { + const pathname = usePathname(); + const bare = pathname === "/widget"; + + if (bare) { + return <main className="flex-1 min-w-0 min-h-full">{children}</main>; + } + + return ( + <> + {sidebar} + <main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main> + {extras} + </> + ); +} diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -7,6 +7,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog"; import { listSites } from "yt-dlp-transcript-common/lib/site"; +import { AppFrame } from "./components/AppFrame"; import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; import { ChangelogNavLink } from "./components/ChangelogNavLink"; @@ -53,6 +54,7 @@ const NAV_GROUPS: NavGroup[] = [ { href: "/jobs", label: "Jobs", badgeKey: "jobs" }, { href: "/jobs/active", label: "Active", badgeKey: "running" }, { href: "/workers", label: "Workers" }, + { href: "/widget/builder", label: "Monitor" }, { href: "/scheduler", label: "Schedule" }, { href: "/build", label: "Build" }, { href: "/actionable", label: "Actionable" }, @@ -139,42 +141,51 @@ export default async function RootLayout({ return ( <html lang="en" className="h-full antialiased"> <body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> - <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col"> - <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800"> - <Link - href="/" - className="font-semibold tracking-tight block leading-tight" - > - {headerLabel} - </Link> - <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5"> - editor - </div> - </div> - <Suspense fallback={null}> - <SiteScopeSelect sites={sites} /> - </Suspense> - <nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible"> - {NAV_GROUPS.map((group) => ( - <div - key={group.label} - className="flex flex-row md:flex-col gap-1 md:gap-0.5 items-center md:items-stretch" - > - <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-600 whitespace-nowrap"> - {group.label} + <AppFrame + sidebar={ + <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col"> + <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800"> + <Link + href="/" + className="font-semibold tracking-tight block leading-tight" + > + {headerLabel} + </Link> + <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5"> + editor </div> - {group.links.map(renderLink)} </div> - ))} - </nav> - </aside> - <main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main> - <Suspense fallback={null}> - <CommandPalette /> - </Suspense> - <AutoRefresh - intervalMs={settings.autoRefreshIntervalSeconds * 1000} - /> + <Suspense fallback={null}> + <SiteScopeSelect sites={sites} /> + </Suspense> + <nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible"> + {NAV_GROUPS.map((group) => ( + <div + key={group.label} + className="flex flex-row md:flex-col gap-1 md:gap-0.5 items-center md:items-stretch" + > + <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-600 whitespace-nowrap"> + {group.label} + </div> + {group.links.map(renderLink)} + </div> + ))} + </nav> + </aside> + } + extras={ + <> + <Suspense fallback={null}> + <CommandPalette /> + </Suspense> + <AutoRefresh + intervalMs={settings.autoRefreshIntervalSeconds * 1000} + /> + </> + } + > + {children} + </AppFrame> </body> </html> ); diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + buildWidgetQuery, + WIDGET_DEFAULTS, + type WidgetConfig, +} from "../../lib/config"; + +const SIZE_PRESETS: { label: string; width: number; height: number }[] = [ + { label: "Small", width: 320, height: 200 }, + { label: "Medium", width: 380, height: 320 }, + { label: "Tall", width: 360, height: 520 }, +]; + +export function WidgetBuilder() { + const [config, setConfig] = useState<WidgetConfig>(WIDGET_DEFAULTS); + const [size, setSize] = useState(SIZE_PRESETS[0]); + const [origin, setOrigin] = useState(""); + const [copied, setCopied] = useState(false); + + // Resolve the absolute origin on the client to avoid an SSR/CSR mismatch. + useEffect(() => { + setOrigin(window.location.origin); + }, []); + + const query = useMemo(() => buildWidgetQuery(config), [config]); + const relativeUrl = query ? `/widget?${query}` : "/widget"; + const fullUrl = origin ? `${origin}${relativeUrl}` : relativeUrl; + + function patch(p: Partial<WidgetConfig>) { + setConfig((c) => ({ ...c, ...p })); + setCopied(false); + } + + async function copy() { + try { + await navigator.clipboard.writeText(fullUrl); + setCopied(true); + } catch { + // clipboard blocked — leave the field for manual copy + } + } + + return ( + <div className="flex flex-col gap-6 lg:flex-row lg:items-start"> + <form className="flex flex-col gap-4 lg:w-80 lg:shrink-0"> + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium mb-1">Sections</legend> + <Check + label="Workers" + checked={config.workers} + onChange={(v) => patch({ workers: v })} + /> + <Check + label="Active jobs" + checked={config.jobs} + onChange={(v) => patch({ jobs: v })} + /> + </fieldset> + + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium mb-1">Display</legend> + <Check + label="Compact (hide per-task detail)" + checked={config.compact} + onChange={(v) => patch({ compact: v })} + /> + <Check + label="Show section titles" + checked={config.showTitles} + onChange={(v) => patch({ showTitles: v })} + /> + <Check + label="Hide when idle" + checked={config.hideIdle} + onChange={(v) => patch({ hideIdle: v })} + /> + </fieldset> + + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Channel filter (slug, optional)</span> + <input + type="text" + value={config.channel ?? ""} + onChange={(e) => patch({ channel: e.target.value || undefined })} + placeholder="all channels" + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1" + /> + </label> + + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Poll interval (seconds)</span> + <input + type="number" + min={1} + value={config.pollSeconds} + onChange={(e) => + patch({ + pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)), + }) + } + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 w-24" + /> + </label> + </form> + + <div className="flex flex-1 flex-col gap-3 min-w-0"> + <div className="flex flex-col gap-1"> + <span className="text-sm font-medium">Widget link</span> + <div className="flex gap-2"> + <input + type="text" + readOnly + aria-label="widget URL" + value={fullUrl} + onFocus={(e) => e.currentTarget.select()} + className="flex-1 min-w-0 rounded border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 px-2 py-1 font-mono text-xs" + /> + <button + type="button" + onClick={copy} + className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 shrink-0" + > + {copied ? "Copied" : "Copy"} + </button> + </div> + <p className="text-xs text-zinc-500"> + Open this in a small pinned window, or embed it with an{" "} + <code className="font-mono">&lt;iframe&gt;</code>. It has no sidebar + and no controls — read-only. + </p> + </div> + + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Preview</span> + <div className="flex gap-1"> + {SIZE_PRESETS.map((p) => ( + <button + key={p.label} + type="button" + onClick={() => setSize(p)} + aria-pressed={size.label === p.label} + className={`px-2 py-0.5 rounded text-xs border ${ + size.label === p.label + ? "bg-zinc-900 text-white border-zinc-900 dark:bg-zinc-100 dark:text-zinc-900 dark:border-zinc-100" + : "border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800" + }`} + > + {p.label} + </button> + ))} + </div> + </div> + <div className="inline-block rounded-lg border border-zinc-300 dark:border-zinc-700 overflow-hidden bg-white dark:bg-zinc-950 shadow-sm"> + <iframe + title="widget preview" + src={relativeUrl} + width={size.width} + height={size.height} + className="block" + /> + </div> + <p className="text-xs text-zinc-500"> + Previewing at {size.width}×{size.height}px. + </p> + </div> + </div> + </div> + ); +} + +function Check({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={checked} + onChange={(e) => onChange(e.target.checked)} + className="h-4 w-4" + /> + <span>{label}</span> + </label> + ); +} diff --git a/editor/app/widget/builder/page.tsx b/editor/app/widget/builder/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { WidgetBuilder } from "./components/WidgetBuilder"; + +export const metadata: Metadata = { title: "Monitor widget" }; + +// Lives inside the normal app shell (AppFrame strips chrome only on exactly +// "/widget"). Lets you compose a read-only monitor link from form controls, +// copy it, and preview the real bare widget in an iframe. +export default function WidgetBuilderPage() { + return ( + <div className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <h1 className="text-2xl font-semibold">Monitor widget</h1> + </div> + <p className="text-sm text-zinc-500 max-w-2xl"> + Build a read-only monitoring widget that shows worker status and active + jobs with no sidebar or controls — sized to sit in a small pinned window + or an embedded <code className="font-mono">&lt;iframe&gt;</code>. Toggle + what it shows below, then copy the link. + </p> + <WidgetBuilder /> + </div> + ); +} diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { formatDuration } from "yt-dlp-transcript-common/lib/format"; +import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs"; +import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList"; +import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView"; +import type { WidgetConfig } from "../lib/config"; + +// Read-only monitor widget. Reuses the existing ~1s poll pattern from +// ActiveJobsLive / WorkersView against the same /api endpoints, but renders a +// compact, control-free view meant to fit in a small pinned window or iframe. + +// Live wall-clock that re-renders once a second; null until mounted so SSR and +// the first client render agree (no Date.now() hydration mismatch). +function useNow(): number | null { + const [now, setNow] = useState<number | null>(null); + useEffect(() => { + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return now; +} + +// Generic poller: fetches `url` every `pollMs` while enabled, swallowing +// transient errors. Disabled (enabled=false) leaves the initial value as-is. +function usePolledPayload<T>( + url: string, + enabled: boolean, + pollMs: number, + initial: T | null, +): T | null { + const [data, setData] = useState<T | null>(initial); + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let timer: ReturnType<typeof setTimeout> | null = null; + async function tick() { + try { + const res = await fetch(url, { cache: "no-store" }); + if (res.ok && !cancelled) setData((await res.json()) as T); + } catch { + // transient — keep polling + } finally { + if (!cancelled) timer = setTimeout(tick, pollMs); + } + } + timer = setTimeout(tick, pollMs); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [url, enabled, pollMs]); + return data; +} + +export function MonitorWidget({ + config, + initialJobs, + initialWorkers, +}: { + config: WidgetConfig; + initialJobs: ActiveJobsPayload | null; + initialWorkers: WorkersPayload | null; +}) { + const pollMs = config.pollSeconds * 1000; + const jobsPayload = usePolledPayload<ActiveJobsPayload>( + "/api/jobs/active", + config.jobs, + pollMs, + initialJobs, + ); + const workersPayload = usePolledPayload<WorkersPayload>( + "/api/workers", + config.workers, + pollMs, + initialWorkers, + ); + + const jobs = (jobsPayload?.jobs ?? []).filter( + (j) => !config.channel || j.channelSlug === config.channel, + ); + const workers = workersPayload?.workers ?? []; + + const nothingActive = + (!config.jobs || jobs.length === 0) && + (!config.workers || workers.every((w) => !w.busy)); + + if (config.hideIdle && nothingActive) { + return ( + <div className="p-2 text-xs text-zinc-500" aria-label="monitor idle"> + Idle + </div> + ); + } + + return ( + <div className="flex flex-col gap-3 p-2 text-zinc-900 dark:text-zinc-100"> + {config.workers && ( + <WorkersStrip + workers={workers} + paused={workersPayload?.paused ?? false} + showTitle={config.showTitles} + /> + )} + {config.jobs && ( + <ActiveJobsStrip + jobs={jobs} + showTitle={config.showTitles} + compact={config.compact} + /> + )} + </div> + ); +} + +// Compact state → dot color, mirroring stateBadge() in WorkersView. +function workerDotClass(w: WorkerView): string { + if (w.degraded) return "bg-red-500"; + switch (w.state) { + case "enabled": + return w.busy ? "bg-green-500" : "bg-green-500/40"; + case "draining": + return "bg-amber-500"; + default: + return "bg-zinc-400 dark:bg-zinc-600"; + } +} + +function workerStateLabel(w: WorkerView): string { + if (w.degraded) return "degraded"; + if (w.state === "enabled") return w.busy ? "busy" : "idle"; + return w.state; // draining | disabled +} + +function WorkersStrip({ + workers, + paused, + showTitle, +}: { + workers: WorkerView[]; + paused: boolean; + showTitle: boolean; +}) { + const busy = workers.filter((w) => w.busy).length; + return ( + <section aria-label="Workers" className="flex flex-col gap-1.5"> + {showTitle && ( + <div className="flex items-baseline gap-2"> + <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500"> + Workers + </h2> + <span className="text-xs text-zinc-500"> + {busy}/{workers.length} busy + </span> + {paused && ( + <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-200 border border-amber-300 dark:border-amber-800"> + paused + </span> + )} + </div> + )} + {workers.length === 0 ? ( + <p className="text-xs text-zinc-500">No workers configured.</p> + ) : ( + <ul className="flex flex-wrap gap-1.5"> + {workers.map((w) => ( + <li + key={w.id} + title={`${w.name} — ${workerStateLabel(w)}`} + aria-label={`worker ${w.name} ${workerStateLabel(w)}`} + className="flex items-center gap-1.5 rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-1.5 py-0.5 text-xs" + > + <span + className={`inline-block h-2 w-2 shrink-0 rounded-full ${workerDotClass(w)}`} + /> + <span className="truncate max-w-[10rem]">{w.name}</span> + </li> + ))} + </ul> + )} + </section> + ); +} + +function ActiveJobsStrip({ + jobs, + showTitle, + compact, +}: { + jobs: RunningJobsListItem[]; + showTitle: boolean; + compact: boolean; +}) { + const running = jobs.filter((j) => j.status === "running").length; + const queued = jobs.filter((j) => j.status === "queued").length; + return ( + <section aria-label="Active jobs" className="flex flex-col gap-1.5"> + {showTitle && ( + <div className="flex items-baseline gap-2"> + <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500"> + Active + </h2> + <span className="text-xs text-zinc-500"> + {running} running, {queued} queued + </span> + </div> + )} + {jobs.length === 0 ? ( + <p className="text-xs text-zinc-500">No active jobs.</p> + ) : ( + <ul className="flex flex-col gap-2"> + {jobs.map((j) => ( + <li key={j.id}> + <JobRow job={j} compact={compact} /> + </li> + ))} + </ul> + )} + </section> + ); +} + +function JobRow({ + job, + compact, +}: { + job: RunningJobsListItem; + compact: boolean; +}) { + const statusClass = + job.status === "running" + ? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" + : "bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200"; + return ( + <div className="flex flex-col gap-1"> + <div className="flex flex-wrap items-center gap-1.5 text-xs"> + <span + className={`uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusClass}`} + > + {job.status} + </span> + <span className="font-mono">{job.kind}</span> + {job.channelSlug && ( + <span className="font-mono text-zinc-500 truncate max-w-[12rem]"> + {job.channelSlug} + </span> + )} + </div> + {job.progress && <JobProgressBar progress={job.progress} />} + {!compact && + job.tasks && + job.tasks.length > 0 && + job.tasks.map((t) => <TaskBar key={t.id} task={t} />)} + </div> + ); +} + +function JobProgressBar({ + progress, +}: { + progress: NonNullable<RunningJobsListItem["progress"]>; +}) { + const label = + progress.metric === "downloads" + ? `↓ ${progress.current}/${progress.target}` + : `${progress.current}/${progress.target}`; + const remaining = progress.target - progress.current; + const etaText = + remaining <= 0 + ? null + : typeof progress.etaSeconds === "number" + ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` + : "…"; + return ( + <div className="flex flex-col gap-0.5"> + <div + role="progressbar" + aria-valuemin={progress.initial} + aria-valuemax={progress.target} + aria-valuenow={progress.current} + aria-label={label} + className="relative h-1.5 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800" + > + <div + className="absolute inset-y-0 left-0 bg-emerald-500" + style={{ width: `${progress.pct}%` }} + /> + </div> + <div className="flex items-baseline justify-between gap-2 text-[11px] text-zinc-500"> + <span>{label}</span> + {etaText && <span className="shrink-0">{etaText}</span>} + </div> + </div> + ); +} + +function TaskBar({ + task, +}: { + task: NonNullable<RunningJobsListItem["tasks"]>[number]; +}) { + const now = useNow(); + const verb = task.kind === "download" ? "↓" : "✎"; + const hasFraction = typeof task.fraction === "number"; + const pct = hasFraction ? Math.round((task.fraction as number) * 100) : 0; + const fillClass = + task.kind === "download" + ? "bg-emerald-300 dark:bg-emerald-700" + : "bg-emerald-500"; + const elapsed = + now === null + ? null + : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) || + "0:00"; + const meta = [elapsed, hasFraction ? `${pct}%` : null, task.detail] + .filter(Boolean) + .join(" · "); + return ( + <div className="flex flex-col gap-0.5 pl-2 border-l-2 border-zinc-200 dark:border-zinc-800"> + <div className="flex items-baseline justify-between gap-2 text-[11px]"> + <span className="truncate"> + <span className="text-zinc-500">{verb} </span> + <span className="font-mono">{task.label}</span> + </span> + <span className="font-mono text-zinc-500 shrink-0">{meta}</span> + </div> + <div + role="progressbar" + aria-valuenow={hasFraction ? pct : undefined} + aria-label={`${task.label}`} + className="relative h-1 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800" + > + {hasFraction ? ( + <div + className={`absolute inset-y-0 left-0 ${fillClass}`} + style={{ width: `${pct}%` }} + /> + ) : ( + <div + className={`absolute inset-y-0 left-0 w-1/3 animate-pulse ${fillClass}`} + /> + )} + </div> + </div> + ); +} diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts @@ -0,0 +1,85 @@ +// Single source of truth for the read-only monitor widget's GET-param contract. +// Used by the bare /widget page (parse) and the /widget/builder UI (parse + +// serialize), so a link the builder copies always renders the way it previewed. + +export type WidgetConfig = { + // Which sections to render. + jobs: boolean; + workers: boolean; + // Optional: only show active jobs for this channel slug. + channel?: string; + // Poll cadence in seconds (clamped >= 1). + pollSeconds: number; + // Denser layout: drops per-task detail rows, keeps the job-level bars. + compact: boolean; + // Show the small "Workers" / "Active" section headers. + showTitles: boolean; + // When nothing is active, collapse to a tiny "Idle" line instead of the + // full empty-state boxes. + hideIdle: boolean; +}; + +export const WIDGET_DEFAULTS: WidgetConfig = { + jobs: true, + workers: true, + channel: undefined, + pollSeconds: 2, + compact: false, + showTitles: true, + hideIdle: false, +}; + +// Next's searchParams give each key as string | string[] | undefined. +type RawParams = Record<string, string | string[] | undefined>; + +function first(v: string | string[] | undefined): string | undefined { + return Array.isArray(v) ? v[0] : v; +} + +// Parse a "0"/"1" (also accepts "true"/"false") flag, falling back to a default. +function parseBool( + v: string | string[] | undefined, + fallback: boolean, +): boolean { + const s = first(v); + if (s === undefined) return fallback; + if (s === "1" || s === "true") return true; + if (s === "0" || s === "false") return false; + return fallback; +} + +export function parseWidgetConfig(params: RawParams): WidgetConfig { + const channel = first(params.channel)?.trim(); + const pollRaw = Number(first(params.poll)); + const pollSeconds = + Number.isFinite(pollRaw) && pollRaw >= 1 + ? Math.min(3600, Math.round(pollRaw)) + : WIDGET_DEFAULTS.pollSeconds; + return { + jobs: parseBool(params.jobs, WIDGET_DEFAULTS.jobs), + workers: parseBool(params.workers, WIDGET_DEFAULTS.workers), + channel: channel || undefined, + pollSeconds, + compact: parseBool(params.compact, WIDGET_DEFAULTS.compact), + showTitles: parseBool(params.titles, WIDGET_DEFAULTS.showTitles), + hideIdle: first(params.idle) === "hide" ? true : WIDGET_DEFAULTS.hideIdle, + }; +} + +// Serialize a config to a query string, omitting anything left at its default so +// shared links stay short. Returns "" when every value is default. +export function buildWidgetQuery(config: WidgetConfig): string { + const sp = new URLSearchParams(); + if (config.jobs !== WIDGET_DEFAULTS.jobs) sp.set("jobs", config.jobs ? "1" : "0"); + if (config.workers !== WIDGET_DEFAULTS.workers) + sp.set("workers", config.workers ? "1" : "0"); + if (config.channel) sp.set("channel", config.channel); + if (config.pollSeconds !== WIDGET_DEFAULTS.pollSeconds) + sp.set("poll", String(config.pollSeconds)); + if (config.compact !== WIDGET_DEFAULTS.compact) + sp.set("compact", config.compact ? "1" : "0"); + if (config.showTitles !== WIDGET_DEFAULTS.showTitles) + sp.set("titles", config.showTitles ? "1" : "0"); + if (config.hideIdle !== WIDGET_DEFAULTS.hideIdle) sp.set("idle", "hide"); + return sp.toString(); +} diff --git a/editor/app/widget/page.tsx b/editor/app/widget/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { buildActiveJobsPayload } from "../jobs/active/buildActiveJobs"; +import { buildWorkersPayload } from "../workers/buildWorkers"; +import { MonitorWidget } from "./components/MonitorWidget"; +import { parseWidgetConfig } from "./lib/config"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "Monitor" }; + +// Bare, read-only monitor. The root layout strips its chrome (see AppFrame) so +// it can be embedded in a small pinned window or iframe. What it shows is driven +// entirely by GET params (see lib/config); the /widget/builder page composes +// those links. Initial payloads are built server-side here for a flicker-free +// first paint; MonitorWidget then polls the same /api endpoints for live data. +export default async function WidgetPage({ + searchParams, +}: { + searchParams: Promise<Record<string, string | string[] | undefined>>; +}) { + const config = parseWidgetConfig((await searchParams) ?? {}); + const initialJobs = config.jobs ? await buildActiveJobsPayload() : null; + const initialWorkers = config.workers ? buildWorkersPayload() : null; + return ( + <MonitorWidget + config={config} + initialJobs={initialJobs} + initialWorkers={initialWorkers} + /> + ); +} diff --git a/editor/app/workers/buildWorkers.ts b/editor/app/workers/buildWorkers.ts @@ -0,0 +1,41 @@ +import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import type { WorkersPayload, WorkerTask } from "./components/WorkersView"; + +// Builds the Workers screen payload: the pool's per-worker slot/state summary +// joined with the registry's in-flight transcribe tasks (which carry workerId) +// so each worker shows what it's currently running. Shared by the server- +// rendered page (initial state), the /api/workers poll route, and the read-only +// monitor widget. +export function buildWorkersPayload(): WorkersPayload { + const pool = getWorkerPool(); + const summary = pool.summary(); + + const byWorker = new Map<string, WorkerTask[]>(); + for (const job of getRegistry().list()) { + if (job.status !== "running" || !job.tasks) continue; + for (const t of job.tasks) { + if (t.kind !== "transcribe" || !t.workerId) continue; + const list = byWorker.get(t.workerId) ?? []; + list.push({ + id: t.id, + label: t.label, + fraction: t.fraction, + detail: t.detail, + startedAt: t.startedAt, + channelSlug: job.channelSlug, + }); + byWorker.set(t.workerId, list); + } + } + + const workers = summary.map((w) => ({ + ...w, + tasks: byWorker.get(w.id) ?? [], + // True when the in-flight transcription can be stopped into a partial result + // (parakeet). Drives the "Stop & keep partial" button on the Workers page. + canStopPartial: pool.canStopPartial(w.id), + })); + + return { paused: pool.isPaused(), workers }; +} diff --git a/editor/app/workers/page.tsx b/editor/app/workers/page.tsx @@ -1,22 +1,14 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; -import { WorkersView, type WorkersPayload } from "./components/WorkersView"; +import { WorkersView } from "./components/WorkersView"; +import { buildWorkersPayload } from "./buildWorkers"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Workers" }; export default async function WorkersPage() { - const pool = getWorkerPool(); - const initial: WorkersPayload = { - paused: pool.isPaused(), - workers: pool.summary().map((w) => ({ - ...w, - tasks: [], - canStopPartial: pool.canStopPartial(w.id), - })), - }; + const initial = buildWorkersPayload(); return ( <div className="flex flex-col gap-4"> <div className="flex items-center justify-between"> diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -0,0 +1,65 @@ +// The read-only monitor widget (/widget) and its builder (/widget/builder). +// The widget is meant to embed in a small pinned window or iframe: the app shell +// (sidebar, command palette) is stripped on exactly /widget, and it carries no +// action controls. The builder lives inside the normal shell and composes links. + +import { test, expect } from "@playwright/test"; +import { resetData, writeSettings } from "./helpers"; + +const TWO_WORKERS = { + workers: [ + { id: "gpu", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, + { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} }, + ], +}; + +test.beforeEach(async () => { + await resetData("empty"); + await writeSettings(TWO_WORKERS); +}); + +test("the bare widget has no sidebar and no controls", async ({ page }) => { + await page.goto("/widget"); + + // Chrome is stripped: no sidebar nav. + await expect(page.locator("aside")).toHaveCount(0); + + // Both sections render with the configured workers visible. + await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); + await expect(page.getByLabel("worker GPU idle")).toBeVisible(); + await expect(page.getByLabel("worker CPU idle")).toBeVisible(); + + // Read-only: no action buttons of any kind. + await expect(page.getByRole("button")).toHaveCount(0); +}); + +test("section params gate what renders", async ({ page }) => { + await page.goto("/widget?jobs=0"); + await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Active" })).toHaveCount(0); + + await page.goto("/widget?workers=0"); + await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); +}); + +test("the builder keeps the shell and composes a link", async ({ page }) => { + await page.goto("/widget/builder"); + + // Builder lives inside the normal app shell. + await expect(page.locator("aside")).toBeVisible(); + + const url = page.getByLabel("widget URL"); + await expect(url).toHaveValue(/\/widget$/); + + const preview = page.locator('iframe[title="widget preview"]'); + await expect(preview).toHaveAttribute("src", "/widget"); + + // Toggling a section updates both the link and the preview src. + await page.getByRole("checkbox", { name: "Workers" }).uncheck(); + await expect(url).toHaveValue(/\/widget\?workers=0$/); + await expect(preview).toHaveAttribute("src", "/widget?workers=0"); + + await expect(page.getByRole("button", { name: /copy/i })).toBeVisible(); +});