Archilyzer · Source

archilyzer

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

commit ac744ed118f3389b5edce6193272fa2e75e5443e
parent 6779e4e3789b805fb9767ed10737cd0031ab6761
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 11 Aug 2026 19:31:50 -0400

Carry in the uncommitted lane-card work from main

This is the working state of the main checkout at 6779e4e, committed here
unchanged so the branch has the baseline the lane-detail plan builds on:
the four lane cards (LaneCard/LaneDeck/LaneRail/laneState/LaneActionButton),
the sweep controls folded into them, and the digest/backfill chips already
removed from the pipeline band.

Not this branch's work — see the following commit for that.

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

Diffstat:
Meditor/CHANGELOG.md | 1+
Meditor/app/components/dashboard/PipelineBand.tsx | 116++++++++++---------------------------------------------------------------------
Aeditor/app/components/lanes/LaneActionButton.tsx | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/lanes/LaneCard.tsx | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/lanes/LaneDeck.tsx | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/lanes/LaneRail.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/lanes/laneState.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/actions.ts | 11++++++-----
Deditor/app/jobs/components/BackfillSweepControls.tsx | 115-------------------------------------------------------------------------------
Deditor/app/jobs/components/DigestSweepControls.tsx | 104-------------------------------------------------------------------------------
Meditor/app/jobs/components/PauseBackfillButton.tsx | 81+++++++++++++++++++++++++++-----------------------------------------------------
Meditor/app/jobs/components/PauseDownloadsButton.tsx | 60++++++++++++++++++++++++------------------------------------
Meditor/app/jobs/components/PauseTranscriptionsButton.tsx | 59++++++++++++++++++++++++++++-------------------------------
Meditor/app/widget/components/MonitorWidget.tsx | 7++-----
Meditor/app/widget/components/WidgetControls.tsx | 75++++++++++++++++++++++++++++++++++++---------------------------------------
15 files changed, 858 insertions(+), 491 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - **Cleanup now says what is holding the audio it can't reclaim, and what to run to get it back.** The page led with one number — how much space you can free right now — and said nothing about the rest of the disk. The sweep skips videos for four different reasons and reported them only as a line in a job log after the fact, with no bytes attached and nothing ranked. A **sieve** now runs down the page: all the audio on disk enters at the top, each gate siphons off its share (no transcript yet, the keep-latest window, do-not-clean pins, awaiting diarization), and the remainder steps down to the reclaimable figure the page already led with. A video leaves at the *first* gate it hits, exactly as the sweep's own cascade does, so the five figures are an attribution and never overlap — a pinned, undiarized video is counted once, under the pin. Below it, the **release ledger** ranks the channels holding the most, split by what it costs to get the space back: a run of a lane that is already weeks deep, or a setting that frees it the moment it changes. "Not counted" sits in the second group and is not called a hold — excluding a channel hides its bytes from the total, it never protected them, which makes it the fastest win on the page. - **Some of that audio is held forever, and nothing anywhere said so.** The cleanup guard fires on `diarization.enabled` alone, but the diarize lane will never produce a sidecar for a video that is over `maxAudioHours`, has no diarizable input, or belongs to a channel whose diarization models were never configured — the kind reports itself disabled and no job is ever queued. Those videos were held from cleaning permanently, and running *Diarize speakers* until the end of time would not have moved the number. The sieve now draws that slice hatched inside gate ④ with its own figure and says it in words: *will not clear on its own*. Deciding what to do about it — raise the cap, configure the models, or let the sweep past the guard — is deliberately left to you: audio is the one input in this pipeline that cannot be regenerated. Channel is the honest granularity throughout; per-video byte sizes exist nowhere outside the report, and inventing them would have cost a corpus walk on every render. +- **Every lane now has one card, and the card shows both of its switches.** Transcription, downloads, digest and backfill each get a card on the dashboard *and* in the monitor widget: the lane's name, its state, its figure, and every control it has, in one place. A hairline under the name reads left → right as feed → gate → lane, so a *held* lane — sweep armed, gate shut — draws as a lit feed running into a break, which is the state that used to look identical to "wedged". The figures that belong to a lane (digest coverage, backfill reachable / needs-media) moved onto their own card, out of the instrument row several elements away from the buttons that move them. The widget gains what it never had: starting and stopping a sweep, and any digest control at all. - **A channel page now opens on the channel's whole lifecycle instead of a viewport of video rows.** The first screen is a single transit line — Playlist → Download → Transcode → Transcribe → Digest → Backfill — where each station carries its count and its coverage, and the gap between two stations carries the shortfall, because the gap *is* the work. Only the largest shortfall is emphasised, so the bottleneck is the thing your eye lands on. Work the lane can actually do today sits on the line; everything that left it (needs cookies, deleted, untranscribable, waiting on a transcript, needs media re-acquired) hangs below it in muted type, on a different axis, so the two can never be read as one number. Below the line: one brand-coloured **next action** that disappears when there is nothing to do, a strip of anomaly chips that says "All clear" rather than vanishing, and the stage panels — **one at a time**, chosen by a tab strip and carried in the URL as `?stage=`, so a stage is a link you can share. With no stage selected you get an overview listing every stage's status at once. Previously the page stacked a full-height video browser above eight simultaneously-expanded stage panels, which put the channel's actual pipeline a full screen and a collapsed `<details>` out of reach. - **The video browser has moved to its own page and no longer builds a DOM node per video.** `/channels/<slug>/videos` is a two-pane workspace: a virtualized list on the left, the selected video's detail on the right, both always visible. The largest channel has ~11,000 videos and the old list rendered every one of them into the page; it now renders the twenty or so you can see. Filters, search and `?video=` selection work exactly as before. The old collapse toggles are gone — they existed only because the pane was squeezed onto a page it shared with everything else, and a page of its own removes the reason for them. - **Command bookmarks are gone.** Saving a job under a label and re-launching it from the strip above **Jobs** / **Active jobs**, and the `/jobs/bookmarks` page that managed those labels, have all been removed. The feature was used for three days after it shipped and never again: every job it could re-launch is still one click away from its own channel-page control, and **Retry** — which re-runs a finished job straight from the descriptor stored with it — covers re-running something you have already run. Retry and **Retry all failed** are untouched and keep working exactly as before, including on jobs old enough to have been evicted from memory. Bucket jobs still re-derive their work from the channel's current state on every re-run rather than replaying a stale list. Your existing `transcripts/.bookmarks/bookmarks.json` is left on disk as a record; nothing reads it any more, and you can delete it whenever you like. diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx @@ -6,10 +6,7 @@ import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs"; import type { WorkersPayload } from "../../workers/components/WorkersView"; import type { WidgetSyncPayload } from "../../api/widget/sync/route"; import { ActiveJobsLive } from "../../jobs/components/ActiveJobsLive"; -import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton"; -import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton"; -import { DigestSweepControls } from "../../jobs/components/DigestSweepControls"; -import { BackfillSweepControls } from "../../jobs/components/BackfillSweepControls"; +import { LaneDeck } from "../lanes/LaneDeck"; import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions"; import { fmtTime } from "../../widget/lib/relativeTime"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; @@ -48,27 +45,10 @@ export function PipelineBand({ // now get separate instruments with their own wording. const disk = jobs?.disk ?? null; const diskLow = disk?.low ?? false; - const digest = sync?.digest ?? null; - // THE DENOMINATOR IS ELIGIBLE VIDEOS, NOT EVERY VIDEO DIRECTORY. `videos` - // counts every directory in the corpus, ~1,700 of which have no transcript or - // are marked untranscribable and so can never carry a digest — against that, - // this bar could not reach 100% however long the sweep ran. `eligible` comes - // from the digest operation's own work list (transcribed, not untranscribable, - // not waiting on transcription), so it is the same population the sweep - // actually walks. - // - // It falls back to `videos` while null, which is the state until every - // channel's snapshot has been regenerated — see the sync payload. That keeps - // the bar reading exactly as it did before rather than blank. - const digestDenominator = - digest === null ? 0 : (digest.eligible ?? digest.videos); - // Deliberately not rounded up. At 0.13% a "1%" would be a lie of the kind - // that makes an 80-day backfill look nearly begun. - const digestPct = - digest && digestDenominator > 0 - ? (digest.digested / digestDenominator) * 100 - : null; - const backfill = sync?.backfill ?? null; + // The digest and backfill figures used to be two more instruments up here, + // several elements away from the buttons that moved them. They now sit inside + // their own lane cards — state, figure and both switches in one place — which + // is the whole point of LaneDeck. const lastSyncText = sync == null @@ -186,87 +166,19 @@ export function PipelineBand({ } /> )} - {backfill?.anyKind && ( - <Instrument - dotClass={ - backfill.sweeping - ? "bg-success" - : backfill.enabled - ? "bg-success/40" - : "bg-muted-foreground/40" - } - label={ - <> - {"\u21ba backfill "} - <span className="font-medium"> - {backfill.reachable.toLocaleString()} - </span> - {" reachable"} - {backfill.needsMedia > 0 && ( - <span className="text-muted-foreground"> - {" "} - (+{backfill.needsMedia.toLocaleString()} need media) - </span> - )} - {!backfill.enabled && ( - <span className="text-muted-foreground"> · lane off</span> - )} - {backfill.enabled && backfill.sweeping && " · sweeping"} - </> - } - /> - )} - {digest && ( - <Instrument - dotClass={ - digest.paused - ? "bg-destructive" - : digest.sweeping - ? "bg-success" - : "bg-muted-foreground/40" - } - label={ - <> - ◆ digests{" "} - <span className="font-medium"> - {digest.digested.toLocaleString()} - </span> - {digestPct !== null && ( - <span className="text-muted-foreground"> - {" "} - ({digestPct < 1 ? digestPct.toFixed(2) : digestPct.toFixed(1)}% - of {digest.videos.toLocaleString()}) - </span> - )} - {digest.paused && ( - <span className="text-destructive"> · paused</span> - )} - {!digest.paused && digest.sweeping && " · sweeping"} - </> - } - /> - )} </div> </div> + <LaneDeck + workers={workers} + sync={sync} + onWorkersChange={onWorkersChange} + onSynced={onSynced} + /> + + {/* What is left is not a lane: a one-shot sweep of every channel, and + the way in for a channel that does not exist yet. */} <div className="flex flex-wrap items-center gap-2"> - <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} /> - <PauseDownloadsButton - paused={downloadsPaused} - onChange={onWorkersChange} - /> - <DigestSweepControls - sweeping={digest?.sweeping ?? false} - paused={digest?.paused ?? false} - onChange={onSynced} - /> - {backfill?.anyKind && ( - <BackfillSweepControls - sweeping={backfill.sweeping} - laneEnabled={backfill.enabled} - onChange={onSynced} - /> - )} <SyncAllButton onSynced={onSynced} /> <Link href="/channels/new" diff --git a/editor/app/components/lanes/LaneActionButton.tsx b/editor/app/components/lanes/LaneActionButton.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +// THE button. Six components used to carry a copy of everything below — the same +// two class strings, the same hydration guard, the same run() and the same +// inline error span — and copies of a hydration guard are exactly the kind of +// thing that goes missing in one place. +// +// `ariaLabel` is explicit and required because these labels are a CONTRACT: the +// e2e suite selects several of these buttons by accessible name, and in the +// dense (rail) frame the visible label collapses to a glyph, at which point the +// aria-label is the only name left. + +export type LaneActionResult = { ok: boolean; error?: string } | void; + +export function LaneActionButton({ + label, + glyph, + ariaLabel, + title, + variant, + disabled, + dense, + action, + onChange, +}: { + label: string; + // What the label collapses to in a dense frame. Falls back to the first + // character, which is never what you want — pass one. + glyph?: string; + ariaLabel: string; + title?: string; + // `active` = this control is the one holding/stopping something right now + // (Resume, Stop): warning-filled, so a held lane is visible from across the + // room. `idle` = the quiet outline. + variant: "active" | "idle"; + disabled?: boolean; + dense?: boolean; + action: () => Promise<LaneActionResult>; + onChange?: () => void | Promise<void>; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + const router = useRouter(); + // Disabled until hydrated. A click on a server-rendered button before React + // attaches fires NOTHING — no request, no job, no error — which is the + // recorded root cause of the digest pilot's "un-created job". + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + function run() { + setError(null); + startTransition(async () => { + const result = await action(); + if (result && !result.ok) setError(result.error ?? "Failed."); + if (onChange) await onChange(); + else router.refresh(); + }); + } + + const busy = pending || !mounted; + const tone = + variant === "active" + ? "bg-warning text-warning-foreground font-medium hover:bg-warning/90" + : "border border-border hover:bg-muted"; + const size = dense + ? "size-6 rounded-md text-xs leading-none" + : "px-3 py-1.5 rounded-md text-sm"; + + return ( + <> + <button + type="button" + disabled={busy || disabled} + aria-label={ariaLabel} + onClick={run} + title={title ?? label} + className={`inline-flex items-center justify-center ${size} ${tone} disabled:opacity-50`} + > + {dense ? (glyph ?? label.slice(0, 1)) : label} + </button> + {error && ( + <span role="alert" className="text-xs text-destructive"> + {error} + </span> + )} + </> + ); +} diff --git a/editor/app/components/lanes/LaneCard.tsx b/editor/app/components/lanes/LaneCard.tsx @@ -0,0 +1,130 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useSectionFrame } from "../../widget/components/WidgetSection"; +import { LaneRail } from "./LaneRail"; +import { + LANE_DOT, + LANE_TEXT, + LANE_WORD, + type LaneState, +} from "./laneState"; +import { LaneActionButton, type LaneActionResult } from "./LaneActionButton"; + +// One lane: its name, its state, its figure and BOTH its switches, in one place. +// +// The card exists because those four things used to live in four places — a +// status word in a grey span after the buttons, the numbers it referred to in +// chips several elements away (the dashboard) or in a strip that could be +// dragged to another corner entirely (the widget). An operator asking "is the +// backfill actually doing anything" had to assemble the answer. +// +// RESTRAINT: the surface stays `bg-card` in every state. The rail says it and +// the dot says it; a third tinted background would be the accessory to remove. + +export type LaneControl = { + key: string; + label: string; + glyph: string; + ariaLabel: string; + title?: string; + variant: "active" | "idle"; + disabled?: boolean; + action: () => Promise<LaneActionResult>; + onChange?: () => void | Promise<void>; +}; + +export function LaneCard({ + name, + state, + figure, + note, + controls, +}: { + name: string; + state: LaneState; + // The lane's own number, in the house's tabular mono. For a lane with no sweep + // this is the sentence saying so. + figure: ReactNode; + // Anything the card must keep saying in words — the backfill lane's "the sweep + // is holding", which the rail now also draws. + note?: ReactNode; + controls: LaneControl[]; +}) { + // The widget provides this per cell; outside the widget the default is + // {scroll:false, dense:false}, so the dashboard needs no provider. + const { dense } = useSectionFrame(); + + const head = ( + <span className="flex items-center gap-1.5 text-xs"> + <span + className={`inline-block h-2 w-2 shrink-0 rounded-full ${LANE_DOT[state]}`} + /> + <span className={LANE_TEXT[state]}>{LANE_WORD[state]}</span> + </span> + ); + + if (dense) { + // A rail cell has room for one line. The rail becomes the left EDGE — same + // reading, turned ninety degrees — and the buttons collapse to glyphs, whose + // aria-labels carry the meaning they always did. + return ( + <div + role="group" + aria-label={`${name.toLowerCase()} lane`} + className="flex items-center gap-1.5 text-xs" + > + <span + aria-hidden="true" + className={`h-4 w-0.5 shrink-0 ${ + state === "running" + ? "bg-info" + : state === "holding" + ? "bg-warning" + : "bg-border" + }`} + /> + <span className="font-mono uppercase tracking-[0.14em] text-muted-foreground"> + {name} + </span> + {head} + <span className="font-mono tabular-nums text-muted-foreground"> + {figure} + </span> + {/* Kept even here. The rail-as-edge cannot draw a break, so in the one + frame where the drawing says least, the words are all there is. */} + {note} + {controls.map(({ key, ...c }) => ( + <LaneActionButton key={key} dense {...c} /> + ))} + </div> + ); + } + + return ( + <div + role="group" + aria-label={`${name.toLowerCase()} lane`} + className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2.5" + > + <div className="flex items-baseline justify-between gap-2"> + <h3 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> + {name} + </h3> + {head} + </div> + <LaneRail state={state} /> + <p className="font-mono text-xs tabular-nums text-muted-foreground"> + {figure} + </p> + {note} + {controls.length > 0 && ( + <div className="flex flex-wrap items-center gap-1.5"> + {controls.map(({ key, ...c }) => ( + <LaneActionButton key={key} {...c} /> + ))} + </div> + )} + </div> + ); +} diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -0,0 +1,377 @@ +"use client"; + +import type { WidgetSyncPayload } from "../../api/widget/sync/route"; +import type { WorkersPayload } from "../../workers/components/WorkersView"; +import { useSectionFrame } from "../../widget/components/WidgetSection"; +import { LaneCard, type LaneControl } from "./LaneCard"; +import { deriveLaneState, formatCount } from "./laneState"; +import { + pauseAllWorkersAction, + resumeAllWorkersAction, +} from "../../workers/actions"; +import { + pauseBackfillAction, + pauseDigestsAction, + pauseDownloadsAction, + resumeBackfillAction, + resumeDigestsAction, + resumeDownloadsAction, + startBackfillSweepAction, + startDigestSweepAction, + stopBackfillSweepAction, + stopDigestSweepAction, +} from "../../jobs/actions"; + +// The four lanes, and the ONLY place lane → server-action wiring lives. Rendered +// identically by the dashboard band and the monitor widget's controls section, +// which is what stops the two surfaces from growing different ideas about what +// a lane can do (the widget could not start or stop a sweep at all, and had no +// digest control whatsoever). +// +// Each lane arrives on the wire in its own shape — transcription and downloads +// have a pause and no sweep, digest has `sweeping` + `paused`, backfill has +// `sweeping` + `enabled` (an inverted pause) — and this is where all four are +// normalised onto { gateHeld, feedRunning }. See laneState.ts for why those two +// are the axes. + +export function LaneDeck({ + workers, + sync, + onWorkersChange, + onSynced, +}: { + workers: WorkersPayload | null; + sync: WidgetSyncPayload | null; + onWorkersChange: () => void | Promise<void>; + onSynced: () => void | Promise<void>; +}) { + const { dense } = useSectionFrame(); + + const paused = workers?.paused ?? false; + const downloadsPaused = workers?.downloadsPaused ?? false; + const busy = (workers?.workers ?? []).filter((w) => w.busy).length; + const digest = sync?.digest ?? null; + const backfill = sync?.backfill ?? null; + + // THE DENOMINATOR IS ELIGIBLE VIDEOS, NOT EVERY VIDEO DIRECTORY. `videos` + // counts every directory in the corpus, ~1,700 of which have no transcript or + // are marked untranscribable and so can never carry a digest — against that, + // this figure could not reach 100% however long the sweep ran. `eligible` + // comes from the digest operation's own work list (transcribed, not + // untranscribable, not waiting on transcription), so it is the same population + // the sweep actually walks. + // + // It falls back to `videos` while null, which is the state until every + // channel's snapshot has been regenerated — see the sync payload. + const digestDenominator = + digest === null ? 0 : (digest.eligible ?? digest.videos); + // Deliberately not rounded up. At 0.13% a "1%" would be a lie of the kind that + // makes an 80-day backfill look nearly begun. + const digestPct = + digest && digestDenominator > 0 + ? (digest.digested / digestDenominator) * 100 + : null; + + // ── Transcription ───────────────────────────────────────────────────────── + // Global pause/resume for transcription workers. Pausing stops handing out new + // work AND gracefully stops in-flight parakeet jobs after the current segment + // (cached for resume); other engines run their in-flight file to completion. + const transcription = ( + <LaneCard + name="Transcription" + state={deriveLaneState({ gateHeld: paused, activeCount: busy })} + figure="no sweep — work arrives from jobs" + controls={[ + paused + ? { + key: "transcription", + label: "Resume Transcriptions", + glyph: "▶", + ariaLabel: "Resume Transcriptions", + variant: "active", + action: resumeAllWorkersAction, + onChange: onWorkersChange, + } + : { + key: "transcription", + label: "Pause Transcriptions", + glyph: "❙❙", + ariaLabel: "Pause Transcriptions", + title: + "Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run", + variant: "idle", + action: pauseAllWorkersAction, + onChange: onWorkersChange, + }, + ]} + /> + ); + + // ── Downloads ───────────────────────────────────────────────────────────── + // Persisted in settings.json (downloadsPaused — survives a restart). Pausing + // gates the auto-download runner on its next loop iteration and makes manual + // download-bearing pipeline actions return a "Downloads are paused" notice; + // store-playlist/enumeration stay allowed. + const downloads = ( + <LaneCard + name="Downloads" + state={deriveLaneState({ gateHeld: downloadsPaused })} + figure="no sweep — work arrives from jobs" + controls={[ + downloadsPaused + ? { + key: "downloads", + label: "Resume Downloads", + glyph: "▶", + ariaLabel: "Resume Downloads", + variant: "active", + action: resumeDownloadsAction, + onChange: onWorkersChange, + } + : { + key: "downloads", + label: "Pause Downloads", + glyph: "❙❙", + ariaLabel: "Pause Downloads", + title: + "Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs.", + variant: "idle", + action: pauseDownloadsAction, + onChange: onWorkersChange, + }, + ]} + /> + ); + + // ── Digest ──────────────────────────────────────────────────────────────── + // TWO CONTROLS, AND THEY ARE NOT THE SAME CONTROL — conflating them is how an + // operator loses a week of GPU time: + // + // Sweep on/off — is there a corpus-wide backfill at all. Persisted, so a + // server restart resumes it (editor/instrumentation.ts). + // Pause/resume — hold a running sweep at zero throughput without ending it. + // The batch's limit() returns 0, which makes the pool idle-WAIT rather + // than finish, so resuming costs nothing and re-derives nothing. + // + // Stopping the sweep drains rather than cancels: the channel in flight + // finishes instead of losing a part-generated video. + const digestSweeping = digest?.sweeping ?? false; + const digestPaused = digest?.paused ?? false; + const digestControls: LaneControl[] = [ + digestSweeping + ? { + key: "digest-sweep", + label: "Stop Digest Sweep", + glyph: "■", + ariaLabel: "stop digest sweep", + title: + "Stop the corpus-wide digest backfill. The channel in flight finishes first; nothing already generated is lost.", + variant: "active", + action: stopDigestSweepAction, + onChange: onSynced, + } + : { + key: "digest-sweep", + label: "Start Digest Sweep", + glyph: "⟳", + ariaLabel: "start digest sweep", + title: + "Start the corpus-wide digest backfill: every channel in turn, heaviest first by remaining audio-hours. Survives a restart.", + variant: "idle", + action: startDigestSweepAction, + onChange: onSynced, + }, + ]; + // The pause only appears once there is something to hold — or once it is + // already holding, which is the state you have to be able to get out of. + if (digestSweeping || digestPaused) { + digestControls.push( + digestPaused + ? { + key: "digest-gate", + label: "Resume Digests", + glyph: "▶", + ariaLabel: "resume digests", + title: + "Resume digest generation. The running job picks up where it left off — it was holding, not stopped.", + variant: "active", + action: resumeDigestsAction, + onChange: onSynced, + } + : { + key: "digest-gate", + label: "Pause Digests", + glyph: "❙❙", + ariaLabel: "pause digests", + title: + "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly.", + variant: "idle", + action: pauseDigestsAction, + onChange: onSynced, + }, + ); + } + + const digestLane = ( + <LaneCard + name="Digest" + state={deriveLaneState({ + gateHeld: digestPaused, + feedRunning: digestSweeping, + })} + figure={ + digest === null ? ( + "—" + ) : ( + <> + {formatCount(digest.digested)} digested + {digestPct !== null && ( + <> + {" · "} + {digestPct < 1 ? digestPct.toFixed(2) : digestPct.toFixed(1)}% of{" "} + {digestDenominator.toLocaleString()} + </> + )} + </> + ) + } + controls={digestControls} + /> + ); + + // ── Backfill ────────────────────────────────────────────────────────────── + // Arm / disarm the corpus-wide backfill sweep, and hold it without ending it. + // + // TWO CONTROLS, AND THIS REVISED AN EARLIER DECISION. The backfill lane used + // to carry only the sweep, on the reasoning that at the default weight the + // lane is ALREADY idle-only — standing aside whenever transcription works — so + // a manual pause looked redundant. + // + // That covered the wrong hazard. Idle-only yielding handles "get out of the + // transcription lane's way"; it does nothing for "this is a desktop someone is + // sitting at, and diarization pins four cores for hours." Backfill work is + // CPU-bound and long — a diarization pass over the corpus runs for days — so + // the operator needs a hold for reasons the scheduler cannot see. + // + // The pause writes THE SAME FIELD the Settings checkbox writes + // (settings.backfill.enabled) rather than a new `backfillPaused` flag. One + // field, several places to set it, and they cannot drift — which is why + // backfill.spec asserts the settings field through this button's label rather + // than just watching the label flip. + // + // Stopping the SWEEP, by contrast, drains rather than cancels: the video in + // flight finishes instead of being thrown away, and the stop reaches the + // per-channel job the sweep is waiting on rather than meaning "after this + // channel". Pause when you want it back; stop when you don't. + const backfillSweeping = backfill?.sweeping ?? false; + const laneEnabled = backfill?.enabled ?? false; + const backfillAvailable = backfill?.anyKind ?? false; + const backfillControls: LaneControl[] = backfillAvailable + ? [ + backfillSweeping + ? { + key: "backfill-sweep", + label: "Stop Backfill Sweep", + glyph: "■", + ariaLabel: "stop backfill sweep", + title: + "Stop the corpus-wide backfill. The channel in flight finishes first; nothing already written is lost.", + variant: "active", + action: stopBackfillSweepAction, + onChange: onSynced, + } + : { + key: "backfill-sweep", + label: "Start Backfill Sweep", + glyph: "⟳", + ariaLabel: "start backfill sweep", + title: + "Start the corpus-wide backfill: every channel in turn, heaviest first by reachable work. Survives a restart.", + variant: "idle", + action: () => startBackfillSweepAction(), + onChange: onSynced, + }, + laneEnabled + ? { + key: "backfill-gate", + label: "Pause Backfill", + glyph: "❙❙", + ariaLabel: "pause backfill", + title: + "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart.", + variant: "idle", + action: pauseBackfillAction, + onChange: onSynced, + } + : { + key: "backfill-gate", + label: "Resume Backfill", + glyph: "▶", + ariaLabel: "resume backfill", + title: + "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped.", + variant: "active", + action: resumeBackfillAction, + onChange: onSynced, + }, + ] + : []; + + const backfillLane = ( + <LaneCard + name="Backfill" + // `anyKind` false is UNAVAILABLE, not idle: with no backfill feature + // registered there is nothing to hold, and an empty work list because a + // feature is switched off must not read as "all caught up". + state={deriveLaneState({ + available: backfillAvailable, + gateHeld: !laneEnabled, + feedRunning: backfillSweeping, + })} + figure={ + backfill === null || !backfillAvailable ? ( + "no backfill feature is on" + ) : ( + <> + {formatCount(backfill.reachable)} reachable + {/* A SEPARATE FIGURE, never summed into the one beside it: on the + measured corpus these are 835 and ~76,270, and one total would + report the work as untouched forever. */} + {backfill.needsMedia > 0 && ( + <> · {formatCount(backfill.needsMedia)} need media</> + )} + </> + ) + } + note={ + backfillSweeping && !laneEnabled ? ( + // Kept in words as well as in the rail. A sweep armed with the lane + // off holds at a zero limit rather than doing work, and "wedged" is + // what that looks like to anyone who does not already know. + <span + aria-label="backfill lane off" + className="text-xs text-muted-foreground" + > + the sweep is holding + </span> + ) : undefined + } + controls={backfillControls} + /> + ); + + return ( + <div + className={ + dense + ? "flex flex-wrap items-center gap-x-3 gap-y-1" + : "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4" + } + > + {transcription} + {downloads} + {digestLane} + {backfillLane} + </div> + ); +} diff --git a/editor/app/components/lanes/LaneRail.tsx b/editor/app/components/lanes/LaneRail.tsx @@ -0,0 +1,43 @@ +import type { LaneState } from "./laneState"; + +// The signature element: a hairline that reads left → right as FEED → GATE → +// LANE. It exists to say the thing the status word cannot — *where* the flow +// stops. A held lane is not a stopped one, and the difference is a break at the +// gate with the feed still lit behind it. +// +// Continuous with the channel page's transit line rather than imported from +// anywhere: same tokens, same dot vocabulary, same single animation. +// +// aria-hidden by construction. The dot and the state word beside it carry the +// meaning; this is the drawing of it. +export function LaneRail({ state }: { state: LaneState }) { + return ( + <div aria-hidden="true" className="flex h-2 items-center"> + {state === "running" && ( + <> + <span className="h-0.5 flex-1 bg-info" /> + {/* The gate, open: the pulse is sitting in it. */} + <span className="relative flex h-2 w-0.5 items-center justify-center bg-info"> + <span className="absolute h-1.5 w-1.5 rounded-full bg-info animate-pulse motion-reduce:animate-none" /> + </span> + <span className="h-0.5 flex-[2] bg-info" /> + </> + )} + {state === "holding" && ( + <> + {/* Feed still running… */} + <span className="h-0.5 flex-1 bg-warning" /> + {/* …into a shut gate… */} + <span className="h-2 w-0.5 bg-warning" /> + {/* …and the break is the point: nothing is moving past it. */} + <span className="w-2" /> + <span className="h-0 flex-[2] border-t border-dashed border-border" /> + </> + )} + {state === "idle" && ( + <span className="h-0 flex-1 border-t border-dashed border-border" /> + )} + {state === "unavailable" && <span className="h-px flex-1 bg-border" />} + </div> + ); +} diff --git a/editor/app/components/lanes/laneState.ts b/editor/app/components/lanes/laneState.ts @@ -0,0 +1,79 @@ +import { + STATION_DOT, + STATION_TEXT, + formatCount, +} from "../../channels/[slug]/components/flow/tone"; + +// A lane's state, and the one derivation every surface reads it from. +// +// THE POINT OF THIS TYPE IS THAT A LANE HAS TWO SWITCHES, NOT ONE. There is the +// SWEEP — the producer that feeds the lane work — and the GATE — the pause that +// decides whether the lane consumes it. They are independent, and the state +// nobody had a word for is the combination "sweep armed, gate shut": the lane is +// not stopped and it is not working. That is `holding`, and it is the whole +// reason this is four states rather than a boolean. +// +// The four lanes model themselves four different ways on the wire (transcription +// and downloads have a pause and no sweep; digest has `sweeping` + `paused`; +// backfill has `sweeping` + `enabled`, an INVERTED pause). Normalising happens +// once, in LaneDeck, and everything downstream reads only this. +export type LaneState = "running" | "holding" | "idle" | "unavailable"; + +export function deriveLaneState({ + available = true, + gateHeld, + feedRunning = false, + activeCount = 0, +}: { + // The lane exists at all. `false` is not "idle": with no backfill feature + // registered there is nothing to hold, and an idle-looking lane would read as + // "all caught up" when the truth is "switched off". + available?: boolean; + gateHeld: boolean; + // A corpus-wide sweep is armed. Absent for the lanes fed by jobs instead. + feedRunning?: boolean; + // Work in flight right now, for lanes with no sweep of their own. + activeCount?: number; +}): LaneState { + if (!available) return "unavailable"; + // The gate decides first. A sweep armed behind a shut gate is HOLDING, not + // running — the batch's limit() returns 0 and the pool idle-waits, which looks + // exactly like wedged unless something says otherwise. + if (gateHeld) return "holding"; + if (feedRunning || activeCount > 0) return "running"; + return "idle"; +} + +// No new palette. These map onto the station tones the channel line already +// uses (see flow/tone.ts): four theme families × light/dark means a bespoke hue +// here would be wrong in eight palettes at once. Deliberately NOT a second copy +// of those maps — flow/OverviewPanel already made one, and three would be a +// guarantee they drift. +export const LANE_DOT: Record<LaneState, string> = { + // The travelling pulse, paired with motion-reduce as everything animated here + // is. + running: `${STATION_DOT.running} motion-reduce:animate-none`, + holding: STATION_DOT.attention, + idle: STATION_DOT.neutral, + unavailable: STATION_DOT.neutral, +}; + +export const LANE_TEXT: Record<LaneState, string> = { + running: STATION_TEXT.running, + holding: STATION_TEXT.attention, + idle: STATION_TEXT.neutral, + unavailable: STATION_TEXT.neutral, +}; + +// The word beside the dot. This — not the rail — is what assistive tech reads, +// so it has to stand on its own. +export const LANE_WORD: Record<LaneState, string> = { + running: "Running", + holding: "Holding", + idle: "Idle", + unavailable: "Off", +}; + +// Re-exported so a lane figure formats its counts the same way a station does, +// including the "—, never 0" rule: a zero would claim a measurement nobody took. +export { formatCount }; diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts @@ -288,11 +288,12 @@ export async function stopBackfillSweepAction(): Promise<BackfillSweepResult> { // // So: one field, two places to set it, and they cannot drift. // -// This REVISES the "one button, not two" note in BackfillSweepControls.tsx. -// That reasoning was that idle-only weighting made a manual hold unnecessary — -// true for standing aside from transcription, but it gives an operator no way -// to stop a long CPU-bound backfill for reasons of their own (the machine is a -// desktop someone is using). See that file's header for the revised rationale. +// This REVISES an earlier "one button, not two" decision. That reasoning was +// that idle-only weighting made a manual hold unnecessary — true for standing +// aside from transcription, but it gives an operator no way to stop a long +// CPU-bound backfill for reasons of their own (the machine is a desktop someone +// is using). See the backfill section of app/components/lanes/LaneDeck.tsx for +// the revised rationale. // // Needs no boot hook: the flag is consulted at dispatch, not applied to a live // pool, and it is already persisted in settings.json. diff --git a/editor/app/jobs/components/BackfillSweepControls.tsx b/editor/app/jobs/components/BackfillSweepControls.tsx @@ -1,115 +0,0 @@ -"use client"; - -import { useEffect, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { - startBackfillSweepAction, - stopBackfillSweepAction, -} from "../actions"; -import { PauseBackfillButton } from "./PauseBackfillButton"; - -// Arm / disarm the corpus-wide backfill sweep, and hold it without ending it. -// -// TWO BUTTONS, AND THIS REVISES AN EARLIER DECISION. This component used to -// carry one button and a note explaining why: at the default weight the lane is -// ALREADY idle-only, standing aside whenever transcription works, so a manual -// pause looked redundant, and a second control was thought to invite exactly the -// confusion DigestSweepControls' pair exists to prevent. -// -// That reasoning covered the wrong hazard. Idle-only yielding handles "get out -// of the transcription lane's way"; it does nothing for "this is a desktop -// someone is sitting at, and diarization pins four cores for hours." Backfill -// work is CPU-bound and long — a diarization pass over the corpus runs for -// days — so the operator needs a hold for reasons the scheduler cannot see. The -// hold already existed; it was only reachable from the Settings page, which is -// a strange place to look for a control over a job you are watching run. -// -// So the pause is surfaced here, and it writes THE SAME FIELD the Settings -// checkbox writes (settings.backfill.enabled) rather than a new `backfillPaused` -// flag. One field, several places to set it, and they cannot drift. See -// pauseBackfillAction in ../actions for why that is the right field. -// -// The pause button itself now lives in ./PauseBackfillButton — the monitor -// widget's controls row wants it too, and one implementation is what keeps the -// two surfaces honest. Everything below is the SWEEP. -// -// Stopping the SWEEP, by contrast, drains rather than cancels: the video in -// flight finishes instead of being thrown away, and the stop reaches the -// per-channel job the sweep is waiting on rather than meaning "after this -// channel". Pause when you want it back; stop when you don't. -export function BackfillSweepControls({ - sweeping, - laneEnabled, - onChange, -}: { - sweeping: boolean; - // settings.backfill.enabled. A sweep can be armed with the lane off — the - // batch then holds at a zero limit rather than doing work — so say so instead - // of letting it look wedged. - laneEnabled: boolean; - onChange?: () => void | Promise<void>; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const router = useRouter(); - // Disabled until hydrated. A click on a server-rendered button before React - // attaches fires NOTHING — no request, no job, no error — which is the - // recorded root cause of the digest pilot's "un-created job". - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - - function run(fn: () => Promise<{ ok: boolean; error?: string }>) { - setError(null); - startTransition(async () => { - const result = await fn(); - if (!result.ok) setError(result.error ?? "Failed."); - if (onChange) await onChange(); - else router.refresh(); - }); - } - - const disabled = pending || !mounted; - - return ( - <div className="flex items-center gap-2"> - <button - type="button" - disabled={disabled} - aria-label={sweeping ? "stop backfill sweep" : "start backfill sweep"} - onClick={() => - run( - sweeping - ? stopBackfillSweepAction - : () => startBackfillSweepAction(), - ) - } - title={ - sweeping - ? "Stop the corpus-wide backfill. The channel in flight finishes first; nothing already written is lost." - : "Start the corpus-wide backfill: every channel in turn, heaviest first by reachable work. Survives a restart." - } - className={ - sweeping - ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50" - : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50" - } - > - {sweeping ? "Stop Backfill Sweep" : "Start Backfill Sweep"} - </button> - <PauseBackfillButton paused={!laneEnabled} onChange={onChange} /> - {sweeping && !laneEnabled && ( - <span - aria-label="backfill lane off" - className="text-xs text-muted-foreground" - > - the sweep is holding - </span> - )} - {error && ( - <span role="alert" className="text-xs text-destructive"> - {error} - </span> - )} - </div> - ); -} diff --git a/editor/app/jobs/components/DigestSweepControls.tsx b/editor/app/jobs/components/DigestSweepControls.tsx @@ -1,104 +0,0 @@ -"use client"; - -import { useEffect, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { - pauseDigestsAction, - resumeDigestsAction, - startDigestSweepAction, - stopDigestSweepAction, -} from "../actions"; - -// The two digest controls, side by side, because they are NOT the same control -// and conflating them is how an operator loses a week of GPU time: -// -// Sweep on/off — is there a corpus-wide backfill at all. Persisted, so a -// server restart resumes it (editor/instrumentation.ts). -// Pause/resume — hold a running sweep at zero throughput without ending it. -// The batch's limit() returns 0, which makes the pool idle-WAIT rather than -// finish, so resuming costs nothing and re-derives nothing. -// -// Stopping the sweep drains rather than cancels: the channel in flight finishes -// instead of losing a part-generated video. -export function DigestSweepControls({ - sweeping, - paused, - onChange, -}: { - sweeping: boolean; - paused: boolean; - onChange?: () => void | Promise<void>; -}) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const router = useRouter(); - // Disabled until hydrated. A click on a server-rendered button before React - // attaches fires nothing at all — no request, no job, no error — which is the - // recorded root cause of the digest pilot's "un-created job". - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - - function run(fn: () => Promise<{ ok: boolean; error?: string }>) { - setError(null); - startTransition(async () => { - const result = await fn(); - if (!result.ok) setError(result.error ?? "Failed."); - if (onChange) await onChange(); - else router.refresh(); - }); - } - - const disabled = pending || !mounted; - - return ( - <div className="flex items-center gap-2"> - <button - type="button" - disabled={disabled} - aria-label={sweeping ? "stop digest sweep" : "start digest sweep"} - onClick={() => - run(sweeping ? stopDigestSweepAction : startDigestSweepAction) - } - title={ - sweeping - ? "Stop the corpus-wide digest backfill. The channel in flight finishes first; nothing already generated is lost." - : "Start the corpus-wide digest backfill: every channel in turn, heaviest first by remaining audio-hours. Survives a restart." - } - className={ - sweeping - ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50" - : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50" - } - > - {sweeping ? "Stop Digest Sweep" : "Start Digest Sweep"} - </button> - {(sweeping || paused) && ( - <button - type="button" - disabled={disabled} - aria-label={paused ? "resume digests" : "pause digests"} - onClick={() => - run(paused ? resumeDigestsAction : pauseDigestsAction) - } - title={ - paused - ? "Resume digest generation. The running job picks up where it left off — it was holding, not stopped." - : "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly." - } - className={ - paused - ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50" - : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50" - } - > - {paused ? "Resume Digests" : "Pause Digests"} - </button> - )} - {error && ( - <span role="alert" className="text-xs text-destructive"> - {error} - </span> - )} - </div> - ); -} diff --git a/editor/app/jobs/components/PauseBackfillButton.tsx b/editor/app/jobs/components/PauseBackfillButton.tsx @@ -1,16 +1,10 @@ "use client"; -import { useEffect, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; +import { LaneActionButton } from "../../components/lanes/LaneActionButton"; import { pauseBackfillAction, resumeBackfillAction } from "../actions"; // Hold the backfill lane without ending anything, in the prop shape the other -// two pause buttons use ({ paused, disabled?, onChange? }) so all three can sit -// side by side wherever work is being watched — the dashboard cockpit, and now -// the monitor widget's controls row. -// -// Extracted from BackfillSweepControls, which still renders it: one -// implementation, so the dashboard and the widget cannot drift. +// two pause buttons use ({ paused, disabled?, onChange? }). // // It is a REAL pause, not a stop. backfillBatch's limit() re-reads // settings.backfill.enabled at dispatch (~3s idle poll) and returns 0, so the @@ -19,6 +13,10 @@ import { pauseBackfillAction, resumeBackfillAction } from "../actions"; // rather than a second `backfillPaused` flag, so the two cannot disagree — which // is why backfill.spec asserts the settings field through this button's label // rather than just the label flipping. +// +// A thin wrapper over the shared lane button: the dashboard and the widget both +// reach this gate through LaneDeck, and this keeps a direct call site available +// without a second implementation. export function PauseBackfillButton({ paused, disabled, @@ -29,51 +27,26 @@ export function PauseBackfillButton({ disabled?: boolean; onChange?: () => void | Promise<void>; }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState<string | null>(null); - const router = useRouter(); - // Disabled until hydrated. A click on a server-rendered button before React - // attaches fires NOTHING — no request, no job, no error — which is the - // recorded root cause of the digest pilot's "un-created job". - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - - function toggle() { - setError(null); - startTransition(async () => { - const result = await (paused ? resumeBackfillAction() : pauseBackfillAction()); - if (!result.ok) setError(result.error ?? "Failed."); - if (onChange) await onChange(); - else router.refresh(); - }); - } - - const busy = pending || !mounted; - return ( - <> - <button - type="button" - disabled={busy || (!paused && disabled)} - aria-label={paused ? "resume backfill" : "pause backfill"} - onClick={toggle} - title={ - paused - ? "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped." - : "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart." - } - className={ - paused - ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50" - : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50" - } - > - {paused ? "Resume Backfill" : "Pause Backfill"} - </button> - {error && ( - <span role="alert" className="text-xs text-destructive"> - {error} - </span> - )} - </> + return paused ? ( + <LaneActionButton + label="Resume Backfill" + glyph="▶" + ariaLabel="resume backfill" + title="Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped." + variant="active" + action={resumeBackfillAction} + onChange={onChange} + /> + ) : ( + <LaneActionButton + label="Pause Backfill" + glyph="❙❙" + ariaLabel="pause backfill" + title="Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart." + variant="idle" + disabled={disabled} + action={pauseBackfillAction} + onChange={onChange} + /> ); } diff --git a/editor/app/jobs/components/PauseDownloadsButton.tsx b/editor/app/jobs/components/PauseDownloadsButton.tsx @@ -1,18 +1,16 @@ "use client"; -import { useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { - pauseDownloadsAction, - resumeDownloadsAction, -} from "../actions"; +import { LaneActionButton } from "../../components/lanes/LaneActionButton"; +import { pauseDownloadsAction, resumeDownloadsAction } from "../actions"; // Global pause/resume for downloads, persisted in settings.json (downloadsPaused // — survives a restart). Pausing gates the auto-download runner on its next loop // iteration and makes manual download-bearing pipeline actions return a -// "Downloads are paused" notice; store-playlist/enumeration stay allowed. Mirrors -// PauseTranscriptionsButton so the dashboard cockpit and the monitor widget can -// present the two pauses side by side. +// "Downloads are paused" notice; store-playlist/enumeration stay allowed. +// +// A thin wrapper over the shared lane button, in the same prop shape as +// PauseTranscriptionsButton — see there for why. The dashboard and the widget +// render the downloads gate through LaneDeck instead. export function PauseDownloadsButton({ paused, disabled, @@ -22,35 +20,25 @@ export function PauseDownloadsButton({ disabled?: boolean; onChange?: () => void | Promise<void>; }) { - const [pending, startTransition] = useTransition(); - const router = useRouter(); - - function toggle() { - startTransition(async () => { - await (paused ? resumeDownloadsAction() : pauseDownloadsAction()); - if (onChange) await onChange(); - else router.refresh(); - }); - } - return paused ? ( - <button - type="button" - disabled={pending} - onClick={toggle} - className="px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50" - > - Resume Downloads - </button> + <LaneActionButton + label="Resume Downloads" + glyph="▶" + ariaLabel="Resume Downloads" + variant="active" + action={resumeDownloadsAction} + onChange={onChange} + /> ) : ( - <button - type="button" - disabled={pending || disabled} - onClick={toggle} + <LaneActionButton + label="Pause Downloads" + glyph="❙❙" + ariaLabel="Pause Downloads" title="Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs." - className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50" - > - Pause Downloads - </button> + variant="idle" + disabled={disabled} + action={pauseDownloadsAction} + onChange={onChange} + /> ); } diff --git a/editor/app/jobs/components/PauseTranscriptionsButton.tsx b/editor/app/jobs/components/PauseTranscriptionsButton.tsx @@ -1,7 +1,6 @@ "use client"; -import { useTransition } from "react"; -import { useRouter } from "next/navigation"; +import { LaneActionButton } from "../../components/lanes/LaneActionButton"; import { pauseAllWorkersAction, resumeAllWorkersAction, @@ -10,8 +9,13 @@ import { // Global pause/resume for transcription workers. Pausing stops handing out new // work AND gracefully stops in-flight parakeet jobs after the current segment // (cached for resume); other engines run their in-flight file to completion. -// Shared by the Workers page (live `paused` from polling, refetch on toggle) -// and the Active Jobs page (server-read `paused`, router.refresh on toggle). +// +// A THIN WRAPPER over the shared lane button. The Workers page (live `paused` +// from polling, refetch on toggle) and the Active Jobs page (server-read +// `paused`, router.refresh on toggle) call it in this prop shape and stay +// untouched; the dashboard and the widget reach the same button through +// LaneDeck. One implementation, so the hydration guard and the error handling +// cannot go missing from one of them. export function PauseTranscriptionsButton({ paused, disabled, @@ -21,35 +25,28 @@ export function PauseTranscriptionsButton({ disabled?: boolean; onChange?: () => void | Promise<void>; }) { - const [pending, startTransition] = useTransition(); - const router = useRouter(); - - function toggle() { - startTransition(async () => { - await (paused ? resumeAllWorkersAction() : pauseAllWorkersAction()); - if (onChange) await onChange(); - else router.refresh(); - }); - } - return paused ? ( - <button - type="button" - disabled={pending} - onClick={toggle} - className="px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50" - > - Resume Transcriptions - </button> + <LaneActionButton + label="Resume Transcriptions" + glyph="▶" + ariaLabel="Resume Transcriptions" + variant="active" + action={resumeAllWorkersAction} + onChange={onChange} + /> ) : ( - <button - type="button" - disabled={pending || disabled} - onClick={toggle} + <LaneActionButton + label="Pause Transcriptions" + glyph="❙❙" + ariaLabel="Pause Transcriptions" title="Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run" - className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50" - > - Pause Transcriptions - </button> + variant="idle" + // Only the pause takes the caller's disable (no workers to pause). A + // resume must always be reachable, or a paused pool with zero enabled + // workers could never be released. + disabled={disabled} + action={pauseAllWorkersAction} + onChange={onChange} + /> ); } diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -184,13 +184,10 @@ export function MonitorWidget({ sync={config.sync} channel={config.channel} confirmSyncAll={config.syncConfirm} - paused={workersPayload?.paused ?? false} - downloadsPaused={workersPayload?.downloadsPaused ?? false} - backfillEnabled={syncData?.backfill.enabled ?? false} - backfillAnyKind={syncData?.backfill.anyKind ?? false} + workersData={workersPayload} + syncData={syncData} onWorkersChange={refetchWorkers} onSynced={refetchSync} - onBackfillChange={refetchSync} /> ); case "lastSync": diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx @@ -1,78 +1,75 @@ "use client"; import { useState } from "react"; -import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton"; -import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton"; -import { PauseBackfillButton } from "../../jobs/components/PauseBackfillButton"; +import type { WidgetSyncPayload } from "../../api/widget/sync/route"; +import type { WorkersPayload } from "../../workers/components/WorkersView"; +import { LaneDeck } from "../../components/lanes/LaneDeck"; import { DrainAllButton } from "../../jobs/components/DrainAllButton"; import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton"; import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions"; import { syncAction } from "../../channels/[slug]/pipelineActions"; +import { useSectionFrame } from "./WidgetSection"; // Opt-in interactive controls for the monitor widget. Two independent // capabilities, each behind its own flag: -// - `controls` → Pause/Resume (transcriptions, downloads, backfill) + Drain + -// Retry (reused verbatim from the Workers / Active Jobs / dashboard pages), -// so a pinned widget can free the GPU, wind work down, hold a days-long -// backfill, or recover failures without opening the full app. +// - `controls` → the four LANE CARDS (transcription, downloads, digest, +// backfill — each with its gate and, where it has one, its sweep) plus Drain +// and Retry. The deck is the same component the dashboard band renders, so a +// pinned widget now has FULL PARITY: it could not start or stop a sweep at +// all before, and had no digest control whatsoever. // - `sync` → a channel-aware Sync button: pinned to one channel it syncs just // that channel (draining the per-channel stream); otherwise it sweeps all // channels. // `onWorkersChange` refetches the worker payload so the pause/resume label flips -// immediately; `onSynced` refetches the last-sync/scheduler readouts so they -// update as soon as a sweep is queued; `onBackfillChange` does the same for the -// backfill lane, whose state rides that same payload. +// immediately; `onSynced` refetches the sync payload, which is where the digest +// and backfill lane state rides as well as the last-sync/scheduler readouts. export function WidgetControls({ controls, sync, channel, confirmSyncAll, - paused, - downloadsPaused, - backfillEnabled, - backfillAnyKind, + workersData, + syncData, onWorkersChange, onSynced, - onBackfillChange, }: { controls: boolean; sync: boolean; channel?: string; confirmSyncAll: boolean; - paused: boolean; - downloadsPaused: boolean; - // settings.backfill.enabled. Persisted state, not in-memory runtime state, so - // the widget's 15s-floor sync poll is enough to keep the label honest between - // clicks — and `onBackfillChange` flips it immediately on one. - backfillEnabled: boolean; - // Whether any backfill FEATURE is registered. With none there is nothing to - // hold, and a dead button reads as a broken one — the same gate PipelineBand - // puts on the dashboard's copy. - backfillAnyKind: boolean; + // The two polled payloads the lanes read, passed whole rather than as scalars: + // LaneDeck is the one place that knows which field belongs to which lane, and + // threading eight booleans through here is how that knowledge got duplicated + // in the first place. Persisted state (the pauses, the sweep flags) rides the + // widget's 15s-floor poll, and a click refetches immediately. + workersData: WorkersPayload | null; + syncData: WidgetSyncPayload | null; onWorkersChange: () => void | Promise<void>; onSynced: () => void | Promise<void>; - onBackfillChange: () => void | Promise<void>; }) { + const { dense } = useSectionFrame(); return ( <section aria-label="Controls" - className="flex flex-wrap items-center gap-1.5" + className={ + dense + ? "flex flex-wrap items-center gap-1.5" + : "flex flex-col gap-2" + } > {controls && ( <> - <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} /> - <PauseDownloadsButton - paused={downloadsPaused} - onChange={onWorkersChange} + <LaneDeck + workers={workersData} + sync={syncData} + onWorkersChange={onWorkersChange} + onSynced={onSynced} /> - {backfillAnyKind && ( - <PauseBackfillButton - paused={!backfillEnabled} - onChange={onBackfillChange} - /> - )} - <DrainAllButton /> - <RetryAllFailedButton /> + {/* Not lanes: one-shot recovery actions over the whole queue. */} + <div className="flex flex-wrap items-center gap-1.5"> + <DrainAllButton /> + <RetryAllFailedButton /> + </div> </> )} {sync && (