Archilyzer · Source

archilyzer

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

commit 6bc63ab481d0d2700b7ebd5838c960b0de02922d
parent c08b2f7cd061c6050b67b4a0517f12c86551a374
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat,  4 Jul 2026 08:09:16 -0400

Merge feat/widget-builder-actionable: needs-work list, actions, in-place settings gear

Diffstat:
Meditor/CHANGELOG.md | 1+
Aeditor/app/api/widget/actionable/route.ts | 39+++++++++++++++++++++++++++++++++++++++
Meditor/app/widget/builder/components/WidgetBuilder.tsx | 124+++++--------------------------------------------------------------------------
Meditor/app/widget/components/MonitorWidget.tsx | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aeditor/app/widget/components/WidgetConfigForm.tsx | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/widget/components/WidgetControls.tsx | 10++++++----
Meditor/app/widget/lib/config.ts | 14++++++++++++++
Meditor/e2e/widget.spec.ts | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
8 files changed, 523 insertions(+), 134 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`. - **Every site build now bundles downloadable transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip`, combined `all-transcripts.zip` / `all-live-chat.zip`) previously only ran as standalone actions that wrote to a non-served directory; now `compose-site` generates them for the site's own channels straight into the served `public/archives/` and writes a `manifest.json` (sizes + counts) that the site's new **Downloads** page reads. **`zip` is now the default archive format** everywhere (was `tar.gz`), and the Build page's format help text tracks the selected format. Generation is **on by default with three opt-out levels**: a global **Generate archive zips on build** toggle in Settings, a per-site **Generate archive zips** toggle (plus an optional **Archive size cap (MB)**) on the site's page, and a per-build **Skip archive zips** checkbox on the Build and Build & Deploy controls (`BUILD_ARCHIVES=0`). Because a single file over ~25 MB breaks a Cloudflare Pages deploy, any archive over the cap (default 25 MB; `0` = no cap) is dropped from what's served and flagged `oversize` in the manifest so the deploy still succeeds and the Downloads page shows it as unavailable rather than a dead link. See `common/bin/compose-site.ts` (`composeArchives`), `common/controller/archive{Transcripts,LiveChat}.ts` (new `outDir` option), `common/lib/archiveOptions.ts` (default + manifest types), `common/lib/{site,settings}.ts` (opt-out flags), and `editor/app/{deploy/buildDeployCore.ts,build/buildAction.ts,deploy/components/Build{Export,Deploy}Button.tsx,sites/components/SiteForm.tsx,settings/components/SettingsForm.tsx}`. - **You can now change a channel's slug (its id) — deliberately, from the Danger zone.** A channel's slug *is* its on-disk directory name (`transcripts/channels/<slug>/`), so it used to be fixed at creation ("Slug is fixed once a channel is created"). A new **Rename** form in the channel's Danger zone lifts that: enter a new slug and **type the current slug to confirm** (same friction as delete), and the rename is blocked while the channel has running/queued jobs (the in-memory registry keys by slug). Because the slug is a directory name, the rename does a **full migration** of every slug-keyed store so nothing silently breaks: it moves the channel dir (config, data, playlist, snapshot, shards, failed lists) **and** the saved-video store dir — rewriting each `saved-video.json` pointer's absolute `dir` so persisted source videos still resolve — then retargets every site.json membership, the sync scheduler's per-channel backoff state, and any job bookmarks. The two filesystem moves run first and roll back on failure; the metadata updates that follow are atomic and best-effort (surfaced as warnings). Renaming **changes the channel's public URL** (the old one 404s), which the form warns about. The slug grammar is also now validated on create. See `common/controller/renameChannel.ts`, `common/controller/channels.ts` (`isValidChannelSlug`), `common/lib/savedVideo-server.ts` (`rewriteSavedVideoDir`), `common/jobs/bookmarks.ts` (`renameChannelInBookmarks`), `editor/app/channels/{actions.ts,components/RenameChannelForm.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-rename.spec.ts`. - **New Queue diagnostics page (`/jobs/queue`): see & force-release stuck jobs.** The job system has two sources of truth that can drift — the registry owns each job's `status`, the scheduler owns the running SLOT per queue. A cancel that never finalizes (a child that ignored SIGTERM, a crashed finalizer) leaves a job "cancelled" in the registry while the scheduler still marks its slot running, silently blocking every job behind it on that queue — and the Active Jobs page hides it (it filters to running/queued). The new **Queue** page reconciles the two: it builds from the **scheduler** as the source of truth for slots, cross-checks each against its registry record, and flags a running head as **stuck** when the record is terminal-but-holding-slot, evicted, or (softer) a live job idle past 10 minutes. It **auto-heals** the hard cases on every view/poll (frees terminal/evicted slots), shows a health strip (active queues, running, queued, **stuck**, workers), per-queue cards with the held-for duration / PID (`kill -9` hint) / last log line, and a **Force-release** button per slot (SIGKILLs the child and frees the slot unconditionally) plus a **Reap all stuck** action. Force-release is also available on any running job in Active Jobs, and Active Jobs links to Queue with a stuck-count badge. See `common/jobs/registry.ts` (`forceRelease`), `editor/app/jobs/queue/*`, `editor/app/jobs/{actions.ts,components/ForceReleaseJobButton.tsx}`, and `editor/e2e/queue.spec.ts`. diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + actionableUndownloadedCount, + actionableUntranscribedCount, + loadActionableSummary, +} from "../../../actionable/lib/loadActionable"; + +export const dynamic = "force-dynamic"; + +export type WidgetActionableChannel = { + slug: string; + undownloaded: number; + untranscribed: number; +}; + +export type WidgetActionablePayload = { + channels: WidgetActionableChannel[]; +}; + +// Backs the monitor widget's optional "Needs work" strip. Reuses the same +// actionable summary that powers the /actionable page, reduced to the two +// buckets a widget acts on — videos to download and videos to transcribe — for +// each channel that has any, sorted by the larger backlog first. +export async function GET() { + const summary = await loadActionableSummary(getPaths()); + const channels = summary.rows + .map((row) => ({ + slug: row.channel.slug, + undownloaded: actionableUndownloadedCount(row), + untranscribed: actionableUntranscribedCount(row), + })) + .filter((c) => c.undownloaded > 0 || c.untranscribed > 0) + .sort( + (a, b) => + b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed), + ); + return NextResponse.json({ channels } satisfies WidgetActionablePayload); +} diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx @@ -6,6 +6,7 @@ import { WIDGET_DEFAULTS, type WidgetConfig, } from "../../lib/config"; +import { WidgetConfigForm } from "../../components/WidgetConfigForm"; import { loadWidgetConfig, saveWidgetConfig } from "../widgetConfigStorage"; const SIZE_PRESETS: { label: string; width: number; height: number }[] = [ @@ -73,99 +74,7 @@ export function WidgetBuilder() { 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 batch progress bar" - checked={config.jobBar} - onChange={(v) => patch({ jobBar: v })} - /> - <Check - label="Show progress in job heading" - checked={config.headingProgress} - onChange={(v) => patch({ headingProgress: v })} - /> - <Check - label="Show time estimates (ETA)" - checked={config.eta} - onChange={(v) => patch({ eta: v })} - /> - <Check - label="Show disk indicator" - checked={config.disk} - onChange={(v) => patch({ disk: v })} - /> - <Check - label="Show cleanable indicator" - checked={config.cleanable} - onChange={(v) => patch({ cleanable: v })} - /> - <Check - label="Show worker names" - checked={config.workerLabels} - onChange={(v) => patch({ workerLabels: 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 })} - /> - <Check - label="Show control buttons (pause, drain)" - checked={config.controls} - onChange={(v) => patch({ controls: 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-border bg-card 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-border bg-card px-2 py-1 w-24" - /> - </label> + <WidgetConfigForm config={config} onChange={patch} /> </form> <div className="flex flex-1 flex-col gap-3 min-w-0"> @@ -200,8 +109,11 @@ export function WidgetBuilder() { <code className="font-mono">&lt;iframe&gt;</code>. It has no sidebar; by default it&apos;s read-only, but{" "} <strong>Show control buttons</strong> adds Pause/Resume - Transcriptions and Drain all. <strong>Open popup</strong> launches a - chromeless window at the selected preview size. + Transcriptions, Drain all, Retry all failed, and per-channel + Download/Transcribe on the needs-work list. The{" "} + <strong>settings gear</strong> lets you reconfigure the widget in + place; turn it off for a locked-down link. <strong>Open popup</strong>{" "} + launches a chromeless window at the selected preview size. </p> </div> @@ -243,25 +155,3 @@ export function WidgetBuilder() { </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/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -9,7 +9,10 @@ import type { import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList"; import { jobKindLabel } from "../../jobs/jobKindLabels"; import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView"; -import type { WidgetConfig } from "../lib/config"; +import { InlineActionButton } from "../../actionable/components/InlineActionButton"; +import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import { buildWidgetQuery, type WidgetConfig } from "../lib/config"; +import { WidgetConfigForm } from "./WidgetConfigForm"; import { WidgetControls } from "./WidgetControls"; // Read-only monitor widget. Reuses the existing ~1s poll pattern from @@ -61,7 +64,11 @@ function usePolledPayload<T>( if (!cancelled) timer = setTimeout(tick, pollMs); } } - timer = setTimeout(tick, pollMs); + // Fetch immediately on (re)subscribe, then poll on the interval. Sections + // seeded from the server (jobs/workers) get a harmless refresh; those with a + // null initial (cleanable/actionable) render on first tick instead of after + // a full interval — matters most for the actionable strip's 15s cadence. + void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); @@ -71,7 +78,7 @@ function usePolledPayload<T>( } export function MonitorWidget({ - config, + config: initialConfig, initialJobs, initialWorkers, }: { @@ -79,6 +86,14 @@ export function MonitorWidget({ initialJobs: ActiveJobsPayload | null; initialWorkers: WorkersPayload | null; }) { + // The URL (parsed into `initialConfig` server-side) is authoritative, so we + // seed state from the prop only — never localStorage — to keep the first + // client render byte-identical to the server's. The in-widget settings gear + // edits this state and mirrors it back into the address bar (see `patch`), so + // a reload re-parses the same config. + const [config, setConfig] = useState<WidgetConfig>(initialConfig); + const [settingsOpen, setSettingsOpen] = useState(false); + const pollMs = config.pollSeconds * 1000; // The controls row needs live `paused` state, so poll workers whenever either // the Workers section or the controls are shown. @@ -102,6 +117,27 @@ export function MonitorWidget({ pollMs, null, ); + // The worklist changes on job completions (minutes), not seconds, and reading + // every channel snapshot each tick is heavier than the other endpoints — so + // poll it no faster than every 15s regardless of the configured cadence. + const { data: actionablePayload } = usePolledPayload<WidgetActionablePayload>( + "/api/widget/actionable", + config.actionable, + Math.max(pollMs, 15000), + null, + ); + + // Apply a config change and mirror it into the address bar so the widget is + // self-describing: a reload re-parses the same config and the link stays + // copyable. `history.replaceState` (not router.replace) avoids a server + // round-trip and the global AutoRefresh race. An all-default config drops the + // query entirely. + function patch(p: Partial<WidgetConfig>) { + const next = { ...config, ...p }; + setConfig(next); + const q = buildWidgetQuery(next); + window.history.replaceState(null, "", q ? `?${q}` : window.location.pathname); + } const jobs = (jobsPayload?.jobs ?? []).filter( (j) => !config.channel || j.channelSlug === config.channel, @@ -109,23 +145,44 @@ export function MonitorWidget({ const workers = workersPayload?.workers ?? []; const disk = jobsPayload?.disk; + const actionableChannels = actionablePayload?.channels ?? []; + const nothingActive = (!config.jobs || jobs.length === 0) && (!config.workers || workers.every((w) => !w.busy)); + // The settings gear + its in-place overlay. Shared by the idle and full + // renders so you can reconfigure a collapsed widget too. Positioned against + // the widget's own relative container (or the viewport, for the overlay). + const settingsLayer = ( + <SettingsLayer + enabled={config.settings} + open={settingsOpen} + onToggle={() => setSettingsOpen((o) => !o)} + onClose={() => setSettingsOpen(false)} + config={config} + onChange={patch} + /> + ); + // A low-disk warning is worth showing even when nothing is running, since it // explains why no downloads start — so it overrides hideIdle. Interactive // controls also stay visible when idle (you may want to pause preemptively). if (config.hideIdle && nothingActive && !disk?.low && !config.controls) { return ( - <div className="p-2 text-xs text-muted-foreground" aria-label="monitor idle"> + <div + className="relative p-2 text-xs text-muted-foreground" + aria-label="monitor idle" + > Idle + {settingsLayer} </div> ); } return ( - <div className="flex flex-col gap-3 p-2 text-foreground"> + <div className="relative flex flex-col gap-3 p-2 text-foreground"> + {settingsLayer} {config.controls && ( <WidgetControls paused={workersPayload?.paused ?? false} @@ -154,6 +211,13 @@ export function MonitorWidget({ eta={config.eta} /> )} + {config.actionable && ( + <ActionableStrip + channels={actionableChannels} + showTitle={config.showTitles} + interactive={config.controls} + /> + )} </div> ); } @@ -291,6 +355,92 @@ function CleanableStrip({ bytes }: { bytes: number }) { ); } +// Compact "needs work" list: channels with videos to download or transcribe, +// echoing the ↓ (download) / ✎ (transcribe) verbs used by the task bars so the +// worklist reads in the same language. Read-only counts by default; when the +// widget is interactive (controls=1), each row gains the same per-channel +// Download/Transcribe buttons as the /actionable page. +const ACTIONABLE_LIMIT = 6; + +function ActionableStrip({ + channels, + showTitle, + interactive, +}: { + channels: WidgetActionablePayload["channels"]; + showTitle: boolean; + interactive: boolean; +}) { + const shown = channels.slice(0, ACTIONABLE_LIMIT); + const overflow = channels.length - shown.length; + return ( + <section aria-label="Needs work" 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-muted-foreground"> + Needs work + </h2> + <span className="text-xs text-muted-foreground"> + {channels.length} {channels.length === 1 ? "channel" : "channels"} + </span> + </div> + )} + {channels.length === 0 ? ( + <p className="text-xs text-muted-foreground">Everything&apos;s handled.</p> + ) : ( + <ul className="flex flex-col gap-1.5"> + {shown.map((c) => ( + <li + key={c.slug} + className="flex flex-col gap-1 rounded border border-border bg-card px-2 py-1" + > + <div className="flex items-center justify-between gap-2"> + <span + className="font-mono text-xs truncate" + title={c.slug} + > + {c.slug} + </span> + <span className="flex shrink-0 items-center gap-1"> + {c.undownloaded > 0 && ( + <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-warning-soft text-warning"> + ↓ {c.undownloaded} + </span> + )} + {c.untranscribed > 0 && ( + <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-info-soft text-info"> + ✎ {c.untranscribed} + </span> + )} + </span> + </div> + {interactive && ( + <div className="flex flex-wrap items-center gap-1.5"> + {c.undownloaded > 0 && ( + <InlineActionButton + variant={{ kind: "downloadMissing", slug: c.slug }} + /> + )} + {c.untranscribed > 0 && ( + <InlineActionButton + variant={{ kind: "transcribeMissing", slug: c.slug }} + /> + )} + </div> + )} + </li> + ))} + {overflow > 0 && ( + <li className="text-xs text-muted-foreground"> + +{overflow} more {overflow === 1 ? "channel" : "channels"} + </li> + )} + </ul> + )} + </section> + ); +} + function ActiveJobsStrip({ jobs, showTitle, @@ -538,3 +688,69 @@ function TaskBar({ </div> ); } + +// The settings gear and its in-place config overlay. The gear anchors to the +// widget's own (relative) container; the overlay covers the whole widget window +// (fixed inset-0) and hosts the same form the builder uses, so a pinned widget +// can be reconfigured without opening the full builder page. Rendering nothing +// when the gear is disabled keeps hooks out of this component entirely. +function SettingsLayer({ + enabled, + open, + onToggle, + onClose, + config, + onChange, +}: { + enabled: boolean; + open: boolean; + onToggle: () => void; + onClose: () => void; + config: WidgetConfig; + onChange: (patch: Partial<WidgetConfig>) => void; +}) { + if (!enabled) return null; + return ( + <> + <button + type="button" + aria-label="Settings" + aria-expanded={open} + onClick={onToggle} + className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded border border-border bg-card/80 text-muted-foreground backdrop-blur hover:bg-muted hover:text-foreground" + > + <span aria-hidden className="text-sm leading-none"> + ⚙ + </span> + </button> + {open && ( + <div + role="dialog" + aria-label="Widget settings" + className="fixed inset-0 z-50 overflow-auto bg-background p-3 text-foreground" + > + <div className="mb-3 flex items-center justify-between"> + <h2 className="text-sm font-semibold">Widget settings</h2> + <button + type="button" + aria-label="Close settings" + onClick={onClose} + className="flex h-6 w-6 items-center justify-center rounded border border-border hover:bg-muted" + > + <span aria-hidden className="text-sm leading-none"> + ✕ + </span> + </button> + </div> + <form className="flex flex-col gap-4"> + <WidgetConfigForm config={config} onChange={onChange} /> + </form> + <p className="mt-3 text-xs text-muted-foreground"> + Changes apply live and update this widget&apos;s link — reload keeps + them. + </p> + </div> + )} + </> + ); +} diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { WidgetConfig } from "../lib/config"; + +// The widget's configuration form — the section/display checkboxes plus the +// channel and poll-interval inputs. Shared by the /widget/builder page (which +// wraps it with the link/copy/preview UI) and the in-widget settings overlay +// (which lets you reconfigure a pinned widget in place). Callers own the +// surrounding <form> element. +export function WidgetConfigForm({ + config, + onChange, +}: { + config: WidgetConfig; + onChange: (patch: Partial<WidgetConfig>) => void; +}) { + return ( + <> + <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) => onChange({ workers: v })} + /> + <Check + label="Active jobs" + checked={config.jobs} + onChange={(v) => onChange({ jobs: v })} + /> + <Check + label="Channels needing work" + checked={config.actionable} + onChange={(v) => onChange({ actionable: 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) => onChange({ compact: v })} + /> + <Check + label="Show batch progress bar" + checked={config.jobBar} + onChange={(v) => onChange({ jobBar: v })} + /> + <Check + label="Show progress in job heading" + checked={config.headingProgress} + onChange={(v) => onChange({ headingProgress: v })} + /> + <Check + label="Show time estimates (ETA)" + checked={config.eta} + onChange={(v) => onChange({ eta: v })} + /> + <Check + label="Show disk indicator" + checked={config.disk} + onChange={(v) => onChange({ disk: v })} + /> + <Check + label="Show cleanable indicator" + checked={config.cleanable} + onChange={(v) => onChange({ cleanable: v })} + /> + <Check + label="Show worker names" + checked={config.workerLabels} + onChange={(v) => onChange({ workerLabels: v })} + /> + <Check + label="Show section titles" + checked={config.showTitles} + onChange={(v) => onChange({ showTitles: v })} + /> + <Check + label="Hide when idle" + checked={config.hideIdle} + onChange={(v) => onChange({ hideIdle: v })} + /> + <Check + label="Show settings gear" + checked={config.settings} + onChange={(v) => onChange({ settings: v })} + /> + <Check + label="Show control buttons (pause, drain, retry, per-channel actions)" + checked={config.controls} + onChange={(v) => onChange({ controls: 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) => onChange({ channel: e.target.value || undefined })} + placeholder="all channels" + className="rounded border border-border bg-card 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) => + onChange({ + pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)), + }) + } + className="rounded border border-border bg-card px-2 py-1 w-24" + /> + </label> + </> + ); +} + +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/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx @@ -2,13 +2,14 @@ import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton"; import { DrainAllButton } from "../../jobs/components/DrainAllButton"; +import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton"; // Opt-in interactive controls for the monitor widget (enabled with controls=1). // Reuses the same actions/buttons as the Workers and Active Jobs pages so a -// pinned widget can free the GPU (pause) or wind work down (drain) without -// opening the full app. `onWorkersChange` refetches the widget's worker payload -// so the pause/resume label flips immediately instead of waiting for the next -// poll. +// pinned widget can free the GPU (pause), wind work down (drain), or recover +// failures (retry) without opening the full app. `onWorkersChange` refetches the +// widget's worker payload so the pause/resume label flips immediately instead of +// waiting for the next poll. export function WidgetControls({ paused, onWorkersChange, @@ -23,6 +24,7 @@ export function WidgetControls({ > <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} /> <DrainAllButton /> + <RetryAllFailedButton /> </section> ); } diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts @@ -36,6 +36,12 @@ export type WidgetConfig = { // Opt-in interactive controls (Pause/Resume Transcriptions, Drain all). Off by // default — the widget stays read-only unless this is enabled. controls: boolean; + // Show the "Needs work" strip — a compact list of channels with videos to + // download or transcribe. Off by default (mirrors the opt-in cleanable strip). + actionable: boolean; + // Show the in-widget settings gear that opens the config overlay in place. On + // by default; turn off to bake a locked-down shared/embedded link. + settings: boolean; }; export const WIDGET_DEFAULTS: WidgetConfig = { @@ -53,6 +59,8 @@ export const WIDGET_DEFAULTS: WidgetConfig = { cleanable: false, workerLabels: true, controls: false, + actionable: false, + settings: true, }; // Next's searchParams give each key as string | string[] | undefined. @@ -96,6 +104,8 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig { cleanable: parseBool(params.clean, WIDGET_DEFAULTS.cleanable), workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels), controls: parseBool(params.controls, WIDGET_DEFAULTS.controls), + actionable: parseBool(params.act, WIDGET_DEFAULTS.actionable), + settings: parseBool(params.gear, WIDGET_DEFAULTS.settings), }; } @@ -127,5 +137,9 @@ export function buildWidgetQuery(config: WidgetConfig): string { sp.set("wnames", config.workerLabels ? "1" : "0"); if (config.controls !== WIDGET_DEFAULTS.controls) sp.set("controls", config.controls ? "1" : "0"); + if (config.actionable !== WIDGET_DEFAULTS.actionable) + sp.set("act", config.actionable ? "1" : "0"); + if (config.settings !== WIDGET_DEFAULTS.settings) + sp.set("gear", config.settings ? "1" : "0"); return sp.toString(); } diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -1,7 +1,9 @@ // The 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. It is read-only by default, -// but `controls=1` opts into interactive Pause/Resume + Drain buttons. The +// command palette) is stripped on exactly /widget. It is read-only by default +// (aside from an in-place settings gear), but `controls=1` opts into interactive +// Pause/Resume + Drain + Retry buttons, `act=1` shows a "needs work" channel +// list, and the two combine into per-channel Download/Transcribe actions. The // builder lives inside the normal shell and composes links. import { test, expect } from "@playwright/test"; @@ -19,7 +21,7 @@ test.beforeEach(async () => { await writeSettings(TWO_WORKERS); }); -test("the bare widget has no sidebar and no controls", async ({ page }) => { +test("the bare widget has no sidebar and stays read-only", async ({ page }) => { await page.goto("/widget"); // Chrome is stripped: no sidebar nav. @@ -31,11 +33,46 @@ test("the bare widget has no sidebar and no controls", async ({ page }) => { 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); + // The settings gear ships on by default, but nothing mutates worker/job state: + // no Pause/Resume/Drain/Retry controls unless controls=1. + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Pause Transcriptions" }), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: "Drain all" })).toHaveCount(0); +}); + +test("gear=0 hides the settings gear", async ({ page }) => { + await page.goto("/widget?gear=0"); + await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); + // The gear is gone, so a locked-down link exposes no settings affordance. + // (Scoped to the gear rather than all buttons — `next dev` injects its own + // Dev Tools button, absent from a production build.) + await expect(page.getByRole("button", { name: "Settings" })).toHaveCount(0); +}); + +test("the settings gear opens an in-place overlay that edits the widget live", async ({ + page, +}) => { + await page.goto("/widget"); + + await page.getByRole("button", { name: "Settings" }).click(); + const dialog = page.getByRole("dialog", { name: "Widget settings" }); + await expect(dialog).toBeVisible(); + + // Toggling a section in the overlay mirrors into the address bar immediately + // (history.replaceState) so a reload preserves it and the link stays copyable. + await dialog.getByRole("checkbox", { name: "Workers" }).uncheck(); + await expect(page).toHaveURL(/[?&]workers=0(&|$)/); + + // Close the overlay and confirm the change is applied to the live widget. + await page.getByRole("button", { name: "Close settings" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); }); -test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({ +test("controls=1 adds interactive Pause/Resume, Drain, and Retry buttons", async ({ page, }) => { await page.goto("/widget?controls=1"); @@ -44,6 +81,9 @@ test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({ const pause = page.getByRole("button", { name: "Pause Transcriptions" }); await expect(pause).toBeVisible(); await expect(page.getByRole("button", { name: "Drain all" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Retry all failed" }), + ).toBeVisible(); // Pausing flips the toggle to Resume (the widget refetches workers on change). await pause.click(); @@ -69,6 +109,39 @@ test("section params gate what renders", async ({ page }) => { await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); }); +test("act=1 shows a compact needs-work list of channels", async ({ page }) => { + await resetData("youtube-with-playlist"); + // Visiting the channel detail page auto-generates the snapshot the list reads. + await page.goto("/channels/test-youtube"); + + await page.goto("/widget?act=1"); + const list = page.getByRole("region", { name: "Needs work" }); + await expect(list).toBeVisible({ timeout: 10_000 }); + // youtube-with-playlist leaves 4 videos undownloaded (↓), none awaiting + // transcription — the strip echoes the task-bar download verb. + await expect(list).toContainText("test-youtube"); + await expect(list).toContainText("↓ 4"); +}); + +test("act=1&controls=1 gives the list per-channel Download actions", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + await page.goto("/channels/test-youtube"); // auto-generate snapshot + + await page.goto("/widget?act=1&controls=1"); + const download = page.getByRole("button", { + name: "download missing test-youtube", + }); + await expect(download).toBeVisible({ timeout: 10_000 }); + + // Reuses the /actionable inline action: clicking queues a download-missing job. + await download.click(); + await expect( + page.getByLabel("download missing test-youtube job"), + ).toBeVisible({ timeout: 10_000 }); +}); + test("the builder keeps the shell and composes a link", async ({ page }) => { await page.goto("/widget/builder"); @@ -92,7 +165,7 @@ test("the builder keeps the shell and composes a link", async ({ page }) => { const popupPromise = page.waitForEvent("popup"); await page.getByRole("button", { name: "Open popup" }).click(); const popup = await popupPromise; - await expect(popup).toHaveURL(/\/widget\?workers=0$/); + await expect(popup).toHaveURL(/\/widget\?workers=0(&|$)/); await popup.close(); }); @@ -124,8 +197,16 @@ test("display toggles serialize into the link and preview", async ({ page }) => await expect(url).toHaveValue(/[?&]wnames=0(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]wnames=0(&|$)/); + // New flags: the needs-work list (act) and the settings gear off-switch (gear). + await page.getByRole("checkbox", { name: "Channels needing work" }).check(); + await expect(url).toHaveValue(/[?&]act=1(&|$)/); + await expect(preview).toHaveAttribute("src", /[?&]act=1(&|$)/); + + await page.getByRole("checkbox", { name: "Show settings gear" }).uncheck(); + await expect(url).toHaveValue(/[?&]gear=0(&|$)/); + await page - .getByRole("checkbox", { name: "Show control buttons (pause, drain)" }) + .getByRole("checkbox", { name: "Show control buttons" }) .check(); await expect(url).toHaveValue(/[?&]controls=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/);