Archilyzer · Source

archilyzer

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

commit a9f5961e3182e4cb65d78f6fe5152e37970402db
parent 30e2834879bf54b90723b5c84c78e1c9d59a0e82
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 13:59:12 -0400

Merge one-core/phase-3-s3a — one drawing per noun: channel row + job in flight

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/autoRunner.ts | 7+++++--
Acommon/views/actionableCounts.test.ts | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/actionableCounts.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/channelGroupSections.ts | 8++++++++
Acommon/views/channelRow.test.ts | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/channelRow.ts | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/jobRowView.ts | 6++++--
Mcommon/views/jobRows.test.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/jobRows.ts | 31+++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/components/ChannelGroupHeaderRow.tsx | 4++--
Meditor/app/channels/components/ChannelGroupLine.tsx | 6+++---
Aeditor/app/channels/components/ChannelsRack.tsx | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelsTable.tsx | 1014+++++++++++++++++++++++++++++--------------------------------------------------
Aeditor/app/channels/components/channelColumnPresets.ts | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/components/channelColumns.tsx | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/page.tsx | 119+++++++++++++++++++++++++++++--------------------------------------------------
Meditor/app/components/MediaLocationBadge.tsx | 20++++++--------------
Aeditor/app/components/actions/PrioritizeButton.tsx | 41+++++++++++++++++++++++++++++++++++++++++
Meditor/app/components/channelWork/ChannelWorkTable.tsx | 177+++++++++++++++++++++++++++++++------------------------------------------------
Deditor/app/components/dashboard/ChannelsTable.tsx | 192-------------------------------------------------------------------------------
Meditor/app/components/dashboard/DashboardCockpit.tsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Deditor/app/components/dashboard/types.ts | 30------------------------------
Meditor/app/jobs/components/JobProgressBars.tsx | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Aeditor/app/jobs/components/JobRow.tsx | 563+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/components/JobsTable.tsx | 243+++----------------------------------------------------------------------------
Meditor/app/jobs/components/LaneStrip.tsx | 13+++++++------
Meditor/app/jobs/components/RunningJobsList.tsx | 131++++---------------------------------------------------------------------------
Meditor/app/lib/actionable/loadActionable.ts | 170+++++++++++++++++++++----------------------------------------------------------
Aeditor/app/lib/formatElapsed.ts | 14++++++++++++++
Meditor/app/operations/components/InFlightList.tsx | 94++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Meditor/app/operations/components/dispatch.ts | 13++-----------
Meditor/app/page.tsx | 89++++++++++++++++++++++++++++++++-----------------------------------------------
Meditor/app/widget/components/MonitorWidget.tsx | 241++++---------------------------------------------------------------------------
Mplans/one-core-phase-3.md | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
35 files changed, 2964 insertions(+), 1927 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -184,6 +184,9 @@ export type AutoRunnerInFlight = { // note renders the note instead of a video link. Absent for every ordinary // unit, which is how the surfaces stay unchanged. note?: string; + // The registry job running this unit, when it is one (a download unit is a + // child job on its platform queue); absent for a task on the runner's job. + jobId?: string; }; // Why the runner is up but dispatching nothing. Every one of these was already @@ -1890,7 +1893,7 @@ async function runLoop( targets: picked.scan.targets, tracker, onLog, - onChildJob: (jid) => childJobIds.set(pick.videoId, jid), + onChildJob: (jid) => { childJobIds.set(pick.videoId, jid); const u = live.inFlight.get(pick.videoId); if (u) u.jobId = jid; }, }); return; } @@ -1905,7 +1908,7 @@ async function runLoop( onLog, signal: runSignal, drainSignal: ctx.drainSignal, - onChildJob: (jid) => childJobIds.set(pick.videoId, jid), + onChildJob: (jid) => { childJobIds.set(pick.videoId, jid); const u = live.inFlight.get(pick.videoId); if (u) u.jobId = jid; }, }); } catch (err) { result = { outcome: "failed" }; diff --git a/common/views/actionableCounts.test.ts b/common/views/actionableCounts.test.ts @@ -0,0 +1,116 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ChannelSnapshot } from "../controller/channelSnapshot"; +import type { OperationSnapshotEntry } from "../lib/operations"; +import { normalizeBuckets } from "./pipeline/stageStatus"; +import { + cleanExtraFormatsBytesOf, + cleanExtraFormatsCountOf, + cleanTranscribedBytesOf, + cleanTranscribedCountOf, + digestReachableCountOf, + digestWarningsCountOf, + incompleteTranscriptCountOf, + metadataScanCountOf, + missingNeverFetchedCountOf, + shortAudioCountOf, + undownloadedCountOf, + untranscribedCountOf, +} from "./actionableCounts"; + +function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot { + return { + generatedAt: "2026-08-01T00:00:00.000Z", + totals: { videos: 10, transcribed: 4, downloaded: 6 }, + buckets: normalizeBuckets(undefined), + undownloadedIds: [], + ...patch, + }; +} + +const ALL = [ + undownloadedCountOf, + missingNeverFetchedCountOf, + metadataScanCountOf, + untranscribedCountOf, + incompleteTranscriptCountOf, + shortAudioCountOf, + cleanTranscribedCountOf, + cleanExtraFormatsCountOf, + digestReachableCountOf, + digestWarningsCountOf, + cleanTranscribedBytesOf, + cleanExtraFormatsBytesOf, +]; + +test("a channel with no report counts zero everywhere", () => { + for (const f of ALL) { + assert.equal(f(null), 0, f.name); + assert.equal(f(undefined), 0, f.name); + } +}); + +test("a snapshot written before the optional buckets existed counts zero", () => { + // The on-disk shape of an old report: no optional buckets, no cleanupBytes, + // no metadataScan, no missingNeverFetched, no backfill entries. + const old = { + generatedAt: "2026-01-01T00:00:00.000Z", + totals: { videos: 3, transcribed: 0, downloaded: 0 }, + buckets: { downloadedNoTranscript: [] }, + undownloadedIds: [], + } as unknown as ChannelSnapshot; + for (const f of ALL) assert.equal(f(old), 0, f.name); +}); + +test("undownloaded and untranscribed drop the availability exclusions", () => { + const snap = snapshotOf({ + undownloadedIds: ["a", "b", "c", "d"], + buckets: { + ...normalizeBuckets(undefined), + downloadedNoTranscript: ["t1", "t2", "b"], + }, + excludedFromDownload: { membersOnly: ["a"], deleted: ["b"], private: [] }, + }); + assert.equal(undownloadedCountOf(snap), 2); + assert.equal(untranscribedCountOf(snap), 2); + // With nothing excluded the lengths are the counts. + const plain = snapshotOf({ undownloadedIds: ["a", "b"] }); + assert.equal(undownloadedCountOf(plain), 2); +}); + +test("the bucket counts are the bucket lengths; the bytes are cleanupBytes", () => { + const snap = snapshotOf({ + buckets: { + ...normalizeBuckets(undefined), + incompleteTranscript: ["i"], + shortAudio: ["s1", "s2"], + transcribedWithAudio: ["w1", "w2", "w3"], + multipleAudioFormats: ["m"], + digestWarnings: ["d1", "d2"], + }, + cleanupBytes: { transcribedWithAudio: 300, multipleAudioFormats: 40 }, + metadataScan: { unscanned: 7 } as ChannelSnapshot["metadataScan"], + missingNeverFetched: [{}, {}] as ChannelSnapshot["missingNeverFetched"], + }); + assert.equal(incompleteTranscriptCountOf(snap), 1); + assert.equal(shortAudioCountOf(snap), 2); + assert.equal(cleanTranscribedCountOf(snap), 3); + assert.equal(cleanExtraFormatsCountOf(snap), 1); + assert.equal(digestWarningsCountOf(snap), 2); + assert.equal(cleanTranscribedBytesOf(snap), 300); + assert.equal(cleanExtraFormatsBytesOf(snap), 40); + assert.equal(metadataScanCountOf(snap), 7); + assert.equal(missingNeverFetchedCountOf(snap), 2); +}); + +test("digest reachable is the digest registry entry's reachable", () => { + const entry = { + ids: [], + missing: 5, + stale: 2, + missingInput: 0, + partial: 1, + } as unknown as OperationSnapshotEntry; + const snap = snapshotOf({ backfill: { digest: entry } }); + assert.equal(digestReachableCountOf(snap), 8); +}); diff --git a/common/views/actionableCounts.ts b/common/views/actionableCounts.ts @@ -0,0 +1,108 @@ +import { + digestWorkOf, + excludedDownloadIdSet, + type ChannelSnapshot, +} from "../controller/channelSnapshot"; + +// THE PER-CHANNEL WORK COUNTS, as pure functions over one channel's snapshot. +// +// These were the `actionable*Count` helpers in the editor's +// `lib/actionable/loadActionable.ts`, each taking an `ActionableRow` and reading +// only `row.snapshot`. They move here so the channel-row builder +// (`./channelRow.ts`) and the actionable census count the SAME way — the +// dashboard's Videos / Digest to do cells and the widget's "Needs work" strip +// were each a hand copy of the census before. `loadActionable.ts` keeps its +// `actionable*` names as one-line wrappers over these, so no caller moved. +// +// Every function takes `null`/`undefined` (a channel that has never been +// reported) and answers 0, and every optional bucket defaults to 0 for a +// snapshot written before the bucket existed. + +type Snap = ChannelSnapshot | null | undefined; + +// Counts that drive the actionable lists exclude IDs that the availability +// check has flagged as deleted / members-only / private — those videos +// can't be acted on, so they shouldn't inflate "needs attention" totals. +// `undownloadedIds` is already filtered at snapshot generation time, but we +// apply the filter again so a stale snapshot can't surface excluded IDs. +function countActionable( + snapshot: Snap, + ids: readonly string[] | undefined, +): number { + if (!snapshot || !ids) return 0; + const excluded = excludedDownloadIdSet(snapshot); + if (excluded.size === 0) return ids.length; + let n = 0; + for (const id of ids) if (!excluded.has(id)) n++; + return n; +} + +export function undownloadedCountOf(snapshot: Snap): number { + return countActionable(snapshot, snapshot?.undownloadedIds); +} + +// Videos the roster says we were told about, never downloaded, and that have +// since left the listing. Deliberately NOT run through countActionable: the +// availability exclusions are keyed on videos we have on disk, and these have no +// dir at all. +export function missingNeverFetchedCountOf(snapshot: Snap): number { + return snapshot?.missingNeverFetched?.length ?? 0; +} + +// Listed videos the metadata scan has neither read nor recently failed on — +// the scan operation's backlog. Deliberately NOT run through countActionable: +// these videos have no directory, so the availability exclusions (which are +// keyed on what is on disk) cannot say anything about them. +export function metadataScanCountOf(snapshot: Snap): number { + return snapshot?.metadataScan?.unscanned ?? 0; +} + +export function untranscribedCountOf(snapshot: Snap): number { + return countActionable(snapshot, snapshot?.buckets.downloadedNoTranscript); +} + +// Transcribed videos whose transcript is badly truncated (the audio download +// stopped early). +export function incompleteTranscriptCountOf(snapshot: Snap): number { + return snapshot?.buckets.incompleteTranscript?.length ?? 0; +} + +// Downloads the duration guard flagged as truncated at the source (short audio +// kept on disk, not transcribed). +export function shortAudioCountOf(snapshot: Snap): number { + return snapshot?.buckets.shortAudio?.length ?? 0; +} + +// Cleanup buckets are filtered by "do not clean" at snapshot-generation time, +// so the length is the actionable count directly. +export function cleanTranscribedCountOf(snapshot: Snap): number { + return snapshot?.buckets.transcribedWithAudio?.length ?? 0; +} + +export function cleanExtraFormatsCountOf(snapshot: Snap): number { + return snapshot?.buckets.multipleAudioFormats?.length ?? 0; +} + +// The digest band's `reachable`, per channel — read through digestWorkOf, the +// registry's classification the runner uses (no transcript = blocked, stale +// cues = deferred; neither is in it). buildBands.ts folds the same call. +export function digestReachableCountOf(snapshot: Snap): number { + return digestWorkOf(snapshot).reachable; +} + +// "The model produced something a human should look at", which includes the +// total failures that write no section and so are invisible to any count of +// files. +export function digestWarningsCountOf(snapshot: Snap): number { + return snapshot?.buckets.digestWarnings?.length ?? 0; +} + +// Estimated bytes each cleanup would reclaim (0 for snapshots written before +// cleanupBytes existed). +export function cleanTranscribedBytesOf(snapshot: Snap): number { + return snapshot?.cleanupBytes?.transcribedWithAudio ?? 0; +} + +export function cleanExtraFormatsBytesOf(snapshot: Snap): number { + return snapshot?.cleanupBytes?.multipleAudioFormats ?? 0; +} diff --git a/common/views/channelGroupSections.ts b/common/views/channelGroupSections.ts @@ -63,6 +63,14 @@ export type StationWork = { laneOff?: boolean; }; +// What the /channels client reads of a section: everything but the stats, +// which it only counts and joins by slug. The page projects to this before the +// sections cross to the browser, so no ChannelConfig (url, dataDir, yt-dlp +// args, cookie settings) rides along with them. +export type ChannelGroupSectionView = Omit<ChannelGroupSection, "channels"> & { + channels: Array<{ slug: string }>; +}; + export type ChannelGroupSection = { group: ChannelGroup; // Membership order asc (absent sorts last), then slug. diff --git a/common/views/channelRow.test.ts b/common/views/channelRow.test.ts @@ -0,0 +1,172 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ChannelSnapshot } from "../controller/channelSnapshot"; +import type { ChannelConfig } from "../lib/channelConfig"; +import type { StorageLocation } from "../lib/storageLocations"; +import { normalizeBuckets } from "./pipeline/stageStatus"; +import { + buildChannelRowView, + channelVolumeOf, + neutralChannelPriority, + reportStateOf, + type ChannelRowInput, +} from "./channelRow"; +import { + digestReachableCountOf, + undownloadedCountOf, + untranscribedCountOf, +} from "./actionableCounts"; + +function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot { + return { + generatedAt: "2026-08-01T00:00:00.000Z", + totals: { videos: 10, transcribed: 4, downloaded: 6 }, + buckets: normalizeBuckets(undefined), + undownloadedIds: [], + ...patch, + }; +} + +function input(patch: Partial<ChannelRowInput> = {}): ChannelRowInput { + const config: ChannelConfig = { + handling: "transcribe", + url: "https://example.test/alpha", + name: "Alpha", + lastSyncedAt: "2026-07-01T00:00:00.000Z", + cookiesFile: "/secret/cookies.txt", + } as ChannelConfig; + return { + slug: "alpha", + config, + snapshot: snapshotOf(), + playlistCount: 12, + bands: [], + priority: neutralChannelPriority(), + media: null, + volume: { id: "internal", label: "Internal" }, + ...patch, + }; +} + +test("a channel with no report: missing, no size, zero work", () => { + const row = buildChannelRowView(input({ snapshot: null })); + assert.deepEqual(row.report, { generatedAt: null, state: "missing" }); + assert.equal(row.mediaBytes, null); + assert.deepEqual(row.work, { + videos: 0, + undownloaded: 0, + untranscribed: 0, + digestReachable: 0, + }); + assert.equal(row.downloadCount, 0); + assert.equal(row.transcriptCount, 0); +}); + +test("stale when the last sync is newer than the report, else current", () => { + const stale = buildChannelRowView( + input({ + config: { handling: "transcribe", lastSyncedAt: "2026-09-01T00:00:00Z" }, + }), + ); + assert.equal(stale.report.state, "stale"); + const current = buildChannelRowView(input()); + assert.equal(current.report.state, "current"); + assert.equal(current.report.generatedAt, "2026-08-01T00:00:00.000Z"); + // Never synced: nothing to be stale against. + assert.equal( + reportStateOf({ config: {}, snapshot: { generatedAt: "2026-01-01" } }), + "current", + ); +}); + +test("work equals the census count helpers over the same snapshot", () => { + const snap = snapshotOf({ + undownloadedIds: ["a", "b", "c"], + buckets: { ...normalizeBuckets(undefined), downloadedNoTranscript: ["x", "a"] }, + excludedFromDownload: { membersOnly: ["a"], deleted: [], private: [] }, + totalMediaBytes: 1234, + }); + const row = buildChannelRowView(input({ snapshot: snap })); + assert.deepEqual(row.work, { + videos: 10, + undownloaded: undownloadedCountOf(snap), + untranscribed: untranscribedCountOf(snap), + digestReachable: digestReachableCountOf(snap), + }); + assert.equal(row.work.undownloaded, 2); + assert.equal(row.mediaBytes, 1234); + assert.equal(row.downloadCount, 6); + assert.equal(row.transcriptCount, 4); +}); + +test("the view carries no config, only the fields a cell draws", () => { + const row = buildChannelRowView(input()); + assert.equal("config" in row, false); + assert.equal(JSON.stringify(row).includes("cookies"), false); + assert.equal(row.name, "Alpha"); + assert.equal(row.hasUrl, true); + assert.equal(row.excludeFromBuild, false); + assert.equal(row.lastSyncedAt, "2026-07-01T00:00:00.000Z"); + assert.equal(row.playlistCount, 12); + const bare = buildChannelRowView(input({ config: { handling: "youtube" } })); + assert.equal(bare.name, ""); + assert.equal(bare.hasUrl, false); + assert.equal(bare.lastSyncedAt, null); +}); + +test("media: in-place is dropped; the label is the volume's unless overridden", () => { + const inPlace = buildChannelRowView( + input({ media: { status: "in-place", target: "/x", detail: "" } }), + ); + assert.equal(inPlace.media, null); + const moved = buildChannelRowView( + input({ + media: { status: "ok", target: "/mnt/big/alpha/data", detail: "d" }, + volume: { id: "big", label: "Big disk" }, + }), + ); + assert.equal(moved.media?.locationLabel, "Big disk"); + assert.equal(moved.volumeId, "big"); + const unnamed = buildChannelRowView( + input({ + media: { status: "unreachable", target: "/mnt/x", detail: "d" }, + volume: { id: "", label: "Elsewhere" }, + }), + ); + assert.equal(unnamed.media?.locationLabel, undefined); + const overridden = buildChannelRowView( + input({ + media: { status: "ok", target: "/mnt/big/alpha/data", detail: "d" }, + volume: { id: "big", label: "Big disk" }, + mediaLocationLabel: undefined, + }), + ); + assert.equal(overridden.media?.locationLabel, undefined); +}); + +test("channelVolumeOf: no dataDir is internal, a named root wins, else unnamed", () => { + const locations = [ + { id: "big", label: "Big disk", root: "/mnt/big" }, + { id: "bigger", label: "", root: "/mnt/big/inner" }, + ] as StorageLocation[]; + assert.deepEqual(channelVolumeOf(undefined, locations), { + id: "internal", + label: "Internal", + }); + assert.deepEqual(channelVolumeOf(" ", locations), { + id: "internal", + label: "Internal", + }); + assert.deepEqual(channelVolumeOf("/mnt/big/alpha/data", locations), { + id: "big", + label: "Big disk", + }); + assert.deepEqual(channelVolumeOf("/mnt/big/inner/a/data", locations), { + id: "bigger", + label: "bigger", + }); + assert.deepEqual(channelVolumeOf("/elsewhere/a/data", locations), { + id: "", + label: "Elsewhere", + }); +}); diff --git a/common/views/channelRow.ts b/common/views/channelRow.ts @@ -0,0 +1,218 @@ +import type { ChannelConfig } from "../lib/channelConfig"; +import type { ChannelSnapshot } from "../controller/channelSnapshot"; +import type { ChannelMediaLocation } from "../lib/channelMedia"; +import type { + PriorityOperation, + StoredChannelTier, +} from "../lib/channelPriority"; +import { + INTERNAL_LOCATION_ID, + locationOfDataDir, + type StorageLocation, +} from "../lib/storageLocations"; +import type { OperationBand } from "./pipeline/band"; +import { + digestReachableCountOf, + undownloadedCountOf, + untranscribedCountOf, +} from "./actionableCounts"; + +// ONE CHANNEL ROW, whichever table draws it. +// +// Three tables used to build their row inline — the /channels rack +// (`channels/page.tsx`), the dashboard's channels table (`app/page.tsx`) and the +// operation pages' work tables (`ChannelWorkTable`) — each reading the config +// and the snapshot its own way. This is the one projection: the server builds a +// `ChannelRowView` per channel and the shared client table +// (`editor/app/channels/components/ChannelsTable.tsx`) draws whichever columns +// its caller names. +// +// CLIENT-SAFE BY CONSTRUCTION. The view never carries `config`: a channel +// config holds cookie paths, yt-dlp args and the download filter, none of which +// a table draws and all of which would otherwise be serialized into every +// page's RSC payload. The builder reads the config; the view carries the six +// fields of it that a cell renders. + +// One row's share of the channel priority document, resolved on the server. +// The row never reads the model itself: `focused` and `heldReason` are facts +// about the corpus-wide focus selector, which no single row can answer. +export type ChannelRowPriority = { + tier: StoredChannelTier; + rank: number | null; + overrides: Partial<Record<PriorityOperation, StoredChannelTier>>; + focused: boolean; + heldReason: string | null; + // Why the machine paused it, or null. See autoPauseReasonOf. + autoPausedReason: string | null; +}; + +// The priority a table that draws no Tier column hands the builder: what a +// channel with no entry in the priority document resolves to. +export function neutralChannelPriority(): ChannelRowPriority { + return { + tier: "normal", + rank: null, + overrides: {}, + focused: false, + heldReason: null, + autoPausedReason: null, + }; +} + +// Where this channel's media physically is, as the badge draws it. Narrower +// than ChannelMediaLocation: a row only needs what it draws. +export type ChannelRowMedia = Pick< + ChannelMediaLocation, + "status" | "target" | "detail" +> & { + // The name of the storage location this channel's media is on, projected by + // the server that built the row. A fourth string is still cheaper than + // shipping the location list to every table. + locationLabel?: string; +}; + +export type ReportState = "current" | "stale" | "missing"; + +export type ChannelRowView = { + slug: string; + // `config.name`, "" when unset. + name: string; + handling: ChannelConfig["handling"]; + // Whether the channel has a URL — Sync and Download need one. + hasUrl: boolean; + excludeFromBuild: boolean; + lastSyncedAt: string | null; + // The playlist file's line count; null when there is no playlist file. + playlistCount: number | null; + downloadCount: number; + transcriptCount: number; + // The per-channel work figures, off the SAME helpers the actionable census + // counts with (./actionableCounts.ts), so a dashboard cell and the widget's + // "Needs work" strip cannot disagree about a channel. + work: { + videos: number; + undownloaded: number; + untranscribed: number; + digestReachable: number; + }; + // The pipeline bands, in column order, projected from the same snapshot the + // counts come from. `[]` for a table that draws no pipeline column. + pipelines: OperationBand[]; + priority: ChannelRowPriority; + // How old this channel's report is. Every count and every band on this row is + // projected from that report, so its age is the caveat on all of them. + report: { generatedAt: string | null; state: ReportState }; + // Null for an in-place channel — the overwhelming majority — so the badge + // marks only the rows whose other numbers may not be trustworthy. + media: ChannelRowMedia | null; + // WHICH VOLUME, as an id a filter can name: a location id, "internal" for the + // corpus volume, or "" for a dataDir under a root nobody named. + volumeId: string; + volumeLabel: string; + // `snapshot.totalMediaBytes`. NULL, not 0, for a report written before the + // field existed: a 400 GB channel that has not been measured must not sort as + // the smallest thing on the disk. + mediaBytes: number | null; +}; + +export type ChannelRowInput = { + slug: string; + config: ChannelConfig; + snapshot: ChannelSnapshot | null; + playlistCount: number | null; + bands: OperationBand[]; + priority: ChannelRowPriority; + // The server's inspectChannelMedia result, or null when it was not asked. + // An in-place result is dropped here, once, for every table. + media: Pick<ChannelMediaLocation, "status" | "target" | "detail"> | null; + volume: ChannelVolumeId; + // The badge's location name. Defaults to the volume's label (undefined for + // the unnamed-root volume, which renders exactly what the badge rendered + // before locations existed). The dashboard names it from the media target, + // as it always has, and passes that here. + mediaLocationLabel?: string; +}; + +// How old a channel's report is, in the three states /channels draws: +// "missing" (never generated), "stale" (older than the last sync — so every +// count read off it may be wrong) and "current". +export function reportStateOf(brief: { + config: Pick<ChannelConfig, "lastSyncedAt">; + snapshot: Pick<ChannelSnapshot, "generatedAt"> | null; +}): ReportState { + if (!brief.snapshot) return "missing"; + const synced = brief.config.lastSyncedAt; + if (!synced) return "current"; + return new Date(synced).getTime() > + new Date(brief.snapshot.generatedAt).getTime() + ? "stale" + : "current"; +} + +export type ChannelVolumeId = { id: string; label: string }; + +// WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id. +// +// Same derivation the badge uses (`config.dataDir` under a location's root, +// longest match wins) with one addition: no `dataDir` at all means the corpus +// volume, which is the row the operator is trying to empty and therefore the +// one they most need to filter to. A `dataDir` under a root NOBODY named is +// neither — it gets "" and falls out of every volume filter, which is the +// honest answer and the nudge to name that root on /storage. +export function channelVolumeOf( + dataDir: string | undefined, + locations: StorageLocation[], +): ChannelVolumeId { + const trimmed = dataDir?.trim(); + if (!trimmed) { + return { id: INTERNAL_LOCATION_ID, label: "Internal" }; + } + const found = locationOfDataDir(trimmed, locations); + return found + ? { id: found.id, label: found.label || found.id } + : { id: "", label: "Elsewhere" }; +} + +export function buildChannelRowView(i: ChannelRowInput): ChannelRowView { + const snap = i.snapshot; + return { + slug: i.slug, + name: i.config.name ?? "", + handling: i.config.handling, + hasUrl: Boolean(i.config.url), + excludeFromBuild: i.config.excludeFromBuild === true, + lastSyncedAt: i.config.lastSyncedAt ?? null, + playlistCount: i.playlistCount, + downloadCount: snap?.totals.downloaded ?? 0, + transcriptCount: snap?.totals.transcribed ?? 0, + work: { + videos: snap?.totals.videos ?? 0, + undownloaded: undownloadedCountOf(snap), + untranscribed: untranscribedCountOf(snap), + digestReachable: digestReachableCountOf(snap), + }, + pipelines: i.bands, + priority: i.priority, + report: { + generatedAt: snap?.generatedAt ?? null, + state: reportStateOf({ config: i.config, snapshot: snap }), + }, + media: + i.media && i.media.status !== "in-place" + ? { + status: i.media.status, + target: i.media.target, + detail: i.media.detail, + locationLabel: + "mediaLocationLabel" in i + ? i.mediaLocationLabel + : i.volume.id === "" + ? undefined + : i.volume.label, + } + : null, + volumeId: i.volume.id, + volumeLabel: i.volume.label, + mediaBytes: snap?.totalMediaBytes ?? null, + }; +} diff --git a/common/views/jobRowView.ts b/common/views/jobRowView.ts @@ -94,6 +94,8 @@ export type JobRowView = { // jobs/jobDetail.ts from the job's own replay spec; absent for every kind // that has nothing to add, so no existing row changes. detail?: string; - // Which adapter built it. Never rendered; tests and the merge read it. - source: "registry" | "archive" | "slot"; + // Which adapter built it. Never rendered; tests and the merge read it — + // except "runner": an auto-queue lane's in-flight unit (fromInFlight), which + // links to a job page only when `inRegistry` says it has one. + source: "registry" | "archive" | "slot" | "runner"; }; diff --git a/common/views/jobRows.test.ts b/common/views/jobRows.test.ts @@ -5,6 +5,7 @@ import type { JobListEntry } from "../jobs/listJobs"; import { createScheduler } from "../jobs/scheduler"; import { fromEntry, + fromInFlight, fromRecord, fromSlot, isLive, @@ -340,3 +341,61 @@ test("fromSlot builds the phantom, and a stuck row sorts above a running one", ( ]); assert.equal(ordered[0].id, phantom.id); }); + +test("fromInFlight: a download unit carries its registry job id", () => { + const row = fromInFlight( + { + videoId: "v1", + leafId: "leaf-a", + channelSlug: "alpha", + startedAt: 1_000, + jobId: "01JOBDOWNLOAD", + }, + { kind: "download", runnerJobId: "01RUNNER", detail: "rule 1 · alpha" }, + ); + assert.equal(row.id, "01JOBDOWNLOAD"); + assert.equal(row.inRegistry, true); + assert.equal(row.source, "runner"); + assert.equal(row.status, "running"); + assert.equal(row.background, true); + assert.equal(row.kind, "download"); + assert.equal(row.channelSlug, "alpha"); + assert.equal(row.videoId, "v1"); + assert.equal(row.startedAt, 1_000); + assert.equal(row.detail, "rule 1 · alpha"); +}); + +test("fromInFlight: a task unit gets a synthetic id and no job page", () => { + const unit = { + videoId: "v2", + leafId: "leaf-b", + channelSlug: "beta", + startedAt: 2_000, + }; + const withRunner = fromInFlight(unit, { + kind: "transcription", + runnerJobId: "01RUNNER", + }); + assert.equal(withRunner.id, "01RUNNER:v2"); + assert.equal(withRunner.inRegistry, false); + // No runner job known (a lane between runs): the lane kind keys it. + const bare = fromInFlight(unit, { kind: "transcription", runnerJobId: null }); + assert.equal(bare.id, "transcription:v2"); + assert.equal(bare.detail, undefined); +}); + +test("fromInFlight: a note replaces the detail and drops the synthetic video id", () => { + const row = fromInFlight( + { + videoId: "metadata-scan alpha", + leafId: "metadata-scan", + channelSlug: "alpha", + startedAt: 3_000, + note: "metadata scan · 12 titles", + }, + { kind: "download", detail: "rule 1 · alpha" }, + ); + assert.equal(row.detail, "metadata scan · 12 titles"); + assert.equal(row.videoId, undefined); + assert.equal(row.id, "download:metadata-scan alpha"); +}); diff --git a/common/views/jobRows.ts b/common/views/jobRows.ts @@ -5,6 +5,7 @@ import type { JobListEntry } from "../jobs/listJobs"; import { jobSpecDetail } from "../jobs/jobDetail"; import type { ChannelStat } from "../controller/channels"; import type { QueueView } from "../jobs/scheduler"; +import type { AutoRunnerInFlight } from "../controller/autoRunner"; import type { JobRowView, StuckReason } from "./jobRowView"; // THE ADAPTERS, THE ORDER AND THE MERGE — pure, and directive-free by @@ -332,3 +333,33 @@ export function isLive(r: JobRowView): boolean { Boolean(r.stuck) ); } + +// AN AUTO-QUEUE LANE'S IN-FLIGHT UNIT, as a job row — so the operations board +// draws what the runner is doing with the same row every other surface draws +// a job with. A download unit is a real registry job (its id rides on the unit +// as `jobId`, set when the runner's child job starts); a transcription, digest +// or backfill unit is a task on the runner's own job and has no page of its +// own, so its id is synthetic and `inRegistry` is false. +// +// `detail` ("rule 3 · newest first from alpha") is the caller's: the leaf +// sentence is an editor helper over the lane's policy, not a pure fact of the +// unit. A unit with a `note` (the download lane's channel-scoped metadata +// scan, whose `videoId` names no directory) says the note instead, and carries +// no videoId, so nothing links to a video that is not there. +export function fromInFlight( + unit: AutoRunnerInFlight, + ctx: { kind: string; runnerJobId?: string | null; detail?: string }, +): JobRowView { + return { + id: unit.jobId ?? `${ctx.runnerJobId ?? ctx.kind}:${unit.videoId}`, + kind: ctx.kind, + status: "running", + channelSlug: unit.channelSlug, + videoId: unit.note ? undefined : unit.videoId, + startedAt: unit.startedAt, + background: true, + inRegistry: Boolean(unit.jobId), + detail: unit.note ?? ctx.detail, + source: "runner", + }; +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Every channel table and every job-in-flight line is now drawn one way.** The /channels rack, the dashboard's Channels table and the work tables on the operation pages and /cleanup are one table with a column set per page, over one channel row built on the server (which no longer ships a channel's config to the browser); the dashboard's "Needs work" seed is computed by the same code the widget endpoint serves. On the jobs side, /jobs rows, the "Active jobs" cards on channel/video/operation pages, the monitor widget's Active jobs strip and the operations board's "In flight" list are one job row in three sizes, with one rule for which buttons (Retry / Reorder / Drain / Cancel / Force-release) a job gets. **What you might notice:** a work table's report column reads "stale"/"missing" like the rack's instead of a date; the dashboard's Sync button is the rack's; a lane line on /jobs offers Force-release while its runner is running; widget job lines show who asked for the job; an in-flight download on the operations board links to its job page. Nothing a count says moved. - **`settings.json` has one schema and one writer, and its key table is generated.** Every key, its default, its clamp and its documentation is now one zod schema (`common/lib/settingsSchema.ts`); `getSettings`/`writeSettings` both parse through it, and every settings form saves through one helper (`editor/app/settings/saveSettings.ts`) that merges only what the form changed. **`SETTINGS.md`** (new, repo root) lists every key with its default and what it does, and `settings.json.example` is now the full default object — both generated by `common/bin/settings-example.ts` and checked by a test, so neither can drift. Nothing an operator has configured reads differently. **Fixed:** adding or editing a storage location on `/storage` no longer erases the record of which location the saved-video store is on (`storage.savedVideosLocationId`). - **Every live panel now polls one endpoint, `/api/view/<name>`, and the eight old addresses still answer.** The change token, the job head, workers, the operations board, the sync console and the widget's three strips were eight separate API routes that each did the same thing; they are one route serving eight named views (`pulse`, `activeJobs`, `workers`, `autoQueueStatus`, `schedulerStatus`, `widgetSync`, `widgetActionable`, `cleanable`), and the editor's own pages poll it there. `/api/pulse`, `/api/jobs/active`, `/api/workers`, `/api/auto-queue/status`, `/api/scheduler/status` and `/api/widget/{sync,actionable,cleanable}` are kept as **rewrites**, not redirects — same method, status, body and query string (`?rev=` included) — so a monitor widget pinned in a browser, or any script polling the old path, keeps working untouched. `/api/widget/presets` is unchanged. An unknown view name is a 404. **One behaviour change you might notice:** the operations board (every 3 s) and the sync console (every 5 s) now send their next poll only after the previous one answers, and abandon a poll that takes longer than 10–15 s — so a slow editor no longer piles requests up behind itself, and a hung request no longer stops the page updating. - **A lane's pause is one key on the lane, and the four old pause fields are gone from `settings.json`.** Holding a lane has been `autoQueue.<lane>.held` since the runner work landed; until now the file also still carried the four flags that used to mean it — `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the backwards `backfill.enabled` (where *enabled* meant *not held*) — which were read only when a lane had no `held` yet, to carry an older file's pause across. Every lane now carries its own key, so those four are **deleted**: nothing reads them, no form writes them, and the next settings save drops them from the file. A settings.json that still spells one of them holds nothing with it, so a hand-edited file (or a very old backup restored over a newer one) can no longer resurrect a pause you had lifted, or lift one you had set. "Run the backfill lane" on the diarization page and the Hold/Pause buttons write the one key, as they already did. **UPGRADING: boot once on the release that writes `held` before taking this one.** That release is the one that moved the gate onto the lane and carried the old fields across on read; a single boot of it (any settings save, or just starting the editor and pausing/resuming anything) puts `autoQueue.<lane>.held` in your settings.json, after which **nothing you can see changes here** — the same buttons, the same labels, the same pauses. An install that jumps straight from an older release to this one has no `held` keys at all and **loses its pauses**: transcription, downloads and digests come up running, and the backfill lane comes up held. Re-set them from the dashboard, or add the keys by hand before starting. diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections"; +import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections"; import { ChannelGroupLine } from "./ChannelGroupLine"; // A group's section header: one full-colspan row above its channels' rows, so @@ -19,7 +19,7 @@ export function ChannelGroupHeaderRow({ siteId, colSpan, }: { - section: ChannelGroupSection; + section: ChannelGroupSectionView; siteId: string; // The table's full width, owned by ChannelsTable: it is the one that knows // how many pipeline columns the settings switched on. diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { Fragment, useEffect, useState } from "react"; import type { - ChannelGroupSection, + ChannelGroupSectionView, StationId, StationWork, } from "yt-dlp-transcript-common/views/channelGroupSections"; @@ -28,7 +28,7 @@ type Station = { // Fixed for four stations; the speakers station reads its label off the // section (`speakersLabel`), because what that lane is called depends on which // operations are switched on and only the server can ask the registry. - label: string | ((section: ChannelGroupSection) => string); + label: string | ((section: ChannelGroupSectionView) => string); // What replaces the button once the group's work is provably finished. A dead // button reading "Download 0" invites a click that would do nothing. done: string; @@ -117,7 +117,7 @@ export function ChannelGroupLine({ section, siteId, }: { - section: ChannelGroupSection; + section: ChannelGroupSectionView; siteId: string; }) { const [status, setStatus] = useState<Status>({ kind: "idle" }); diff --git a/editor/app/channels/components/ChannelsRack.tsx b/editor/app/channels/components/ChannelsRack.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; +import { BandLegend } from "../../components/pipelines/StateBand"; +import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections"; +import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar"; +import { + ChannelSelectionDeck, + type BulkDestination, +} from "./ChannelSelectionDeck"; +import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection"; +// A VALUE import from views/, and it is safe: every import in that module is a +// type import, so what reaches the client bundle is two string constants. The +// point is that "internal" and "location" have ONE spelling across /storage +// (which writes the link) and /channels (which reads it). +import { INTERNAL_ROW_ID } from "yt-dlp-transcript-common/views/storage"; +import { ChannelsTable } from "./ChannelsTable"; +import { RACK_COLUMNS, type PipelineColumn } from "./channelColumns"; + +// THE /channels RACK: the page's chrome around the shared channel table — the +// focus line, the volume bar, the instrument bar (grouping, band legend), the +// one scroll region the table pins inside, and the selection deck below it. +// The rows, cells and sort are ./ChannelsTable.tsx, which the dashboard and the +// operation pages' work sections draw too; this component turns every feature +// of it on. + +export function ChannelsRack({ + channels, + columns, + sections = null, + siteId, + mediaDestinations = [], + defaultLocationId = "", + sites = [], + focusLabel = null, + volumes = [], + locationFilter = null, + initialSort = null, +}: { + channels: ChannelRowView[]; + // Which pipelines to draw, in group order, resolved on the server from the + // live settings. A switched-off feature is simply absent — which is the + // honest rendering: an empty column because nobody enabled attribution is not + // the same as an attribution lane with nothing left to do. + columns: PipelineColumn[]; + // Null under "all sites" (or with no sites configured): groups only partition + // a SINGLE site's channels, so there is no one grouping to render across the + // pool. That path is today's flat table, unchanged. + sections?: ChannelGroupSectionView[] | null; + siteId?: string; + // The configured storage locations, resolved on the server. The selection + // deck's destination list; empty when none are configured, which leaves the + // deck's free-text root box as the whole control. + mediaDestinations?: BulkDestination[]; + // `settings.storage.defaultLocationId` — which destination the deck opens on. + defaultLocationId?: string; + // Every configured site, for the "Focus site" control. Not the same list as + // the page's scope selector: a focus is corpus-wide, so it can name a site + // whose channels are not the ones on screen. + sites?: FocusSite[]; + // What the focus selector currently names, resolved on the server, or null. + focusLabel?: string | null; + // One entry per storage volume the channels on screen live on, with its free + // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar. + volumes?: ChannelVolume[]; + // `?location=`, already validated by the server against what is on screen. + locationFilter?: string | null; + // The sort the URL asked for (`?sort=size`, which is how /storage links to a + // "largest first" list). Only the initial value: sorting is client state + // from then on, because a router.replace races the global AutoRefresh. + initialSort?: "size" | null; +}) { + // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a + // re-render that reorders or drops a row cannot retarget the selection — + // the same reason SyncConsole keys its selection this way. + const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); + // Plain component state, deliberately NOT the URL: router.replace races the + // global AutoRefresh's router.refresh() and gets dropped. + const [grouped, setGrouped] = useState(true); + + // Always the intersection with what is on screen: a slug can leave the table + // between renders (a scope change, a deletion), and a bulk edit must not act + // on a row nobody can see. + const selectedSlugs = channels + .map((c) => c.slug) + .filter((s) => selected.has(s)); + // "FREE UP N GB" — the arithmetic the operator was doing by hand. + // + // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this + // is the wire between it and the tick boxes. It SELECTS and does not act: + // what moves anything is the deck's existing Move button, with its existing + // destination and its existing per-channel skips. + // + // Always over `channels` — what is on screen — so a volume filter or a site + // scope narrows the proposal exactly as the operator expects. + const [freeUpNote, setFreeUpNote] = useState<string | null>(null); + function freeUp(targetGB: number) { + const result = selectToFreeBytes( + channels.map((c) => ({ + slug: c.slug, + bytes: c.mediaBytes, + inPlace: c.volumeId === INTERNAL_ROW_ID, + // A MARKER IS A GUARANTEED SKIP. The bulk move refuses a channel whose + // media is in transition by name, so proposing one would put a row in + // the deck that cannot move and count its bytes toward a total the + // Move will never deliver. + inTransition: c.media?.status === "in-transition", + })), + targetGB * 1024 ** 3, + ); + setSelected(new Set(result.slugs)); + setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`); + } + + function toggleOne(slug: string) { + setSelected((prev) => { + const next = new Set(prev); + if (!next.delete(slug)) next.add(slug); + return next; + }); + } + + // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the + // thead's real height rather than a guess, because the head grows a line when + // the pipeline block's eyebrow renders and shrinks again when settings switch + // every derived operation off. `top-9` is the fallback the class carries for + // the first paint and for a browser with no ResizeObserver. + const regionRef = useRef<HTMLDivElement>(null); + const theadRef = useRef<HTMLTableSectionElement>(null); + useEffect(() => { + const region = regionRef.current; + const thead = theadRef.current; + if (!region || !thead || typeof ResizeObserver === "undefined") return; + const measure = () => + region.style.setProperty("--thead-h", `${thead.offsetHeight}px`); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(thead); + return () => observer.disconnect(); + }, []); + + // HOW MANY ROWS THE FOCUS IS HOLDING, for the focus line's one-line summary. + // The reason itself stays per-row (each held chip carries the full sentence + // as its screen-reader text); the COUNT is the corpus-wide fact, and stating + // it once above the table is what lets the rows stop repeating the sentence. + const heldCount = channels.filter((c) => c.priority.heldReason).length; + + return ( + // On md+ this is a pane of the page's viewport-height flex column: the + // instrument bar and the deck are fixed chrome and only the region between + // them scrolls. Below md it has no height of its own and the document + // scrolls, exactly as it always has. + <div className="flex min-h-0 flex-1 flex-col"> + <ChannelFocusBar + sites={sites} + focusLabel={focusLabel} + heldCount={heldCount} + /> + <ChannelVolumeBar + volumes={volumes} + active={locationFilter} + onFreeUp={freeUp} + /> + {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left, + how to read it (the band legend, and the two-route explainer behind a + disclosure) on the right. Both used to live in an 11px stack UNDER 67 + rows, where the legend for a chart is of no use to anyone. */} + <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground"> + {sections && sections.length > 0 ? ( + <label className="flex items-center gap-2"> + <input + type="checkbox" + checked={grouped} + onChange={(e) => setGrouped(e.target.checked)} + className="accent-primary" + /> + Group by section + </label> + ) : ( + <span /> + )} + <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> + <BandLegend /> + {columns.some((c) => c.id.startsWith("attribution-")) && ( + <details className="max-w-prose"> + <summary className="cursor-pointer hover:text-foreground"> + Names&middot;A / Names&middot;T + </summary> + <p className="pt-1"> + Speaker names come from the audio (Names&middot;A) or the + transcript (Names&middot;T) &mdash; two routes to the same + field, counted separately because they cost different things. + </p> + </details> + )} + </div> + </div> + {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and + Slug cells pin to its left, and the sixteen columns move underneath + them. The table itself must NOT clip (`overflow-hidden` would make it + the sticky ancestor and nothing would pin) — the rounded corners are + the region's. */} + <div + ref={regionRef} + className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border" + > + <ChannelsTable + rows={channels} + columns={RACK_COLUMNS} + pipelineColumns={columns} + sort={{ initialKey: initialSort }} + selection={{ + selected, + onToggle: toggleOne, + onToggleAll: (checked) => + setSelected( + checked ? new Set(channels.map((c) => c.slug)) : new Set(), + ), + }} + sections={grouped ? sections : null} + siteId={siteId} + sticky + theadRef={theadRef} + /> + </div> + {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to + the table's scroll box rather than to the screen, which is what put + the old bar off the bottom of a phone. */} + <ChannelSelectionDeck + slugs={selectedSlugs} + onClear={() => { + setSelected(new Set()); + setFreeUpNote(null); + }} + destinations={mediaDestinations} + defaultLocationId={defaultLocationId} + // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar + // (it is about volumes and free space, and it has to be reachable with + // nothing ticked — the deck does not exist then). What belongs down + // here is what the proposal actually came to, beside the Move button + // that will act on it. + freeUpNote={freeUpNote} + /> + </div> + ); +} + diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -1,125 +1,40 @@ "use client"; -import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels"; +import { useMemo, useState, type ReactNode, type Ref } from "react"; +import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; import { bandSentence, type OperationBand, } from "yt-dlp-transcript-common/views/pipeline/band"; -import { BandLegend, StateBand } from "../../components/pipelines/StateBand"; -import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections"; +import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections"; +import { tierOrder } from "yt-dlp-transcript-common/lib/channelPriority"; +import { StateBand } from "../../components/pipelines/StateBand"; import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow"; -import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; -import { ChannelBuildToggle } from "./ChannelBuildToggle"; -import { ChannelSyncButton } from "./ChannelSyncButton"; -import ChannelTierSelect from "./ChannelTierSelect"; -import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar"; -import { InlineActionButton } from "../../components/actions/InlineActionButton"; import { - MediaLocationBadge, - type MediaBadgeInput, -} from "../../components/MediaLocationBadge"; -import { - ChannelSelectionDeck, - type BulkDestination, -} from "./ChannelSelectionDeck"; -import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar"; -import { formatBytes } from "yt-dlp-transcript-common/lib/format"; -import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection"; -// A VALUE import from views/, and it is safe: every import in that module is a -// type import, so what reaches the client bundle is two string constants. The -// point is that "internal" and "location" have ONE spelling across /storage -// (which writes the link) and /channels (which reads it). -import { INTERNAL_ROW_ID } from "yt-dlp-transcript-common/views/storage"; -import { - tierOrder, - type PriorityOperation, - type StoredChannelTier, -} from "yt-dlp-transcript-common/lib/channelPriority"; - -// One row's share of the channel priority document, resolved on the server. -// The row never reads the model itself: `focused` and `heldReason` are facts -// about the corpus-wide focus selector, which no single row can answer. -export type ChannelRowPriority = { - tier: StoredChannelTier; - rank: number | null; - overrides: Partial<Record<PriorityOperation, StoredChannelTier>>; - focused: boolean; - heldReason: string | null; - // Why the machine paused it, or null. See autoPauseReasonOf. - autoPausedReason: string | null; -}; + CHANNEL_COLUMNS, + type ChannelCellCtx, + type ChannelColumnId, + type ChannelColumnLabels, + type ChannelRowExtras, + type ChannelSortKey, + type PipelineColumn, +} from "./channelColumns"; -// A row is a stat plus its pipeline bands, in column order. The bands are -// projected on the server from the same snapshot the counts come from, so a -// figure in a band and the count beside it cannot disagree. -export type ChannelRow = ChannelStat & { - pipelines: OperationBand[]; - // Its tier, its pins, and whether the active focus is holding it. Replaces - // the row's sync-inclusion flag: one ordered model instead of one boolean. - priority: ChannelRowPriority; - // How old this channel's report is. Every count and every band on this row is - // projected from that report, so its age is the caveat on all of them — which - // is why it belongs beside them rather than on a page of its own. - report: { - generatedAt: string | null; - state: "current" | "stale" | "missing"; - }; - // Where this channel's media physically is, from inspectChannelMedia on the - // server. Null for an in-place channel — the overwhelming majority — so the - // badge column is empty for them and the two that matter stand out. See - // components/MediaLocationBadge.tsx. - media: MediaBadgeInput | null; - // WHICH VOLUME, as an id the filter can name: a location id, "internal" for - // the corpus volume, or "" for a dataDir under a root nobody named. Derived - // on the server from `config.dataDir` — a pure prefix match, never a probe. - volumeId: string; - volumeLabel: string; - // `snapshot.totalMediaBytes`. NULL, not 0, for a report written before the - // field existed: the Size cell renders that as "—" and the sort puts it - // first on ascending, because a 400 GB channel that has not been measured - // must not sort as the smallest thing on the disk. - mediaBytes: number | null; -}; - -// A column heading for one pipeline. Comes off the operation registry on the -// server (shortLabel, costBasis) rather than being abbreviated here — see -// operations.ts. -export type PipelineColumn = { - id: string; - shortLabel: string; - label: string; - costBasis: string; - // The two externally-dispatched pipelines keep their existing count column - // semantics: the cell's text node is the bare number the suite asserts on, - // under this accessible name. The slug is appended by the cell, so this is - // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which - // is the name channels-counts.spec.ts has always addressed these cells by. - // Null for the derived operations, which have never had a count column and - // get the band's sentence as their text. - countLabel: string | null; -}; - -// Sorting a pipeline column sorts by `reachable` — "which channel has the most -// diarizable audio left right now", a question this page has never been able to -// answer without opening 68 channel pages one at a time. -type SortKey = - | "slug" - | "name" - | "handling" - | "build" - | "tier" - | "playlist" - | "lastSync" - | "report" - | "location" - | "size" - | `op:${string}`; +// ONE CHANNEL TABLE — the /channels rack, the dashboard's channels table and +// every operation page's work section draw their rows here, off the one +// `ChannelRowView` (common/views/channelRow.ts), with whichever columns the +// caller names from the registry in ./channelColumns.tsx. The rack's chrome — +// focus bar, volume bar, instrument bar, scroll region, selection deck — is +// ./ChannelsRack.tsx; this file is the thead, the tbodies, the rows and the +// sort. +// +// Rows are a PROP, never state: the global AutoRefresh re-renders the server +// page and hands down fresh rows, and a table that copied them into state would +// draw the first render's numbers forever. type SortDir = "asc" | "desc"; -type SortState = { key: SortKey; dir: SortDir } | null; +export type ChannelSortState = { key: ChannelSortKey; dir: SortDir } | null; const DEFAULT_DIR: Record<string, SortDir> = { slug: "asc", @@ -143,11 +58,11 @@ const DEFAULT_DIR: Record<string, SortDir> = { // A pipeline column defaults to `reachable` descending: the first click puts the // channels with work at the top, which is the only reason to sort by a pipeline // at all. -function defaultDirFor(key: SortKey): SortDir { +export function defaultDirFor(key: ChannelSortKey): SortDir { return DEFAULT_DIR[key] ?? "desc"; } -function bandOf(row: ChannelRow, id: string): OperationBand | undefined { +function bandOf(row: ChannelRowView, id: string): OperationBand | undefined { return row.pipelines.find((b) => b.id === id); } @@ -172,7 +87,10 @@ function compareNumbers( return (a as number) - (b as number); } -function compareDates(a: string | undefined, b: string | undefined): number { +function compareDates( + a: string | null | undefined, + b: string | null | undefined, +): number { // Missing dates sort as oldest (so stale channels surface first on asc). const aHas = !!a; const bHas = !!b; @@ -196,7 +114,7 @@ function compareBools(a: boolean, b: boolean): number { return (a ? 1 : 0) - (b ? 1 : 0); } -function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { +function cmp(a: ChannelRowView, b: ChannelRowView, key: ChannelSortKey): number { if (key.startsWith("op:")) { const id = key.slice(3); return compareNumbers(bandOf(a, id)?.reachable, bandOf(b, id)?.reachable); @@ -205,14 +123,11 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { case "slug": return a.slug.localeCompare(b.slug); case "name": - return compareStrings(a.config.name, b.config.name); + return compareStrings(a.name, b.name); case "handling": - return compareStrings(a.config.handling, b.config.handling); + return compareStrings(a.handling, b.handling); case "build": - return compareBools( - a.config.excludeFromBuild === true, - b.config.excludeFromBuild === true, - ); + return compareBools(a.excludeFromBuild, b.excludeFromBuild); case "tier": // THE COMPILED ORDER, not the stored one: focus is a position the focus // selector produces, so a focused channel sorts above every normal one @@ -227,12 +142,9 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { case "playlist": return compareNumbers(a.playlistCount, b.playlistCount); case "lastSync": - return compareDates(a.config.lastSyncedAt, b.config.lastSyncedAt); + return compareDates(a.lastSyncedAt, b.lastSyncedAt); case "report": - return compareDates( - a.report.generatedAt ?? undefined, - b.report.generatedAt ?? undefined, - ); + return compareDates(a.report.generatedAt, b.report.generatedAt); case "location": // By LABEL, then slug: the operator reads names, and a stable tiebreak // keeps the two halves of a volume from shuffling between renders. @@ -254,130 +166,98 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { // Pure, and module-level so the grouped render can apply the SAME sort state // independently to each section — which is what "sorts within each group" means. function applySort( - list: ReadonlyArray<ChannelRow>, - sort: SortState, -): ReadonlyArray<ChannelRow> { + list: ReadonlyArray<ChannelRowView>, + sort: ChannelSortState, +): ReadonlyArray<ChannelRowView> { if (!sort) return list; const sorted = [...list].sort((a, b) => cmp(a, b, sort.key)); return sort.dir === "desc" ? sorted.reverse() : sorted; } +export type ChannelSelection = { + selected: ReadonlySet<string>; + onToggle: (slug: string) => void; + onToggleAll: (checked: boolean) => void; +}; + export function ChannelsTable({ - channels, + rows: channels, columns, + pipelineColumns = [], + rowLabelPrefix, + rowExtras, + labels = {}, + sort: sortConfig, + selection, sections = null, siteId, - mediaDestinations = [], - defaultLocationId = "", - sites = [], - focusLabel = null, - volumes = [], - locationFilter = null, - initialSort = null, + sticky = false, + theadRef, + now = null, }: { - channels: ChannelRow[]; - // Which pipelines to draw, in group order, resolved on the server from the - // live settings. A switched-off feature is simply absent — which is the - // honest rendering: an empty column because nobody enabled attribution is not - // the same as an attribution lane with nothing left to do. - columns: PipelineColumn[]; - // Null under "all sites" (or with no sites configured): groups only partition - // a SINGLE site's channels, so there is no one grouping to render across the - // pool. That path is today's flat table, unchanged. - sections?: ChannelGroupSection[] | null; + rows: ChannelRowView[]; + // Which columns, in order, by registry id (./channelColumns.tsx). + columns: readonly ChannelColumnId[]; + // What the `pipelines` column expands into, in group order, resolved on the + // server from the live settings. A switched-off feature is simply absent — + // which is the honest rendering: an empty column because nobody enabled + // attribution is not the same as an attribution lane with nothing left. + pipelineColumns?: PipelineColumn[]; + // `<tr aria-label="${prefix} row ${slug}">` — the dashboard's `channel row`, + // a work section's `<id> row`. The rack's rows carry none. + rowLabelPrefix?: string; + // Per-row data (and rendered elements) a server caller adds; see + // ChannelRowExtras. + rowExtras?: Record<string, ChannelRowExtras>; + labels?: ChannelColumnLabels; + // False: plain headers, rows in the order given. Otherwise the header + // buttons sort (client state — a router.replace races the AutoRefresh), from + // `initialKey` when the URL asked for one (`?sort=size`). + sort: false | { initialKey?: ChannelSortKey | null }; + // False: no checkbox column. The rack owns the set (its deck acts on it). + selection: false | ChannelSelection; + // One tbody per group, each with its header row. Null (or no siteId) is the + // flat table: groups only partition a SINGLE site's channels. + sections?: ChannelGroupSectionView[] | null; siteId?: string; - // The configured storage locations, resolved on the server. The selection - // deck's destination list; empty when none are configured, which leaves the - // deck's free-text root box as the whole control. - mediaDestinations?: BulkDestination[]; - // `settings.storage.defaultLocationId` — which destination the deck opens on. - defaultLocationId?: string; - // Every configured site, for the "Focus site" control. Not the same list as - // the page's scope selector: a focus is corpus-wide, so it can name a site - // whose channels are not the ones on screen. - sites?: FocusSite[]; - // What the focus selector currently names, resolved on the server, or null. - focusLabel?: string | null; - // One entry per storage volume the channels on screen live on, with its free - // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar. - volumes?: ChannelVolume[]; - // `?location=`, already validated by the server against what is on screen. - locationFilter?: string | null; - // The sort the URL asked for (`?sort=size`, which is how /storage links to a - // "largest first" list). Only the initial value: sorting is client state - // from then on, because a router.replace races the global AutoRefresh. - initialSort?: "size" | null; + // The rack: the thead pins to the scroll region's top and the checkbox and + // Slug cells pin to its left. The caller owns the scroll region. + sticky?: boolean; + theadRef?: Ref<HTMLTableSectionElement>; + // The live clock for relative dates; null until mounted. + now?: number | null; }) { - const [sort, setSort] = useState<SortState>( - initialSort ? { key: initialSort, dir: defaultDirFor(initialSort) } : null, + const initialKey = sortConfig ? (sortConfig.initialKey ?? null) : null; + const [sort, setSort] = useState<ChannelSortState>( + initialKey ? { key: initialKey, dir: defaultDirFor(initialKey) } : null, ); - // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a - // re-render that reorders or drops a row cannot retarget the selection — - // the same reason SyncConsole keys its selection this way. - const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); - // Plain component state, deliberately NOT the URL: router.replace races the - // global AutoRefresh's router.refresh() and gets dropped. - const [grouped, setGrouped] = useState(true); + const sortable = sortConfig !== false; const rows = useMemo(() => applySort(channels, sort), [channels, sort]); - // A section lists ChannelStats; the bands live on the ChannelRow the page - // built for the same slug. Joined by slug, never by index — pairing one - // channel's counts with another channel's bands is exactly the silent - // wrongness the row type exists to remove. + // A section lists ChannelStats; the row the page built carries the same + // slug. Joined by slug, never by index — pairing one channel's counts with + // another channel's bands is exactly the silent wrongness the row type + // exists to remove. const rowBySlug = useMemo( () => new Map(channels.map((c) => [c.slug, c])), [channels], ); - const showSections = grouped && !!sections && sections.length > 0 && !!siteId; - // The select column, ten fixed columns (Location and Size joined the eight), - // one per pipeline, then Actions. - const colSpan = 12 + columns.length; - // Always the intersection with what is on screen: a slug can leave the table - // between renders (a scope change, a deletion), and a bulk edit must not act - // on a row nobody can see. - const selectedSlugs = channels - .map((c) => c.slug) - .filter((s) => selected.has(s)); + const showSections = !!sections && sections.length > 0 && !!siteId; + const visibleColumns = columns.filter( + (id) => id !== "select" || selection !== false, + ); + // Computed from what is drawn, never a literal: every column counts one, + // except `pipelines`, which counts one per pipeline. + const colSpan = visibleColumns.reduce( + (n, id) => n + (id === "pipelines" ? pipelineColumns.length : 1), + 0, + ); const allSelected = - channels.length > 0 && selectedSlugs.length === channels.length; + selection !== false && + channels.length > 0 && + channels.every((c) => selection.selected.has(c.slug)); - // "FREE UP N GB" — the arithmetic the operator was doing by hand. - // - // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this - // is the wire between it and the tick boxes. It SELECTS and does not act: - // what moves anything is the deck's existing Move button, with its existing - // destination and its existing per-channel skips. - // - // Always over `channels` — what is on screen — so a volume filter or a site - // scope narrows the proposal exactly as the operator expects. - const [freeUpNote, setFreeUpNote] = useState<string | null>(null); - function freeUp(targetGB: number) { - const result = selectToFreeBytes( - channels.map((c) => ({ - slug: c.slug, - bytes: c.mediaBytes, - inPlace: c.volumeId === INTERNAL_ROW_ID, - // A MARKER IS A GUARANTEED SKIP. The bulk move refuses a channel whose - // media is in transition by name, so proposing one would put a row in - // the deck that cannot move and count its bytes toward a total the - // Move will never deliver. - inTransition: c.media?.status === "in-transition", - })), - targetGB * 1024 ** 3, - ); - setSelected(new Set(result.slugs)); - setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`); - } - - function toggleOne(slug: string) { - setSelected((prev) => { - const next = new Set(prev); - if (!next.delete(slug)) next.add(slug); - return next; - }); - } - - function onHeaderClick(key: SortKey) { + function onHeaderClick(key: ChannelSortKey) { setSort((prev) => { if (prev && prev.key === key) { return { key, dir: prev.dir === "asc" ? "desc" : "asc" }; @@ -386,268 +266,152 @@ export function ChannelsTable({ }); } - // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the - // thead's real height rather than a guess, because the head grows a line when - // the pipeline block's eyebrow renders and shrinks again when settings switch - // every derived operation off. `top-9` is the fallback the class carries for - // the first paint and for a browser with no ResizeObserver. - const regionRef = useRef<HTMLDivElement>(null); - const theadRef = useRef<HTMLTableSectionElement>(null); - useEffect(() => { - const region = regionRef.current; - const thead = theadRef.current; - if (!region || !thead || typeof ResizeObserver === "undefined") return; - const measure = () => - region.style.setProperty("--thead-h", `${thead.offsetHeight}px`); - measure(); - const observer = new ResizeObserver(measure); - observer.observe(thead); - return () => observer.disconnect(); - }, []); + const pad = sticky ? "px-2 py-1.5" : "px-3 py-2"; + const head: ReactNode[] = []; + for (const id of visibleColumns) { + if (id === "select") { + head.push( + <th + key="select" + className={`${sticky ? "sticky left-0 z-10 bg-muted " : ""}w-9 px-2 py-1.5 align-bottom`} + > + <input + type="checkbox" + aria-label="select all channels" + checked={allSelected} + onChange={(e) => + selection !== false && selection.onToggleAll(e.target.checked) + } + className="accent-primary" + /> + </th>, + ); + continue; + } + if (id === "pipelines") { + // THE METER BRIDGE. Six loose grey dashes become one block: the band + // columns share an eyebrow naming them, and the rules that open and + // close the block run the full height of the rack. + pipelineColumns.forEach((col, i) => { + const className = + "w-24 min-w-20" + + (i === 0 ? " border-l border-border" : "") + + (i === pipelineColumns.length - 1 ? " border-r border-border" : ""); + head.push( + sortable ? ( + <SortableTh + key={col.id} + label={col.shortLabel} + sortKey={`op:${col.id}`} + sort={sort} + onClick={onHeaderClick} + eyebrow={i === 0 ? "Pipeline" : undefined} + className={className} + title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`} + /> + ) : ( + <th + key={col.id} + className={`text-left ${pad} font-medium ${className}`} + title={`${col.label} — ${col.costBasis}`} + > + {col.shortLabel} + </th> + ), + ); + }); + continue; + } + const col = CHANNEL_COLUMNS[id]; + const label = labels[id] ?? col.label; + const className = [ + col.th?.className ?? "", + sticky ? (col.th?.stickyClassName ?? "") : "", + ] + .filter(Boolean) + .join(" "); + if (sortable && col.sortKey) { + head.push( + <SortableTh + key={id} + label={label} + sortKey={col.sortKey} + sort={sort} + onClick={onHeaderClick} + align={col.align} + className={className} + title={col.th?.title} + />, + ); + } else { + head.push( + <th + key={id} + className={`${col.align === "right" ? "text-right" : "text-left"} ${pad}${sticky ? " align-bottom" : ""} font-medium ${className}`} + title={col.th?.title} + > + {label} + </th>, + ); + } + } - // HOW MANY ROWS THE FOCUS IS HOLDING, for the focus line's one-line summary. - // The reason itself stays per-row (each held chip carries the full sentence - // as its screen-reader text); the COUNT is the corpus-wide fact, and stating - // it once above the table is what lets the rows stop repeating the sentence. - const heldCount = channels.filter((c) => c.priority.heldReason).length; + const renderRow = (c: ChannelRowView) => ( + <ChannelTableRow + key={c.slug} + channel={c} + columns={visibleColumns} + pipelineColumns={pipelineColumns} + selection={selection} + extras={rowExtras?.[c.slug]} + rowLabelPrefix={rowLabelPrefix} + sticky={sticky} + now={now} + /> + ); - return ( - // On md+ this is a pane of the page's viewport-height flex column: the - // instrument bar and the deck are fixed chrome and only the region between - // them scrolls. Below md it has no height of its own and the document - // scrolls, exactly as it always has. - <div className="flex min-h-0 flex-1 flex-col"> - <ChannelFocusBar - sites={sites} - focusLabel={focusLabel} - heldCount={heldCount} - /> - <ChannelVolumeBar - volumes={volumes} - active={locationFilter} - onFreeUp={freeUp} - /> - {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left, - how to read it (the band legend, and the two-route explainer behind a - disclosure) on the right. Both used to live in an 11px stack UNDER 67 - rows, where the legend for a chart is of no use to anyone. */} - <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground"> - {sections && sections.length > 0 ? ( - <label className="flex items-center gap-2"> - <input - type="checkbox" - checked={grouped} - onChange={(e) => setGrouped(e.target.checked)} - className="accent-primary" - /> - Group by section - </label> - ) : ( - <span /> - )} - <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> - <BandLegend /> - {columns.some((c) => c.id.startsWith("attribution-")) && ( - <details className="max-w-prose"> - <summary className="cursor-pointer hover:text-foreground"> - Names&middot;A / Names&middot;T - </summary> - <p className="pt-1"> - Speaker names come from the audio (Names&middot;A) or the - transcript (Names&middot;T) &mdash; two routes to the same - field, counted separately because they cost different things. - </p> - </details> - )} - </div> - </div> - {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and - Slug cells pin to its left, and the sixteen columns move underneath - them. The table itself must NOT clip (`overflow-hidden` would make it - the sticky ancestor and nothing would pin) — the rounded corners are - the region's. */} - <div - ref={regionRef} - className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border" + const table = ( + <table + className={ + sticky + ? "w-full text-sm" + : "text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full" + } + > + <thead + ref={theadRef} + className={sticky ? "sticky top-0 z-30 bg-muted" : "bg-muted"} > - <table className="w-full text-sm"> - <thead ref={theadRef} className="sticky top-0 z-30 bg-muted"> - <tr> - <th className="sticky left-0 z-10 w-9 bg-muted px-2 py-1.5 align-bottom"> - <input - type="checkbox" - aria-label="select all channels" - checked={allSelected} - onChange={(e) => - setSelected( - e.target.checked - ? new Set(channels.map((c) => c.slug)) - : new Set(), - ) - } - className="accent-primary" - /> - </th> - <SortableTh - label="Slug" - sortKey="slug" - sort={sort} - onClick={onHeaderClick} - className="sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]" - /> - <SortableTh - label="Name" - sortKey="name" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Handling" - sortKey="handling" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Build" - sortKey="build" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Tier" - sortKey="tier" - sort={sort} - onClick={onHeaderClick} - title="Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column." - /> - <SortableTh - label="Playlist" - sortKey="playlist" - sort={sort} - onClick={onHeaderClick} - align="right" - /> - <SortableTh - label="Last sync" - sortKey="lastSync" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - /> - <SortableTh - label="Report" - sortKey="report" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong." - /> - {/* THE TWO STORAGE COLUMNS. Which disk the media is on, and how - much of it there is — the pair that makes "what should I move" - answerable without opening 71 channel pages. Free space is - deliberately NOT here: it is a fact about a volume, not about - a row, and it is stated once in the volume bar above. */} - <SortableTh - label="Location" - sortKey="location" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - title="Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage." - /> - <SortableTh - label="Size" - sortKey="size" - sort={sort} - onClick={onHeaderClick} - align="right" - className="whitespace-nowrap" - title="Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first." - /> - {/* THE METER BRIDGE. Six loose grey dashes become one block: the - band columns share an eyebrow naming them, and the rules that - open and close the block run the full height of the rack. */} - {columns.map((col, i) => ( - <SortableTh - key={col.id} - label={col.shortLabel} - sortKey={`op:${col.id}`} - sort={sort} - onClick={onHeaderClick} - eyebrow={i === 0 ? "Pipeline" : undefined} - className={ - "w-24 min-w-20" + - (i === 0 ? " border-l border-border" : "") + - (i === columns.length - 1 ? " border-r border-border" : "") - } - title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`} - /> - ))} - <th className="whitespace-nowrap px-2 py-1.5 text-left align-bottom font-medium"> - Actions - </th> - </tr> - </thead> - {showSections ? ( - // One sort state, applied independently per section — which IS - // "sorts within each group". - sections.map((section) => ( - <tbody - key={section.group.id} - aria-labelledby={`group-${section.group.id}`} - > - <ChannelGroupHeaderRow - section={section} - siteId={siteId as string} - colSpan={colSpan} - /> - {applySort( - section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), - sort, - ).map((c) => ( - <ChannelTableRow - key={c.slug} - channel={c} - columns={columns} - selected={selected.has(c.slug)} - onToggle={toggleOne} - /> - ))} - </tbody> - )) - ) : ( - <tbody> - {rows.map((c) => ( - <ChannelTableRow - key={c.slug} - channel={c} - columns={columns} - selected={selected.has(c.slug)} - onToggle={toggleOne} - /> - ))} - </tbody> - )} - </table> - </div> - {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to - the table's scroll box rather than to the screen, which is what put - the old bar off the bottom of a phone. */} - <ChannelSelectionDeck - slugs={selectedSlugs} - onClear={() => { - setSelected(new Set()); - setFreeUpNote(null); - }} - destinations={mediaDestinations} - defaultLocationId={defaultLocationId} - // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar - // (it is about volumes and free space, and it has to be reachable with - // nothing ticked — the deck does not exist then). What belongs down - // here is what the proposal actually came to, beside the Move button - // that will act on it. - freeUpNote={freeUpNote} - /> + <tr>{head}</tr> + </thead> + {showSections ? ( + // One sort state, applied independently per section — which IS + // "sorts within each group". + sections.map((section) => ( + <tbody + key={section.group.id} + aria-labelledby={`group-${section.group.id}`} + > + <ChannelGroupHeaderRow + section={section} + siteId={siteId as string} + colSpan={colSpan} + /> + {applySort( + section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), + sort, + ).map(renderRow)} + </tbody> + )) + ) : ( + <tbody>{rows.map(renderRow)}</tbody> + )} + </table> + ); + if (sticky) return table; + return ( + <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> + {table} </div> ); } @@ -672,6 +436,7 @@ function PipelineCell({ band, count, className = "", + pad, }: { slug: string; column: PipelineColumn; @@ -687,12 +452,18 @@ function PipelineCell({ // close the block. Owned by the row, because only the row knows which cell is // first and which is last. className?: string; + pad: string; }) { if (!band) - return <Td className={`text-muted-foreground ${className}`}>&mdash;</Td>; + return ( + <Td pad={pad} className={`text-muted-foreground ${className}`}> + &mdash; + </Td> + ); const sentence = bandSentence(band); return ( <Td + pad={pad} className={className} ariaLabel={`${column.countLabel ?? column.label} for ${slug}`} title={`${column.label} — ${sentence}`} @@ -705,24 +476,35 @@ function PipelineCell({ ); } -// One channel's row, shared by the flat and the sectioned render so the two -// can never drift apart column-for-column. +// One channel's row, shared by the flat and the sectioned render — and by +// every table that draws a channel — so they can never drift apart +// column-for-column. function ChannelTableRow({ channel: c, columns, - selected, - onToggle, + pipelineColumns, + selection, + extras, + rowLabelPrefix, + sticky, + now, }: { - channel: ChannelRow; - columns: PipelineColumn[]; - selected: boolean; - onToggle: (slug: string) => void; + channel: ChannelRowView; + columns: readonly ChannelColumnId[]; + pipelineColumns: PipelineColumn[]; + selection: false | ChannelSelection; + extras: ChannelRowExtras | undefined; + rowLabelPrefix: string | undefined; + sticky: boolean; + now: number | null; }) { + const selected = selection !== false && selection.selected.has(c.slug); // A sticky cell paints over the row behind it, so it cannot inherit the row's // background — it has to carry the same one explicitly or the rows would show // through the pinned identity column while the rest scrolls. const stickyBg = selected ? "bg-accent" : "bg-background"; const bridge = "bg-surface w-24 min-w-20"; + const pad = sticky ? "px-2 py-1.5" : "px-3 py-2"; // Dimmed for the two things that take the row out of a pipeline: it is // excluded from the export build, or its base tier is Paused. (The sync // exclusion flag that used to dim it is now a `sync` pin, which is a @@ -734,152 +516,101 @@ function ChannelTableRow({ // `absolute z-30` Advanced popover (ChannelTierSelect) can no longer paint // above the rows that follow, however high its z-index — every later row // draws over it and swallows the clicks. So the Tier cell — the one that - // hosts the popover — is the one cell that is NOT dimmed. Moving this back - // onto the row re-breaks the menu (operator report 2026-09-17). + // hosts the popover — is the one cell that is NOT dimmed (its registry entry + // says `dim: false`). Moving this back onto the row re-breaks the menu + // (operator report 2026-09-17). + // + // RACK ONLY. The dashboard and the work tables never dimmed a row: their + // priority is neutral, and build exclusion only affects the export — dimming + // a channel in an "undownloaded" list would read as "out of the pipeline" + // when it is not. const dim = - c.config.excludeFromBuild || c.priority.tier === "paused" + sticky && (c.excludeFromBuild || c.priority.tier === "paused") ? " opacity-60" : ""; - return ( - <tr className={"border-t border-border " + (selected ? "bg-accent " : "")}> - <td - className={`sticky left-0 z-10 w-9 px-2 py-1.5 align-middle ${stickyBg}${dim}`} - > - <input - type="checkbox" - aria-label={`select ${c.slug}`} - checked={selected} - onChange={() => onToggle(c.slug)} - className="accent-primary" - /> - </td> - <Td - className={`sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${stickyBg}${dim}`} - > - <span className="inline-flex items-center gap-1.5"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-foreground" - > - {c.slug} - </Link> - <MediaLocationBadge media={c.media} compact /> - </span> - </Td> - <Td className={`max-w-48 truncate${dim}`}>{c.config.name ?? ""}</Td> - <Td className={`font-mono text-[11px] text-muted-foreground${dim}`}> - {c.config.handling} - </Td> - <Td className={dim}> - <ChannelBuildToggle - slug={c.slug} - excluded={c.config.excludeFromBuild === true} - /> - </Td> - <Td> - <ChannelTierSelect - slug={c.slug} - tier={c.priority.tier} - overrides={c.priority.overrides} - focused={c.priority.focused} - heldReason={c.priority.heldReason} - autoPausedReason={c.priority.autoPausedReason} - /> - </Td> - <Td - className={`text-right tabular-nums${dim}`} - ariaLabel={`playlist count for ${c.slug}`} - > - {c.playlistCount ?? "—"} - </Td> - <Td - className={`whitespace-nowrap text-xs tabular-nums text-muted-foreground${dim}`} - > - {formatStamp(c.config.lastSyncedAt)} - </Td> - <Td - ariaLabel={`report age for ${c.slug}`} - className={`whitespace-nowrap text-xs tabular-nums ${ - c.report.state === "current" - ? "text-muted-foreground" - : "text-warning" - }${dim}`} - > - {c.report.state === "current" - ? formatStamp(c.report.generatedAt) - : c.report.state} - </Td> + const ctx: ChannelCellCtx = { + dim, + stickyBg, + sticky, + selected, + onToggle: selection === false ? () => {} : selection.onToggle, + extras, + now, + }; + const cells: ReactNode[] = []; + for (const id of columns) { + if (id === "select") { + cells.push( + <Td + key="select" + pad="px-2 py-1.5" + className={`${sticky ? `sticky left-0 z-10 ${stickyBg} ` : ""}w-9${dim}`} + > + <input + type="checkbox" + aria-label={`select ${c.slug}`} + checked={selected} + onChange={() => ctx.onToggle(c.slug)} + className="accent-primary" + /> + </Td>, + ); + continue; + } + if (id === "pipelines") { + pipelineColumns.forEach((col, i) => { + cells.push( + <PipelineCell + key={col.id} + pad={pad} + slug={c.slug} + column={col} + band={bandOf(c, col.id)} + className={ + bridge + + (i === 0 ? " border-l border-border" : "") + + (i === pipelineColumns.length - 1 + ? " border-r border-border" + : "") + + dim + } + count={ + col.id === "download" + ? c.downloadCount + : col.id === "transcription" + ? c.transcriptCount + : null + } + />, + ); + }); + continue; + } + const col = CHANNEL_COLUMNS[id]; + const cell = col.cell(c, ctx); + cells.push( <Td - ariaLabel={`media location for ${c.slug}`} - className={`whitespace-nowrap text-xs${dim}`} - title={c.media?.target ?? undefined} + key={id} + pad={pad} + className={`${cell.className ?? ""}${col.dim === false ? "" : dim}`} + ariaLabel={cell.ariaLabel} + title={cell.title} + suppressHydrationWarning={cell.suppressHydrationWarning} > - {/* THE LABEL ONLY — NO SECOND BADGE. The unreachable / in-transition - marking is the badge's, and the badge is already on this row, in the - Slug cell beside the channel's name; drawing another here put two - elements with the same accessible name on every relocated row, which - is a strict-mode violation for the suite and a screen reader reading - the same sentence twice for a human. The COLUMN answers "which - disk"; the badge answers "can it be reached", and one of each per - row is the right number. */} - {c.volumeLabel} - </Td> - <Td - ariaLabel={`media size for ${c.slug}`} - className={`whitespace-nowrap text-right tabular-nums${dim}`} - title={ - c.mediaBytes === null - ? "No size in this channel's report yet — refresh it for a figure." - : undefined - } - > - {c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes)} - </Td> - {columns.map((col, i) => ( - <PipelineCell - key={col.id} - slug={c.slug} - column={col} - band={bandOf(c, col.id)} - className={ - bridge + - (i === 0 ? " border-l border-border" : "") + - (i === columns.length - 1 ? " border-r border-border" : "") + - dim - } - count={ - col.id === "download" - ? c.downloadCount - : col.id === "transcription" - ? c.transcriptCount - : null - } - /> - ))} - <Td className={`whitespace-nowrap${dim}`}> - <div className="flex items-center gap-2"> - <ChannelSyncButton slug={c.slug} disabled={!c.config.url} /> - <ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} /> - <InlineActionButton - variant={{ kind: "refreshReport", slug: c.slug }} - /> - </div> - </Td> + {cell.content} + </Td>, + ); + } + return ( + <tr + aria-label={rowLabelPrefix ? `${rowLabelPrefix} row ${c.slug}` : undefined} + className={"border-t border-border " + (selected ? "bg-accent " : "")} + > + {cells} </tr> ); } -// A 40px row has one line for a date, so the date has to fit one line. The -// locale default spells the year and the seconds out ("11/09/2026, 10:24:31"); -// medium/short is the same instant in the same locale, two thirds the width. -function formatStamp(iso: string | null | undefined): string { - if (!iso) return ""; - return new Date(iso).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); -} - function SortableTh({ label, sortKey, @@ -891,9 +622,9 @@ function SortableTh({ eyebrow, }: { label: string; - sortKey: SortKey; - sort: SortState; - onClick: (key: SortKey) => void; + sortKey: ChannelSortKey; + sort: ChannelSortState; + onClick: (key: ChannelSortKey) => void; align?: "left" | "right"; className?: string; title?: string; @@ -948,17 +679,24 @@ function Td({ className = "", ariaLabel, title, + pad, + suppressHydrationWarning, }: { - children: React.ReactNode; + children: ReactNode; className?: string; ariaLabel?: string; title?: string; + pad: string; + // Locale-formatted dates: the server's locale/timezone need not be the + // browser's (a Docker runtime is UTC), as JobRow's date cells already allow. + suppressHydrationWarning?: boolean; }) { return ( <td - className={`px-2 py-1.5 align-middle ${className}`} + className={`${pad} align-middle ${className}`} aria-label={ariaLabel} title={title} + suppressHydrationWarning={suppressHydrationWarning} > {children} </td> diff --git a/editor/app/channels/components/channelColumnPresets.ts b/editor/app/channels/components/channelColumnPresets.ts @@ -0,0 +1,96 @@ +// THE CHANNEL COLUMN IDS AND PRESETS — plain data, deliberately NOT in the +// "use client" registry (./channelColumns.tsx). A server component that imports +// a value from a client module gets a client REFERENCE, not the value: the work +// tables' server shell calling `WORK_COLUMNS.filter(...)` threw "filter is not +// a function". So the ids, the sort keys, the pipeline-column shape and the +// presets live here, importable from either side; the cells stay client-only. + +export type ChannelColumnId = + | "select" + | "slug" + | "name" + | "handling" + | "build" + | "tier" + | "playlist" + | "videos" + | "digestReachable" + | "count" + | "extra" + | "lastSync" + | "lastSyncRelative" + | "report" + | "location" + | "size" + | "pipelines" + | "actions"; + +// Sorting a pipeline column sorts by `reachable` — "which channel has the most +// diarizable audio left right now". +export type ChannelSortKey = + | "slug" + | "name" + | "handling" + | "build" + | "tier" + | "playlist" + | "lastSync" + | "report" + | "location" + | "size" + | `op:${string}`; + +// A column heading for one pipeline. Comes off the operation registry on the +// server (shortLabel, costBasis) rather than being abbreviated here — see +// operations.ts. +export type PipelineColumn = { + id: string; + shortLabel: string; + label: string; + costBasis: string; + // The two externally-dispatched pipelines keep their existing count column + // semantics: the cell's text node is the bare number the suite asserts on, + // under this accessible name. The slug is appended by the cell, so this is + // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which + // is the name channels-counts.spec.ts has always addressed these cells by. + // Null for the derived operations, which have never had a count column and + // get the band's sentence as their text. + countLabel: string | null; +}; + +// THE PRESETS. The rack draws everything; the dashboard and a work section +// each name the handful they have always drawn. +export const RACK_COLUMNS: readonly ChannelColumnId[] = [ + "select", + "slug", + "name", + "handling", + "build", + "tier", + "playlist", + "lastSync", + "report", + "location", + "size", + "pipelines", + "actions", +]; + +export const DASHBOARD_COLUMNS: readonly ChannelColumnId[] = [ + "slug", + "handling", + "videos", + "digestReachable", + "lastSyncRelative", + "actions", +]; + +// `extra` is dropped by the caller when the section has no extra column. +export const WORK_COLUMNS: readonly ChannelColumnId[] = [ + "slug", + "count", + "extra", + "report", + "lastSync", + "actions", +]; diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx @@ -0,0 +1,350 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { MediaLocationBadge } from "../../components/MediaLocationBadge"; +import { InlineActionButton } from "../../components/actions/InlineActionButton"; +import { fmtTime } from "../../widget/lib/relativeTime"; +import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; +import { ChannelBuildToggle } from "./ChannelBuildToggle"; +import { ChannelSyncButton } from "./ChannelSyncButton"; +import ChannelTierSelect from "./ChannelTierSelect"; +import type { + ChannelColumnId, + ChannelSortKey, +} from "./channelColumnPresets"; + +// Re-exported for the client importers; a SERVER importer must take these from +// ./channelColumnPresets.ts directly (see there). +export { + DASHBOARD_COLUMNS, + RACK_COLUMNS, + WORK_COLUMNS, + type ChannelColumnId, + type ChannelSortKey, + type PipelineColumn, +} from "./channelColumnPresets"; + +// THE CHANNEL COLUMN REGISTRY. +// +// A REGISTRY BY ID, NOT COLUMN OBJECTS PASSED AS PROPS. The work tables are +// drawn by a SERVER shell (ChannelWorkTable — its section configs hold +// functions that return elements), and a function cannot cross the +// server/client boundary as a prop. So a caller names columns by id — a preset +// below, or its own list — and the cells live here, on the client, where the +// shared table (./ChannelsTable.tsx) looks them up. What a server caller needs +// to put INTO a cell (a count, an extra figure, an actions cluster) travels as +// data or as an already-rendered element in `rowExtras`, never as a function. +// +// Every accessible name a spec addresses a cell by is spelled here, once: +// `playlist count for`, `report age for`, `media location for`, `media size +// for`, and — in the table's pipeline cell — `${countLabel} for`. + +// What a server caller adds to one row: the section's count and extra figure, +// and its actions cluster as an ELEMENT (elements cross the boundary; the +// functions that built them stay on the server). +export type ChannelRowExtras = { + count?: number; + extra?: string; + actions?: ReactNode; +}; + +export type ChannelColumnLabels = Partial<Record<ChannelColumnId, string>>; + +export type ChannelCellCtx = { + // " opacity-60" when the row is out of a pipeline (build-excluded or + // paused), else "". Applied by the TABLE, per cell, to every column whose + // `dim` is not false — never to the <tr>; see ChannelsTable. + dim: string; + // The rack's pinned-column background (a sticky cell paints over the row + // behind it, so it must carry the row's background explicitly). + stickyBg: string; + sticky: boolean; + selected: boolean; + onToggle: (slug: string) => void; + extras: ChannelRowExtras | undefined; + // The relative-time clock; null until mounted. + now: number | null; +}; + +export type ChannelCell = { + content: ReactNode; + className?: string; + ariaLabel?: string; + title?: string; + // Set on locale-formatted date cells; see Td in ./ChannelsTable.tsx. + suppressHydrationWarning?: boolean; +}; + + +export type ChannelColumn = { + // A caller's `labels[id]` wins over this — a work section names its count + // column ("undownloaded") and calls the report column "Last report". + label: string; + align?: "left" | "right"; + sortKey?: ChannelSortKey; + // `stickyClassName` applies only in the rack (`sticky`), where the Slug + // column pins left under the pinned header. + th?: { className?: string; stickyClassName?: string; title?: string }; + cell: (row: ChannelRowView, ctx: ChannelCellCtx) => ChannelCell; + // False for the one cell that must not dim: Tier hosts the Advanced popover, + // and a dimmed cell is a stacking context that would trap it. + dim?: boolean; +}; + +// A 40px row has one line for a date, so the date has to fit one line. The +// locale default spells the year and the seconds out ("11/09/2026, 10:24:31"); +// medium/short is the same instant in the same locale, two thirds the width. +export function formatStamp(iso: string | null | undefined): string { + if (!iso) return ""; + return new Date(iso).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function toMs(iso: string | null): number | null { + if (!iso) return null; + const n = Date.parse(iso); + return Number.isNaN(n) ? null : n; +} + +export const CHANNEL_COLUMNS: Record< + Exclude<ChannelColumnId, "select" | "pipelines">, + ChannelColumn +> = { + slug: { + label: "Slug", + sortKey: "slug", + th: { + stickyClassName: + "sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]", + }, + cell: (c, ctx) => ({ + className: ctx.sticky + ? `sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${ctx.stickyBg}` + : "whitespace-nowrap font-mono", + content: ( + <span className="inline-flex items-center gap-1.5"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-foreground" + > + {c.slug} + </Link> + <MediaLocationBadge media={c.media} compact /> + </span> + ), + }), + }, + name: { + label: "Name", + sortKey: "name", + cell: (c) => ({ className: "max-w-48 truncate", content: c.name }), + }, + handling: { + label: "Handling", + sortKey: "handling", + cell: (c) => ({ + className: "font-mono text-[11px] text-muted-foreground", + content: c.handling, + }), + }, + build: { + label: "Build", + sortKey: "build", + cell: (c) => ({ + content: ( + <ChannelBuildToggle slug={c.slug} excluded={c.excludeFromBuild} /> + ), + }), + }, + tier: { + label: "Tier", + sortKey: "tier", + th: { + title: + "Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column.", + }, + dim: false, + cell: (c) => ({ + content: ( + <ChannelTierSelect + slug={c.slug} + tier={c.priority.tier} + overrides={c.priority.overrides} + focused={c.priority.focused} + heldReason={c.priority.heldReason} + autoPausedReason={c.priority.autoPausedReason} + /> + ), + }), + }, + playlist: { + label: "Playlist", + sortKey: "playlist", + align: "right", + cell: (c) => ({ + className: "text-right tabular-nums", + ariaLabel: `playlist count for ${c.slug}`, + content: c.playlistCount ?? "—", + }), + }, + // From the snapshot rather than a corpus walk, so it lags a sync by the + // snapshot scheduler's debounce. + videos: { + label: "Videos", + align: "right", + cell: (c) => ({ + className: "text-right tabular-nums", + content: c.work.videos, + }), + }, + // Muted: during the backfill this is nearly every video, so it is a + // coverage readout rather than a call to action. + digestReachable: { + label: "Digest to do", + align: "right", + th: { + className: "whitespace-nowrap", + title: + "Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it", + }, + cell: (c) => ({ + className: "text-right tabular-nums text-xs text-muted-foreground", + content: c.work.digestReachable, + }), + }, + // A work section's own figure — undownloaded, untranscribed, reclaimable — + // computed by the section's `getCount` on the server and carried here as data. + count: { + label: "Count", + align: "right", + cell: (_c, ctx) => ({ + className: "text-right tabular-nums", + content: ctx.extras?.count ?? "", + }), + }, + extra: { + label: "Extra", + align: "right", + th: { className: "whitespace-nowrap" }, + cell: (_c, ctx) => ({ + className: "text-right tabular-nums whitespace-nowrap text-muted-foreground", + content: ctx.extras?.extra ?? "", + }), + }, + lastSync: { + label: "Last sync", + sortKey: "lastSync", + th: { className: "whitespace-nowrap" }, + cell: (c, ctx) => ({ + className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground", + suppressHydrationWarning: true, + // The rack leaves a never-synced cell empty; a work table says so. + content: formatStamp(c.lastSyncedAt) || (ctx.sticky ? "" : "never"), + }), + }, + // The dashboard's live "5m ago", ticking off the cockpit's clock. + lastSyncRelative: { + label: "Last sync", + th: { className: "whitespace-nowrap" }, + cell: (c, ctx) => { + const ms = toMs(c.lastSyncedAt); + return { + className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground", + content: ms === null ? "never" : (fmtTime(ms, ctx.now, false) ?? "…"), + }; + }, + }, + report: { + label: "Report", + sortKey: "report", + th: { + className: "whitespace-nowrap", + title: + "When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong.", + }, + // The rack says only the state word when the report cannot be trusted; a + // work table ("Last report") keeps the date beside it — how stale is what + // decides whether that row's count can be believed. + cell: (c, ctx) => { + const stamp = formatStamp(c.report.generatedAt); + return { + ariaLabel: `report age for ${c.slug}`, + className: `whitespace-nowrap text-xs tabular-nums ${ + c.report.state === "current" ? "text-muted-foreground" : "text-warning" + }`, + title: stamp || undefined, + suppressHydrationWarning: true, + content: + c.report.state === "current" + ? stamp + : !ctx.sticky && c.report.state === "stale" && stamp + ? `stale · ${stamp}` + : c.report.state, + }; + }, + }, + // THE TWO STORAGE COLUMNS. Which disk the media is on, and how much of it + // there is — the pair that makes "what should I move" answerable without + // opening 71 channel pages. Free space is deliberately NOT here: it is a fact + // about a volume, not about a row, and it is stated once in the volume bar. + location: { + label: "Location", + sortKey: "location", + th: { + className: "whitespace-nowrap", + title: + "Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage.", + }, + // THE LABEL ONLY — NO SECOND BADGE. The unreachable / in-transition marking + // is the badge's, and the badge is already on this row, in the Slug cell; + // drawing another here put two elements with the same accessible name on + // every relocated row (a strict-mode violation for the suite, and a screen + // reader reading the same sentence twice). The COLUMN answers "which disk"; + // the badge answers "can it be reached". + cell: (c) => ({ + ariaLabel: `media location for ${c.slug}`, + className: "whitespace-nowrap text-xs", + title: c.media?.target ?? undefined, + content: c.volumeLabel, + }), + }, + size: { + label: "Size", + sortKey: "size", + align: "right", + th: { + className: "whitespace-nowrap", + title: + "Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first.", + }, + cell: (c) => ({ + ariaLabel: `media size for ${c.slug}`, + className: "whitespace-nowrap text-right tabular-nums", + title: + c.mediaBytes === null + ? "No size in this channel's report yet — refresh it for a figure." + : undefined, + content: c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes), + }), + }, + // The rack's own cluster unless the caller rendered one for this row. + actions: { + label: "Actions", + th: { className: "whitespace-nowrap" }, + cell: (c, ctx) => ({ + className: "whitespace-nowrap", + content: ctx.extras?.actions ?? ( + <div className="flex items-center gap-2"> + <ChannelSyncButton slug={c.slug} disabled={!c.hasUrl} /> + <ChannelAvailabilityButton slug={c.slug} disabled={!c.hasUrl} /> + <InlineActionButton variant={{ kind: "refreshReport", slug: c.slug }} /> + </div> + ), + }), + }, +}; diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -32,26 +32,26 @@ import { type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { - locationOfDataDir, - type StorageLocation, -} from "yt-dlp-transcript-common/lib/storageLocations"; -import { INTERNAL_LOCATION_ID, INTERNAL_LOCATION_LABEL, volumeFreeBytes, } from "yt-dlp-transcript-common/controller/storageLocations"; import { buildChannelBands } from "yt-dlp-transcript-common/views/pipeline/buildBands"; import { EXTERNAL_BAND_IDS } from "yt-dlp-transcript-common/views/pipeline/buildBands"; +import { ChannelsRack } from "./components/ChannelsRack"; +import type { PipelineColumn } from "./components/channelColumnPresets"; import { - ChannelsTable, - type ChannelRow, - type PipelineColumn, -} from "./components/ChannelsTable"; + buildChannelRowView, + channelVolumeOf, + type ChannelRowView, +} from "yt-dlp-transcript-common/views/channelRow"; import type { ChannelVolume } from "./components/ChannelVolumeBar"; -import { buildChannelGroupSections } from "yt-dlp-transcript-common/views/channelGroupSections"; +import { + buildChannelGroupSections, + type ChannelGroupSectionView, +} from "yt-dlp-transcript-common/views/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton"; -import { reportStateOf } from "../lib/actionable/loadActionable"; import { resolveActiveSite } from "../lib/activeSite"; export const dynamic = "force-dynamic"; @@ -155,28 +155,6 @@ function focusLabelOf( : `${focusSlugs.length} channels`; } -// WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id. -// -// Same derivation the badge uses (`config.dataDir` under a location's root, -// longest match wins) with one addition: no `dataDir` at all means the corpus -// volume, which is the row the operator is trying to empty and therefore the -// one they most need to filter to. A `dataDir` under a root NOBODY named is -// neither — it gets "" and falls out of every volume filter, which is the -// honest answer and the nudge to name that root on /storage. -function volumeOf( - dataDir: string | undefined, - locations: StorageLocation[], -): { id: string; label: string } { - const trimmed = dataDir?.trim(); - if (!trimmed) { - return { id: INTERNAL_LOCATION_ID, label: "Internal" }; - } - const found = locationOfDataDir(trimmed, locations); - return found - ? { id: found.id, label: found.label || found.id } - : { id: "", label: "Elsewhere" }; -} - export default async function ChannelsPage({ searchParams, }: { @@ -242,44 +220,23 @@ export default async function ChannelsPage({ // TWO SYSCALLS PER VOLUME, NOT A PROBE. See volumeFreeBytes: this table draws // 71 rows on every auto-refresh and the rule is that tables never shell out. const freeByVolume = await volumeFreeBytes({ paths, locations }); - const all: ChannelRow[] = stats.map((stat) => { + // ONE ROW PER CHANNEL, off the shared builder (common/views/channelRow.ts): + // the dashboard and the operation pages build theirs the same way. The view + // carries no `config` — this table is a client component, and a channel + // config holds cookie paths and yt-dlp args no cell draws. + const all: ChannelRowView[] = stats.map((stat) => { const brief = briefBySlug.get(stat.slug); - const media = mediaBySlug.get(stat.slug); - const volume = volumeOf(brief?.config.dataDir, locations); - return { - ...stat, - // WHERE THE BYTES ARE, AND HOW MANY. Both off the snapshot already in - // hand — no walk, no probe. `mediaBytes` is null for a report written - // before the field existed, and the cell renders that as "—" rather than - // "0 B": a zero would sort a 400 GB channel to the bottom of the very - // list the operator opened to find it. - volumeId: volume.id, - volumeLabel: volume.label, - mediaBytes: brief?.snapshot?.totalMediaBytes ?? null, - pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), - report: { - generatedAt: brief?.snapshot?.generatedAt ?? null, - state: brief ? reportStateOf(brief) : ("missing" as const), - }, - media: - media && media.status !== "in-place" - ? { - status: media.status, - target: media.target, - detail: media.detail, - // WHICH NAMED LOCATION — a pure prefix match of the recorded - // target against the configured roots, done here because the - // settings are here and the table is a client component. NEVER a - // probe: this table draws one badge per row. Undefined for a root - // nobody named, which renders exactly what it rendered before. - // The row already derived it once for the Location column; - // re-deriving it here would be a second answer to the same - // question. "" is the root nobody named — the badge takes - // undefined for that, which renders exactly what it rendered - // before locations existed. - locationLabel: volume.id === "" ? undefined : volume.label, - } - : null, + return buildChannelRowView({ + slug: stat.slug, + config: stat.config, + snapshot: snapshots.get(stat.slug) ?? null, + playlistCount: stat.playlistCount, + bands: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), + // WHERE THE MEDIA IS: inspectChannelMedia's result; the builder drops an + // in-place one and names the location from the volume, which is the + // same prefix match the Location column shows — one answer, not two. + media: mediaBySlug.get(stat.slug) ?? null, + volume: channelVolumeOf(brief?.config.dataDir, locations), priority: { tier: tierOf(priority, stat.slug), rank: rankOf(priority, stat.slug), @@ -303,7 +260,7 @@ export default async function ChannelsPage({ ? `Held — focus: ${focusLabel}` : null, }, - }; + }); }); // Scope to the active site's membership; "all sites" shows the full pool. // @@ -351,14 +308,26 @@ export default async function ChannelsPage({ locationFilter === null ? inSite : inSite.filter((c) => c.volumeId === locationFilter); + const shownSlugs = new Set(channels.map((c) => c.slug)); // GROUPS ARE A PARTITION OF A SITE, so a volume filter and the grouped render // cannot both be true: half a group is not a group. Filtering flattens. - const sections = + // PROJECTED to slugs before they cross to the client: a section's stats + // carry each channel's full config, and the rack reads only slug and count. + const sections: ChannelGroupSectionView[] | null = activeSite && locationFilter === null - ? buildChannelGroupSections(activeSite, channels, briefs, settings) + ? buildChannelGroupSections( + activeSite, + stats.filter((st) => shownSlugs.has(st.slug)), + briefs, + settings, + ).map((s) => ({ + ...s, + channels: s.channels.map((c) => ({ slug: c.slug })), + })) : null; - const shown = new Set(channels.map((c) => c.slug)); - const freshness = summariseFreshness(briefs.filter((b) => shown.has(b.slug))); + const freshness = summariseFreshness( + briefs.filter((b) => shownSlugs.has(b.slug)), + ); return ( // ON md+ THE DOCUMENT STOPS SCROLLING. The page is a flex column exactly // the height of the viewport (main carries py-6, hence -3rem) so that the @@ -448,7 +417,7 @@ export default async function ChannelsPage({ : "No channels yet."} </p> ) : ( - <ChannelsTable + <ChannelsRack channels={channels} columns={columns} sections={sections} diff --git a/editor/app/components/MediaLocationBadge.tsx b/editor/app/components/MediaLocationBadge.tsx @@ -1,7 +1,5 @@ -import type { - ChannelMediaLocation, - ChannelMediaStatus, -} from "yt-dlp-transcript-common/lib/channelMedia"; +import type { ChannelMediaStatus } from "yt-dlp-transcript-common/lib/channelMedia"; +import type { ChannelRowMedia } from "yt-dlp-transcript-common/views/channelRow"; // THE ONE RENDERING OF "where is this channel's media, and can we reach it". // @@ -31,16 +29,10 @@ import type { // The prop shape, deliberately narrower than ChannelMediaLocation: a row only // needs what it draws, so a server page can project three fields onto a client -// component instead of serializing a whole location per channel. -export type MediaBadgeInput = Pick< - ChannelMediaLocation, - "status" | "target" | "detail" -> & { - // The name of the storage location this channel's media is on, projected by - // the server that built the row (see `mediaBadgeOf`). A fourth string is - // still cheaper than shipping the location list to every table. - locationLabel?: string; -}; +// component instead of serializing a whole location per channel. It is the +// channel row's own media field (common/views/channelRow.ts), which is where +// the in-place filter and the location label are decided, once. +export type MediaBadgeInput = ChannelRowMedia; export type MediaBadgeTone = "neutral" | "danger"; diff --git a/editor/app/components/actions/PrioritizeButton.tsx b/editor/app/components/actions/PrioritizeButton.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { prioritizeChannelDownloadAction } from "../../operations/actions"; + +// One-click "Top of queue": prepend this channel to the auto-download policy and +// start the runner (prioritizeChannelDownloadAction). +export function PrioritizeButton({ slug }: { slug: string }) { + const [pending, startTransition] = useTransition(); + const [done, setDone] = useState(false); + const [error, setError] = useState<string | null>(null); + + function handleClick() { + setError(null); + startTransition(async () => { + const res = await prioritizeChannelDownloadAction(slug); + if (res.ok) setDone(true); + else setError(res.error); + }); + } + + return ( + <span className="inline-flex items-center gap-1.5"> + <button + type="button" + onClick={handleClick} + disabled={pending || done} + aria-label={`prioritize ${slug} for download`} + title="Prepend to the auto-download queue and start the runner" + className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap" + > + {done ? "Queued ↑" : pending ? "…" : "Top of queue"} + </button> + {error && ( + <span role="alert" className="text-xs text-destructive"> + {error} + </span> + )} + </span> + ); +} diff --git a/editor/app/components/channelWork/ChannelWorkTable.tsx b/editor/app/components/channelWork/ChannelWorkTable.tsx @@ -1,22 +1,35 @@ -import Link from "next/link"; -import { - isStaleOrMissing, - type ActionableRow, - type ActionableSummary, +import type { + ActionableRow, + ActionableSummary, } from "../../lib/actionable/loadActionable"; +import { + buildChannelRowView, + neutralChannelPriority, + type ChannelRowView, +} from "yt-dlp-transcript-common/views/channelRow"; import { InlineActionButton } from "../actions/InlineActionButton"; +import { ChannelsTable } from "../../channels/components/ChannelsTable"; +import type { ChannelRowExtras } from "../../channels/components/channelColumns"; +// From the plain-data module, never the "use client" registry: a server +// component importing a client module's value gets a reference, not the array. +import { WORK_COLUMNS } from "../../channels/components/channelColumnPresets"; import type { SectionConfig } from "./sections"; // A server component on purpose: `SectionConfig.primaryAction` is a function // that returns an element, and a function cannot cross the server/client -// boundary as a prop. +// boundary as a prop. So this is the SHELL — section, heading, description, +// empty paragraph — and the rows are the shared channel table +// (channels/components/ChannelsTable.tsx) with the WORK_COLUMNS preset, fed +// each row's count, extra figure and actions as data and elements. // // THE ARIA CONTRACT BELOW IS LOAD-BEARING. `channel-work.spec`, // `cleanup-actionable.spec`, `incomplete-transcript.spec` and // `download-format-guard.spec` all locate by these exact strings: // <section aria-label="<id>">, <h2>{title}</h2>, // <p aria-label="<id> empty">, <tr aria-label="<id> row <slug>">, -// columns Slug / countLabel / [extra] / Last report / Last sync / Actions, +// columns Slug / countLabel / [extra] / Last report / Last sync / Actions +// (the report cell is the rack's: `report age for <slug>`, "stale" / +// "missing" in the warning colour), // and a `refresh report <slug>` InlineActionButton on every row. // // NEVER add `role="status"` in here. This renders on the RUNNER operation @@ -51,6 +64,27 @@ function Section({ config: SectionConfig; rows: ActionableRow[]; }) { + // The functions stay HERE, on the server: each row's count, extra figure and + // actions are computed now and cross to the shared (client) table as a + // number, a string and an element. + const rowExtras: Record<string, ChannelRowExtras> = {}; + for (const row of rows) { + rowExtras[row.channel.slug] = { + count: config.getCount(row), + extra: config.extraColumn?.getValue(row), + actions: ( + <span className="inline-flex items-center gap-3 flex-wrap"> + {config.primaryAction(row)} + <InlineActionButton + variant={{ kind: "refreshReport", slug: row.channel.slug }} + /> + </span> + ), + }; + } + const columns = config.extraColumn + ? WORK_COLUMNS + : WORK_COLUMNS.filter((id) => id !== "extra"); return ( <section aria-label={config.id} @@ -69,110 +103,37 @@ function Section({ {config.emptyLabel} </p> ) : ( - <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> - <table className="text-sm border-y md:border md:border-border border-border md:rounded-md md:overflow-hidden w-full"> - <thead className="bg-muted"> - <tr> - <th className="text-left font-medium px-3 py-2">Slug</th> - <th className="text-right font-medium px-3 py-2"> - {config.countLabel} - </th> - {config.extraColumn && ( - <th className="text-right font-medium px-3 py-2 whitespace-nowrap"> - {config.extraColumn.label} - </th> - )} - <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> - Last report - </th> - <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> - Last sync - </th> - <th className="text-right font-medium px-3 py-2">Actions</th> - </tr> - </thead> - <tbody> - {rows.map((row) => ( - <Row - key={row.channel.slug} - row={row} - count={config.getCount(row)} - extraValue={config.extraColumn?.getValue(row) ?? null} - primaryAction={config.primaryAction(row)} - sectionId={config.id} - /> - ))} - </tbody> - </table> - </div> + <ChannelsTable + rows={rows.map(workRowOf)} + columns={columns} + rowLabelPrefix={config.id} + rowExtras={rowExtras} + labels={{ + count: config.countLabel, + extra: config.extraColumn?.label, + report: "Last report", + }} + sort={false} + selection={false} + /> )} </section> ); } -function Row({ - row, - count, - extraValue, - primaryAction, - sectionId, -}: { - row: ActionableRow; - count: number; - extraValue: string | null; - primaryAction: React.ReactNode; - sectionId: string; -}) { - const { channel, snapshot } = row; - const lastSync = channel.config.lastSyncedAt; - const lastReport = snapshot?.generatedAt ?? null; - // The loader's own definition, which this used to carry a copy of. - const isStale = isStaleOrMissing(row); - return ( - <tr - aria-label={`${sectionId} row ${channel.slug}`} - className="border-t border-border" - > - <td className="px-3 py-2 font-mono"> - <Link - href={`/channels/${channel.slug}`} - className="underline hover:text-foreground" - > - {channel.slug} - </Link> - </td> - <td className="px-3 py-2 text-right tabular-nums">{count}</td> - {extraValue !== null && ( - <td className="px-3 py-2 text-right tabular-nums whitespace-nowrap text-muted-foreground"> - {extraValue} - </td> - )} - <td - className={`px-3 py-2 text-xs whitespace-nowrap ${ - isStale - ? "text-warning" - : "text-muted-foreground" - }`} - > - {lastReport ? ( - <time dateTime={lastReport}> - {new Date(lastReport).toLocaleString()} - </time> - ) : ( - "never" - )} - </td> - <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap"> - {lastSync ? new Date(lastSync).toLocaleString() : "never"} - </td> - <td className="px-3 py-2 text-right"> - <span className="inline-flex items-center justify-end gap-3 flex-wrap"> - {primaryAction} - <InlineActionButton - variant={{ kind: "refreshReport", slug: channel.slug }} - /> - </span> - </td> - </tr> - ); +// The shared row, for a table that draws no bands, no tier, no media badge and +// no Location column: those inputs are neutral rather than read. The report +// state is the builder's `reportStateOf` — the loader's own definition, which +// this table used to carry a copy of. +function workRowOf(row: ActionableRow): ChannelRowView { + return buildChannelRowView({ + slug: row.channel.slug, + config: row.channel.config, + snapshot: row.snapshot, + playlistCount: null, + bands: [], + priority: neutralChannelPriority(), + media: null, + volume: { id: "", label: "" }, + }); } diff --git a/editor/app/components/dashboard/ChannelsTable.tsx b/editor/app/components/dashboard/ChannelsTable.tsx @@ -1,192 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useState, useTransition } from "react"; -import { syncAction } from "../../channels/[slug]/pipelineActions"; -import { prioritizeChannelDownloadAction } from "../../operations/actions"; -import { InlineActionButton } from "../actions/InlineActionButton"; -import { fmtTime } from "../../widget/lib/relativeTime"; -import type { DashboardChannel } from "./types"; -import { MediaLocationBadge } from "../MediaLocationBadge"; - -// The enriched channels table: the plain slug/handling/videos list plus a -// relative "last sync" that ticks live, and per-row inline actions (Sync, -// Download missing, and a one-click "Top of queue" that prioritizes the channel -// for auto-download). -export function ChannelsTable({ - channels, - now, -}: { - channels: DashboardChannel[]; - now: number | null; -}) { - if (channels.length === 0) return null; - return ( - <section className="flex flex-col gap-3"> - <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> - Channels - </h2> - <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> - <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full"> - <thead className="bg-muted"> - <tr> - <th className="text-left font-medium px-3 py-2">Slug</th> - <th className="text-left font-medium px-3 py-2">Handling</th> - <th className="text-right font-medium px-3 py-2">Videos</th> - <th - className="text-right font-medium px-3 py-2 whitespace-nowrap" - title="Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it" - > - Digest to do - </th> - <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> - Last sync - </th> - <th className="text-left font-medium px-3 py-2">Actions</th> - </tr> - </thead> - <tbody> - {channels.map((c) => ( - <tr - key={c.slug} - aria-label={`channel row ${c.slug}`} - className="border-t border-border align-top" - > - <td className="px-3 py-2 font-mono"> - <span className="inline-flex items-center gap-1.5"> - <Link - href={`/channels/${c.slug}`} - className="underline underline-offset-2 hover:text-brand transition-colors" - > - {c.slug} - </Link> - <MediaLocationBadge media={c.media} compact /> - </span> - </td> - <td className="px-3 py-2">{c.handling}</td> - <td className="px-3 py-2 text-right tabular-nums"> - {c.videoCount} - </td> - {/* Muted: during the backfill this is nearly every video, so - it is a coverage readout rather than a call to action. */} - <td className="px-3 py-2 text-right tabular-nums text-xs text-muted-foreground"> - {c.digestReachable} - </td> - <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap tabular-nums"> - {c.lastSyncedAt == null - ? "never" - : (fmtTime(c.lastSyncedAt, now, false) ?? "…")} - </td> - <td className="px-3 py-2"> - <div className="flex flex-wrap items-center gap-1.5"> - {c.hasUrl ? ( - <> - <InlineSyncButton slug={c.slug} /> - {c.undownloaded > 0 && ( - <InlineActionButton - variant={{ kind: "downloadMissing", slug: c.slug }} - /> - )} - <PrioritizeButton slug={c.slug} /> - </> - ) : ( - <span className="text-xs text-muted-foreground"> - no URL - </span> - )} - </div> - </td> - </tr> - ))} - </tbody> - </table> - </div> - </section> - ); -} - -// Single-channel Sync: kicks the streaming sync and drains the stream so the job -// keeps running server-side while this row returns to idle. -function InlineSyncButton({ slug }: { slug: string }) { - const [status, setStatus] = useState<"idle" | "running" | "done" | "error">( - "idle", - ); - const [message, setMessage] = useState<string | null>(null); - - async function handleClick() { - setStatus("running"); - setMessage(null); - try { - const res = await syncAction(slug); - if (!res.ok) { - setStatus("error"); - setMessage(res.error); - return; - } - void res.stream.cancel(); - setStatus("done"); - } catch (e) { - setStatus("error"); - setMessage((e as Error).message); - } - } - - return ( - <span className="inline-flex items-center gap-1.5"> - <button - type="button" - onClick={handleClick} - disabled={status === "running"} - aria-label={`sync ${slug}`} - className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap" - > - {status === "running" ? "Syncing…" : "Sync"} - </button> - {status === "done" && ( - <span className="text-xs text-muted-foreground">queued</span> - )} - {status === "error" && message && ( - <span role="alert" className="text-xs text-destructive"> - {message} - </span> - )} - </span> - ); -} - -// One-click "Top of queue": prepend this channel to the auto-download policy and -// start the runner (prioritizeChannelDownloadAction). -function PrioritizeButton({ slug }: { slug: string }) { - const [pending, startTransition] = useTransition(); - const [done, setDone] = useState(false); - const [error, setError] = useState<string | null>(null); - - function handleClick() { - setError(null); - startTransition(async () => { - const res = await prioritizeChannelDownloadAction(slug); - if (res.ok) setDone(true); - else setError(res.error); - }); - } - - return ( - <span className="inline-flex items-center gap-1.5"> - <button - type="button" - onClick={handleClick} - disabled={pending || done} - aria-label={`prioritize ${slug} for download`} - title="Prepend to the auto-download queue and start the runner" - className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap" - > - {done ? "Queued ↑" : pending ? "…" : "Top of queue"} - </button> - {error && ( - <span role="alert" className="text-xs text-destructive"> - {error} - </span> - )} - </span> - ); -} diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx @@ -8,8 +8,15 @@ import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { PipelineBand } from "./PipelineBand"; import { NeedsWorkPanel } from "./NeedsWorkPanel"; import { QuickAddPanel } from "./QuickAddPanel"; -import { ChannelsTable } from "./ChannelsTable"; -import type { DashboardChannel } from "./types"; +import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; +import { ChannelsTable } from "../../channels/components/ChannelsTable"; +import { + DASHBOARD_COLUMNS, + type ChannelRowExtras, +} from "../../channels/components/channelColumns"; +import { ChannelSyncButton } from "../../channels/components/ChannelSyncButton"; +import { InlineActionButton } from "../actions/InlineActionButton"; +import { PrioritizeButton } from "../actions/PrioritizeButton"; // Poll cadences mirror the monitor widget: job/worker state moves by the second; // the worklist and sync/scheduler markers move by the minute, so they poll no @@ -22,7 +29,9 @@ export type DashboardCockpitInitial = { workers: WorkersPayload; actionable: WidgetActionablePayload; sync: WidgetSyncPayload; - channels: DashboardChannel[]; + // Built server-side in app/page.tsx and passed straight through — never into + // client state — so a global AutoRefresh re-seeds them live. + channels: ChannelRowView[]; changelogSource: string | null; }; @@ -78,7 +87,59 @@ export function DashboardCockpit({ <NeedsWorkPanel data={actionable} /> <QuickAddPanel changelogSource={initial.changelogSource} /> </div> - <ChannelsTable channels={initial.channels} now={now} /> + <DashboardChannels channels={initial.channels} now={now} /> </div> ); } + +// The dashboard's channels table: the shared channel table with six columns, +// a relative "last sync" that ticks live, and per-row inline actions (Sync, +// Download missing, and a one-click "Top of queue" that prioritizes the channel +// for auto-download). +function DashboardChannels({ + channels, + now, +}: { + channels: ChannelRowView[]; + now: number | null; +}) { + if (channels.length === 0) return null; + const rowExtras: Record<string, ChannelRowExtras> = {}; + for (const c of channels) { + rowExtras[c.slug] = { + actions: ( + <div className="flex flex-wrap items-center gap-1.5"> + {c.hasUrl ? ( + <> + <ChannelSyncButton slug={c.slug} /> + {c.work.undownloaded > 0 && ( + <InlineActionButton + variant={{ kind: "downloadMissing", slug: c.slug }} + /> + )} + <PrioritizeButton slug={c.slug} /> + </> + ) : ( + <span className="text-xs text-muted-foreground">no URL</span> + )} + </div> + ), + }; + } + return ( + <section className="flex flex-col gap-3"> + <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> + Channels + </h2> + <ChannelsTable + rows={channels} + columns={DASHBOARD_COLUMNS} + rowLabelPrefix="channel" + rowExtras={rowExtras} + sort={false} + selection={false} + now={now} + /> + </section> + ); +} diff --git a/editor/app/components/dashboard/types.ts b/editor/app/components/dashboard/types.ts @@ -1,30 +0,0 @@ -import type { MediaBadgeInput } from "../MediaLocationBadge"; - -// Enriched per-channel row for the dashboard cockpit's Channels table. Built -// server-side in app/page.tsx from the actionable summary and passed straight -// through (not into client state), so a global AutoRefresh re-seeds it live. -export type DashboardChannel = { - slug: string; - handling: string; - videoCount: number; - // Epoch ms of the channel's last sync, or null when it has never synced. - lastSyncedAt: number | null; - // Whether the channel has a URL — inline Sync / Download need one. - hasUrl: boolean; - undownloaded: number; - untranscribed: number; - // The digest band's reachable — what the lane could act on today; not - // blocked, not deferred. Missing, stale or part-done at the CURRENT identity, - // from the operation registry. The only per-channel number that makes corpus - // coverage legible while a multi-week sweep is running. - // - // Videos with no transcript are NOT in it: they are classified as waiting on - // transcription and counted separately. - digestReachable: number; - // Where this channel's media physically is, when that is not "in the channel - // directory". Null for an in-place channel — the overwhelming majority — so - // the badge marks only the rows whose other numbers may not be trustworthy: - // an unmounted drive reads as "nothing downloaded" to every count on this - // row. See app/components/MediaLocationBadge.tsx. - media: MediaBadgeInput | null; -}; diff --git a/editor/app/jobs/components/JobProgressBars.tsx b/editor/app/jobs/components/JobProgressBars.tsx @@ -1,30 +1,23 @@ "use client"; -import { useEffect, useState } from "react"; import { formatDuration } from "yt-dlp-transcript-common/lib/format"; +import { useNow } from "../../lib/usePolledPayload"; import type { JobProgressMetric, JobTaskKind, } from "yt-dlp-transcript-common/jobs/registry"; import type { JobRowTask, JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; -// The progress bars, shared by the card renderer (RunningJobsList) and the -// /jobs table. The task bar's aria-label and its `gap-0.5` wrapper are what the -// suite climbs from to read the elapsed timer, so they live in ONE place rather -// than being restated by whichever surface draws them. - -// Live wall-clock that re-renders once a second on the client. Starts null so -// the SSR and first client render agree (no hydration mismatch from Date.now()); -// the elapsed timers fill in right after mount. -function useNow(): number | null { - const [now, setNow] = useState<number | null>(null); - useEffect(() => { - setNow(Date.now()); - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); - }, []); - return now; -} +// The progress bars, drawn by every job row (./JobRow.tsx): the /jobs table, +// the card list, and — `compact` — the monitor widget and the in-flight lists. +// The task bar's aria-label and its `gap-0.5` wrapper are what the suite climbs +// from to read the elapsed timer, so they live in ONE place rather than being +// restated by whichever surface draws them. +// +// COMPACT KEEPS THE WIDGET'S GLYPHS. A pinned widget reads "↓ 5/10", "✎ id"; +// the full form reads "Downloads: 5 / 10", "Transcribing id", and the suite +// asserts the full form's names (`/Transcribing slowop/`), so the two label +// sets are both kept, each on its own variant. // Records over JobTaskKind, so adding a kind is a compile error here. const TASK_KIND_VERB: Record<JobTaskKind, string> = { @@ -43,12 +36,34 @@ const METRIC_FILL_BY_TASK: Record<JobTaskKind, string> = { relocate: "bg-info/70", }; -export function TaskProgressBar({ task }: { task: JobRowTask }) { +// The compact bar's glyph per task kind, and the probing glyph. +const TASK_KIND_GLYPH: Record<JobTaskKind, string> = { + download: "\u2193", + transcribe: "\u270e", + digest: "\u00b6", + backfill: "\u21ba", + // A right arrow: a relocate task is a channel's media moving to another disk. + relocate: "\u21e2", +}; + +export function TaskProgressBar({ + task, + compact = false, +}: { + task: JobRowTask; + compact?: boolean; +}) { // How long this task has been running. Null until mounted (see useNow); // formatDuration returns "" for 0, so the just-started case shows "0:00". const now = useNow(); const probing = task.phase === "probing"; - const verb = probing ? "Probing audio" : TASK_KIND_VERB[task.kind]; + const verb = compact + ? probing + ? "🔍" + : TASK_KIND_GLYPH[task.kind] + : probing + ? "Probing audio" + : TASK_KIND_VERB[task.kind]; // While probing, fill against the estimated probe duration (a distinct violet // "scanning" bar) rather than the frozen download fraction. yt-dlp is paused, // so the download fraction wouldn't advance anyway. Falls back to an @@ -83,17 +98,25 @@ export function TaskProgressBar({ task }: { task: JobRowTask }) { const meta = [ elapsed, hasFraction ? `${pct}%` : null, - probing ? "probing audio…" : task.detail, + probing ? (compact ? "probing" : "probing audio…") : task.detail, ] .filter(Boolean) .join(" · "); return ( - <div className="flex flex-col gap-0.5"> - <div className="flex items-baseline justify-between gap-2 text-xs"> + <div + className={ + compact + ? "flex flex-col gap-0.5 pl-2 border-l-2 border-border" + : "flex flex-col gap-0.5" + } + > + <div + className={`flex items-baseline justify-between gap-2 ${compact ? "text-[11px]" : "text-xs"}`} + > <span className="truncate"> <span className="text-muted-foreground">{verb} </span> <span className="font-mono">{task.label}</span> - {task.workerName && ( + {!compact && task.workerName && ( <span className="text-muted-foreground"> {" "} on <span className="font-mono">{task.workerName}</span> @@ -104,11 +127,11 @@ export function TaskProgressBar({ task }: { task: JobRowTask }) { </div> <div role="progressbar" - aria-valuemin={0} - aria-valuemax={100} + aria-valuemin={compact ? undefined : 0} + aria-valuemax={compact ? undefined : 100} aria-valuenow={hasFraction ? pct : undefined} - aria-label={`${verb} ${task.label}`} - className="relative h-1.5 w-full overflow-hidden rounded bg-muted" + aria-label={compact ? task.label : `${verb} ${task.label}`} + className={`relative ${compact ? "h-1" : "h-1.5"} w-full overflow-hidden rounded bg-muted`} > {hasFraction ? ( <div @@ -137,6 +160,15 @@ const METRIC_LABELS: Record<JobProgressMetric, string> = { scans: "Metadata scan", }; +// Per-metric glyph for the compact line ("↓ 5/10"). +const METRIC_PREFIX: Record<JobProgressMetric, string> = { + downloads: "\u2193 ", + transcripts: "", + digests: "\u00b6 ", + backfills: "\u21ba ", + scans: "\u2315 ", +}; + const METRIC_FILL: Record<JobProgressMetric, string> = { downloads: "bg-success/60", transcripts: "bg-success", @@ -145,11 +177,64 @@ const METRIC_FILL: Record<JobProgressMetric, string> = { scans: "bg-info/60", }; +// One-line textual summary of a job's batch progress, e.g. "↓ 5/10 · ~2m left" +// — the compact heading (headingProgress) and the compact bar's caption. +export function compactProgressText( + progress: NonNullable<JobRowView["progress"]>, + showEta: boolean, +): string { + const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`; + const etaText = compactEta(progress, showEta); + return etaText ? `${label} · ${etaText}` : label; +} + +function compactEta( + progress: NonNullable<JobRowView["progress"]>, + showEta: boolean, +): string | null { + const remaining = progress.target - progress.current; + return !showEta || remaining <= 0 + ? null + : typeof progress.etaSeconds === "number" + ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` + : "…"; +} + export function JobProgressBar({ progress, + compact = false, + showEta = true, }: { progress: NonNullable<JobRowView["progress"]>; + compact?: boolean; + // Compact only: the widget's `eta` switch. + showEta?: boolean; }) { + if (compact) { + const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`; + const etaText = compactEta(progress, showEta); + return ( + <div className="flex flex-col gap-0.5"> + <div + role="progressbar" + aria-valuemin={progress.initial} + aria-valuemax={progress.target} + aria-valuenow={progress.current} + aria-label={label} + className="relative h-1.5 w-full overflow-hidden rounded bg-muted" + > + <div + className="absolute inset-y-0 left-0 bg-success" + style={{ width: `${progress.pct}%` }} + /> + </div> + <div className="flex items-baseline justify-between gap-2 text-[11px] text-muted-foreground"> + <span>{label}</span> + {etaText && <span className="shrink-0">{etaText}</span>} + </div> + </div> + ); + } const label = `${METRIC_LABELS[progress.metric]}: ${progress.current} / ${progress.target}`; // Append an ETA once the batch has a measured average. formatDuration returns // "" for 0/falsy, so guard against printing a bare "·". diff --git a/editor/app/jobs/components/JobRow.tsx b/editor/app/jobs/components/JobRow.tsx @@ -0,0 +1,563 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; +import { formatDuration } from "yt-dlp-transcript-common/lib/format"; +import { JobLogTail } from "../[id]/components/JobLogTail"; +import { jobKindLabel } from "../jobKindLabels"; +import { formatElapsed } from "../../lib/formatElapsed"; +import { CancelJobButton } from "./CancelJobButton"; +import { DrainJobButton } from "./DrainJobButton"; +import { ForceReleaseJobButton } from "./ForceReleaseJobButton"; +import { ReorderJobButtons } from "./ReorderJobButtons"; +import { RetryJobButton } from "./RetryJobButton"; +import { + JobProgressBar, + TaskProgressBar, + compactProgressText, +} from "./JobProgressBars"; + +// ONE JOB, DRAWN ONE WAY — in three sizes. +// +// Every surface that shows a job in flight draws a `JobRowView` +// (common/views/jobRowView.ts) through this file: the /jobs table (`table`, a +// <tr>), the card list a page shows for its own jobs (`card`, an <li>), and +// the dense lines of the monitor widget and the operations board's in-flight +// list (`compact`, a <div>). The heading, the status colours, the action +// buttons and the progress bars are this file's, once; a variant decides only +// the container and which parts it `show`s. + +// Which parts a row draws. Every flag defaults per variant (see DEFAULTS). +export type JobRowShow = { + tasks: boolean; // the per-task progress bars + jobBar: boolean; // the batch progress bar + headingProgress: boolean; // compact: "↓ 5/10" in the heading line + eta: boolean; // compact: "~2m left" beside the batch figure + actions: boolean; // JobRowActions + links: boolean; // Job / channel / video links (compact: plain text when off) + log: boolean; // card: the Show log toggle + tail + elapsed: boolean; // compact: "3m07s" since startedAt, at the line's end + statusPill: boolean; // off where every row is running by construction +}; + +type Variant = "table" | "card" | "compact"; + +const DEFAULTS: Record<Variant, JobRowShow> = { + table: { + tasks: true, + jobBar: true, + headingProgress: false, + eta: true, + actions: true, + links: true, + log: false, + elapsed: false, + statusPill: true, + }, + card: { + tasks: true, + jobBar: true, + headingProgress: false, + eta: true, + actions: true, + links: true, + log: false, + elapsed: false, + statusPill: true, + }, + compact: { + tasks: true, + jobBar: false, + headingProgress: false, + eta: false, + actions: false, + links: false, + log: false, + elapsed: false, + statusPill: true, + }, +}; + +function fmtDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + const m = s / 60; + return `${m.toFixed(1)}m`; +} + +// "next in line" reads better than "1st"; past that, an ordinal. +function queuePlace(position: number): string { + if (position === 1) return "next in line"; + const rem100 = position % 100; + const rem10 = position % 10; + const suffix = + rem100 >= 11 && rem100 <= 13 + ? "th" + : rem10 === 1 + ? "st" + : rem10 === 2 + ? "nd" + : rem10 === 3 + ? "rd" + : "th"; + return `${position}${suffix} in line`; +} + +export function statusColor(status: string): string { + switch (status) { + case "queued": + return "bg-muted text-muted-foreground"; + case "running": + return "bg-info-soft text-info"; + case "done": + return "bg-success-soft text-success"; + case "failed": + return "bg-destructive-soft text-destructive"; + case "cancelled": + return "bg-warning-soft text-warning"; + default: + return "bg-muted text-muted-foreground"; + } +} + +// A row with a page at /jobs/<id>. A runner's in-flight unit that is a task +// on its runner's job (a transcription, a digest) is not a registry job, so it +// does not link to one; a download unit is, and carries its id. +function linksToJob(job: JobRowView): boolean { + return job.source !== "runner" || Boolean(job.inRegistry); +} + +function videoHref(job: JobRowView): string | null { + return job.channelSlug && job.videoId + ? `/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}` + : null; +} + +// THE ACTIONS, by status — one rule for every surface: Retry a failed +// replayable job, Reorder a queued one, Drain a drainable running one, Cancel +// anything live, and Force-release anything running or stuck (the escape hatch +// for a Cancel that leaves a queue wedged; see registry.forceRelease — a stuck +// terminal/evicted row has no Cancel to offer, so it gets this instead). +export function JobRowActions({ + job, + forceRelease = "running-or-stuck", +}: { + job: JobRowView; + // The lane line offers Force-release only on a STUCK runner: for a healthy + // one it does what Cancel beside it does, as a second, filled-red button. + forceRelease?: "running-or-stuck" | "stuck"; +}) { + return ( + <> + {job.status === "failed" && job.replayable && ( + <RetryJobButton jobId={job.id} /> + )} + {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && ( + <ReorderJobButtons + jobId={job.id} + canMoveUp={job.canMoveUp} + canMoveDown={job.canMoveDown} + /> + )} + {job.status === "running" && (job.drainable || job.draining) && ( + <DrainJobButton jobId={job.id} draining={job.draining} /> + )} + {(job.status === "running" || job.status === "queued") && ( + <CancelJobButton jobId={job.id} /> + )} + {((forceRelease === "running-or-stuck" && job.status === "running") || + Boolean(job.stuck)) && <ForceReleaseJobButton jobId={job.id} />} + </> + ); +} + +function AutoBadge({ compact }: { compact?: boolean }) { + return ( + <span + className={ + compact + ? "uppercase tracking-wide px-1 py-0.5 rounded text-[9px] bg-warning-soft text-warning" + : "text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning" + } + title="Background work — yields to a manually-triggered sync/download on the same platform" + > + auto + </span> + ); +} + +// THE HEADING: status pill · kind · detail · auto badge · links · queue key. +// "Fetch window" alone is the least useful row on a page — every window fetch +// has that kind — so WHO ASKED (`detail`) sits beside the kind. +function JobRowHeading({ + job, + compact = false, + links = true, + hideChannelSlug, + hideVideoId, + headingProgress = false, + eta = false, + statusPill = true, +}: { + statusPill?: boolean; + job: JobRowView; + compact?: boolean; + links?: boolean; + hideChannelSlug?: boolean; + hideVideoId?: boolean; + headingProgress?: boolean; + eta?: boolean; +}) { + const linkClass = "font-mono text-xs underline hover:text-foreground"; + const video = videoHref(job); + return ( + <> + {statusPill && ( + <span + className={ + compact + ? `uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusColor(job.status)}` + : `text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(job.status)}` + } + > + {job.status} + </span> + )} + <span + className={compact ? "shrink-0 font-mono" : "text-xs font-medium"} + title={job.kind} + > + {job.kind ? jobKindLabel(job.kind) : "—"} + </span> + {job.detail && + (compact ? ( + // One line, always: a pinned widget and an in-flight list are + // narrow, and "rule N · <leaf sentence>" would wrap into a paragraph. + <span + className="min-w-0 truncate text-xs text-muted-foreground" + title={job.detail} + > + · {job.detail} + </span> + ) : ( + <span className="text-xs text-muted-foreground">· {job.detail}</span> + ))} + {job.background && <AutoBadge compact={compact} />} + {links && linksToJob(job) && ( + <Link href={`/jobs/${job.id}`} className={linkClass}> + Job {job.id} + </Link> + )} + {!hideChannelSlug && + job.channelSlug && + (links ? ( + <Link href={`/channels/${job.channelSlug}`} className={linkClass}> + {job.channelSlug} + </Link> + ) : ( + <span className="font-mono text-muted-foreground truncate max-w-[12rem]"> + {job.channelSlug} + </span> + ))} + {links && !hideVideoId && video && ( + <Link href={video} className={linkClass}> + {job.videoId} + </Link> + )} + {headingProgress && job.progress && ( + <span className="font-mono text-muted-foreground shrink-0"> + {compactProgressText(job.progress, eta)} + </span> + )} + {!compact && job.status === "queued" && ( + <span className="text-xs text-muted-foreground"> + queue <code className="font-mono">{job.queueKey || "—"}</code> + {job.background && " · waiting behind a manual job"} + </span> + )} + </> + ); +} + +function TaskList({ job, compact }: { job: JobRowView; compact?: boolean }) { + if (!job.tasks || job.tasks.length === 0) return null; + if (compact) + return ( + <> + {job.tasks.map((t) => ( + <TaskProgressBar key={t.id} task={t} compact /> + ))} + </> + ); + return ( + <ul + aria-label="In-progress operations" + className="flex flex-col gap-1.5 pl-2 border-l-2 border-border" + > + {job.tasks.map((t) => ( + <li key={t.id}> + <TaskProgressBar task={t} /> + </li> + ))} + </ul> + ); +} + +export function JobRow({ + job, + variant, + hideChannelSlug, + hideVideoId, + show: showPatch, + live = false, + now = null, +}: { + job: JobRowView; + variant: Variant; + hideChannelSlug?: boolean; + hideVideoId?: boolean; + show?: Partial<JobRowShow>; + // table: the row came from the live payload (SSR seed or poll) — `data-live`. + live?: boolean; + // The ticking clock (useNow), for elapsed and running durations. null until + // mounted; the table falls back to Date.now() behind suppressHydrationWarning, + // as it always has. + now?: number | null; +}) { + const show = { ...DEFAULTS[variant], ...showPatch }; + if (variant === "table") return <TableRow job={job} live={live} now={now} />; + if (variant === "card") + return ( + <CardRow + job={job} + show={show} + hideChannelSlug={hideChannelSlug} + hideVideoId={hideVideoId} + /> + ); + return ( + <div className="flex flex-col gap-1"> + <div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-xs"> + <JobRowHeading + job={job} + compact + statusPill={show.statusPill} + links={show.links} + hideChannelSlug={hideChannelSlug} + hideVideoId={hideVideoId} + headingProgress={show.headingProgress} + eta={show.eta} + /> + {(show.actions || show.elapsed) && ( + <span className="ml-auto flex items-center gap-2"> + {show.actions && <JobRowActions job={job} />} + {show.elapsed && ( + <span className="tabular-nums text-xs text-muted-foreground"> + {now !== null && typeof job.startedAt === "number" + ? formatElapsed(now - job.startedAt) + : "—"} + </span> + )} + </span> + )} + </div> + {show.jobBar && job.progress && ( + <JobProgressBar progress={job.progress} compact showEta={show.eta} /> + )} + {show.tasks && <TaskList job={job} compact />} + </div> + ); +} + +// THE CARD: a page's own jobs (channel, video, build, operation pages). +function CardRow({ + job, + show, + hideChannelSlug, + hideVideoId, +}: { + job: JobRowView; + show: JobRowShow; + hideChannelSlug?: boolean; + hideVideoId?: boolean; +}) { + const [showLog, setShowLog] = useState(false); + return ( + <li className="flex flex-col gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0"> + <div className="flex flex-wrap items-center gap-2 text-sm"> + <JobRowHeading + job={job} + links={show.links} + hideChannelSlug={hideChannelSlug} + hideVideoId={hideVideoId} + /> + {(show.actions || show.log) && ( + <div className="ml-auto flex items-center gap-2"> + {show.actions && <JobRowActions job={job} />} + {show.log && ( + <button + type="button" + onClick={() => setShowLog((s) => !s)} + aria-expanded={showLog} + aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`} + className="px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90" + > + {showLog ? "Hide log" : "Show log"} + </button> + )} + </div> + )} + </div> + {show.jobBar && job.progress && ( + <JobProgressBar progress={job.progress} /> + )} + {show.tasks && <TaskList job={job} />} + {showLog && <JobLogTail jobId={job.id} initiallyRunning />} + </li> + ); +} + +// THE TABLE ROW: /jobs, one <tr> per job, live head and history tail alike. +function TableRow({ + job: j, + live, + now, +}: { + job: JobRowView; + live: boolean; + now: number | null; +}) { + const start = j.startedAt ?? j.queuedAt; + const dur = j.endedAt + ? j.endedAt - (start ?? j.endedAt) + : j.startedAt + ? (now ?? Date.now()) - j.startedAt + : 0; + const video = videoHref(j); + return ( + <tr + // The kind cell renders a human label (jobKindLabel), so the machine + // kind is not matchable from the row text. Expose it here as well as on + // the cell's title: asserting on the label text is what rotted 9 e2e + // tests when kinds gained labels. + data-kind={j.kind || undefined} + data-job-id={j.id} + // Came from the live payload — the SSR seed or the poll. + data-live={live ? "" : undefined} + data-stuck={j.stuck?.reason} + className="border-t border-border" + > + <td className="px-3 py-2 font-mono text-xs align-top"> + <Link + href={`/jobs/${j.id}`} + className="underline hover:text-foreground" + > + {j.id} + </Link> + </td> + <td className="px-3 py-2 align-top" title={j.kind || undefined}> + <span className="flex items-center gap-1.5"> + {j.kind ? jobKindLabel(j.kind) : "—"} + {j.detail && ( + <span className="text-xs text-muted-foreground">· {j.detail}</span> + )} + {j.background && <AutoBadge />} + </span> + </td> + <td className="px-3 py-2 font-mono text-xs align-top"> + {j.channelSlug ? ( + <Link + href={`/channels/${j.channelSlug}`} + className="underline hover:text-foreground" + > + {j.channelSlug} + </Link> + ) : ( + "—" + )} + </td> + <td className="px-3 py-2 font-mono text-xs align-top"> + {video ? ( + <Link href={video} className="underline hover:text-foreground"> + {j.videoId} + </Link> + ) : ( + "—" + )} + </td> + <td className="px-3 py-2 font-mono text-xs align-top"> + {j.queueKey === undefined + ? "—" + : j.queueKey === "" + ? "(immediate)" + : j.queueKey} + {j.status === "queued" && + typeof j.position === "number" && + j.position >= 1 && ( + <span className="text-muted-foreground"> + {" "} + · {queuePlace(j.position)} + </span> + )} + </td> + <td className="px-3 py-2 align-top min-w-56"> + {/* gap-1, deliberately NOT gap-0.5: the suite climbs from a task's + progressbar to its nearest gap-0.5 ancestor to read the elapsed + timer, and that ancestor must stay the task's own wrapper. */} + <div className="flex flex-col gap-1"> + <span + className={`self-start text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`} + > + {j.status} + </span> + {j.stuck && ( + <span + data-stuck={j.stuck.reason} + title={j.pid ? `pid ${j.pid} — kill -9 ${j.pid}` : undefined} + className="self-start text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-destructive-soft text-destructive" + > + stuck · {j.stuck.reason} + </span> + )} + {j.progress && <JobProgressBar progress={j.progress} />} + <TaskList job={j} /> + </div> + </td> + <td + className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top" + suppressHydrationWarning + > + {start === undefined ? "—" : new Date(start).toLocaleString()} + </td> + <td + className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top" + suppressHydrationWarning + > + {j.startedAt === undefined && typeof j.heldMs === "number" + ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}` + : fmtDuration(dur)} + </td> + <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top"> + {j.stuck && j.lastLogLine ? ( + <span + className="font-mono text-xs text-muted-foreground truncate max-w-64 block" + title={j.lastLogLine} + > + {j.lastLogLine} + </span> + ) : typeof j.logSize === "number" ? ( + `${j.logSize.toLocaleString()} B` + ) : ( + "—" + )} + </td> + <td className="px-3 py-2 text-right align-top"> + <div className="flex items-center justify-end gap-2"> + <JobRowActions job={j} /> + </div> + </td> + </tr> + ); +} diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx @@ -1,21 +1,14 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import { usePolledPayload } from "../../lib/usePolledPayload"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { isLive, mergeJobRows } from "yt-dlp-transcript-common/views/jobRows"; -import { CancelJobButton } from "./CancelJobButton"; -import { DrainJobButton } from "./DrainJobButton"; -import { ForceReleaseJobButton } from "./ForceReleaseJobButton"; -import { ReorderJobButtons } from "./ReorderJobButtons"; -import { RetryJobButton } from "./RetryJobButton"; import { ReapStuckButton } from "./ReapStuckButton"; import { LaneStrip } from "./LaneStrip"; -import { JobProgressBar, TaskProgressBar } from "./JobProgressBars"; +import { JobRow, statusColor } from "./JobRow"; import { jobKindLabel } from "../jobKindLabels"; -import { formatDuration } from "yt-dlp-transcript-common/lib/format"; import { clearJobsFilters, defaultJobsFilters, @@ -28,32 +21,6 @@ import { // per-task progress bars still advance smoothly. const POLL_MS = 1000; -function fmtDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const s = ms / 1000; - if (s < 60) return `${s.toFixed(1)}s`; - const m = s / 60; - return `${m.toFixed(1)}m`; -} - -// "next in line" reads better than "1st"; past that, an ordinal. -function queuePlace(position: number): string { - if (position === 1) return "next in line"; - const rem100 = position % 100; - const rem10 = position % 10; - const suffix = - rem100 >= 11 && rem100 <= 13 - ? "th" - : rem10 === 1 - ? "st" - : rem10 === 2 - ? "nd" - : rem10 === 3 - ? "rd" - : "th"; - return `${position}${suffix} in line`; -} - // One figure on the health line. `data-stat` is the hook; the label text is the // contract (the suite matches "stuck" and "active queues" exactly). function healthStat( @@ -76,23 +43,6 @@ function healthStat( ); } -function statusColor(status: string): string { - switch (status) { - case "queued": - return "bg-muted text-muted-foreground"; - case "running": - return "bg-info-soft text-info"; - case "done": - return "bg-success-soft text-success"; - case "failed": - return "bg-destructive-soft text-destructive"; - case "cancelled": - return "bg-warning-soft text-warning"; - default: - return "bg-muted text-muted-foreground"; - } -} - // ONE LIST. // // The live head is what the registry and the scheduler say NOW, polled at 1 s @@ -382,189 +332,14 @@ export function JobsTable({ </tr> </thead> <tbody> - {visible.map((j) => { - const start = j.startedAt ?? j.queuedAt; - const dur = j.endedAt - ? j.endedAt - (start ?? j.endedAt) - : j.startedAt - ? Date.now() - j.startedAt - : 0; - return ( - <tr - key={j.id} - // The kind cell renders a human label (jobKindLabel), so the - // machine kind is not matchable from the row text. Expose it - // here as well as on the cell's title: asserting on the label - // text is what rotted 9 e2e tests when kinds gained labels. - data-kind={j.kind || undefined} - data-job-id={j.id} - // Came from the live payload — the SSR seed or the poll. - data-live={liveIds.has(j.id) ? "" : undefined} - data-stuck={j.stuck?.reason} - className="border-t border-border" - > - <td className="px-3 py-2 font-mono text-xs align-top"> - <Link - href={`/jobs/${j.id}`} - className="underline hover:text-foreground" - > - {j.id} - </Link> - </td> - <td className="px-3 py-2 align-top" title={j.kind || undefined}> - <span className="flex items-center gap-1.5"> - {j.kind ? jobKindLabel(j.kind) : "—"} - {/* See RunningJobsList: the kind alone cannot say which - tool asked for which clip. */} - {j.detail && ( - <span className="text-xs text-muted-foreground"> - · {j.detail} - </span> - )} - {j.background && ( - <span - className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning" - title="Background work — yields to a manually-triggered sync/download on the same platform" - > - auto - </span> - )} - </span> - </td> - <td className="px-3 py-2 font-mono text-xs align-top"> - {j.channelSlug ? ( - <Link - href={`/channels/${j.channelSlug}`} - className="underline hover:text-foreground" - > - {j.channelSlug} - </Link> - ) : ( - "—" - )} - </td> - <td className="px-3 py-2 font-mono text-xs align-top"> - {j.channelSlug && j.videoId ? ( - <Link - href={`/channels/${j.channelSlug}/videos/${encodeURIComponent(j.videoId)}`} - className="underline hover:text-foreground" - > - {j.videoId} - </Link> - ) : ( - "—" - )} - </td> - <td className="px-3 py-2 font-mono text-xs align-top"> - {j.queueKey === undefined - ? "—" - : j.queueKey === "" - ? "(immediate)" - : j.queueKey} - {j.status === "queued" && - typeof j.position === "number" && - j.position >= 1 && ( - <span className="text-muted-foreground"> - {" "} - · {queuePlace(j.position)} - </span> - )} - </td> - <td className="px-3 py-2 align-top min-w-56"> - {/* gap-1, deliberately NOT gap-0.5: the suite climbs from a - task's progressbar to its nearest gap-0.5 ancestor to - read the elapsed timer, and that ancestor must stay the - task's own wrapper. */} - <div className="flex flex-col gap-1"> - <span - className={`self-start text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`} - > - {j.status} - </span> - {j.stuck && ( - <span - data-stuck={j.stuck.reason} - title={ - j.pid ? `pid ${j.pid} — kill -9 ${j.pid}` : undefined - } - className="self-start text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-destructive-soft text-destructive" - > - stuck · {j.stuck.reason} - </span> - )} - {j.progress && <JobProgressBar progress={j.progress} />} - {j.tasks && j.tasks.length > 0 && ( - <ul - aria-label="In-progress operations" - className="flex flex-col gap-1.5 pl-2 border-l-2 border-border" - > - {j.tasks.map((t) => ( - <li key={t.id}> - <TaskProgressBar task={t} /> - </li> - ))} - </ul> - )} - </div> - </td> - <td - className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top" - suppressHydrationWarning - > - {start === undefined ? "—" : new Date(start).toLocaleString()} - </td> - <td - className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top" - suppressHydrationWarning - > - {j.startedAt === undefined && typeof j.heldMs === "number" - ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}` - : fmtDuration(dur)} - </td> - <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top"> - {j.stuck && j.lastLogLine ? ( - <span - className="font-mono text-xs text-muted-foreground truncate max-w-64 block" - title={j.lastLogLine} - > - {j.lastLogLine} - </span> - ) : typeof j.logSize === "number" ? ( - `${j.logSize.toLocaleString()} B` - ) : ( - "—" - )} - </td> - <td className="px-3 py-2 text-right align-top"> - <div className="flex items-center justify-end gap-2"> - {j.status === "failed" && j.replayable && ( - <RetryJobButton jobId={j.id} /> - )} - {j.status === "queued" && (j.canMoveUp || j.canMoveDown) && ( - <ReorderJobButtons - jobId={j.id} - canMoveUp={j.canMoveUp} - canMoveDown={j.canMoveDown} - /> - )} - {j.status === "running" && (j.drainable || j.draining) && ( - <DrainJobButton jobId={j.id} draining={j.draining} /> - )} - {(j.status === "running" || j.status === "queued") && ( - <CancelJobButton jobId={j.id} /> - )} - {(j.status === "running" || Boolean(j.stuck)) && ( - // Escape hatch: force-free the scheduler slot for any - // running job, for when a Cancel leaves the queue wedged - // (see registry.forceRelease). A stuck terminal/evicted - // row has no Cancel to offer, so it gets this instead. - <ForceReleaseJobButton jobId={j.id} /> - )} - </div> - </td> - </tr> - ); - })} + {visible.map((j) => ( + <JobRow + key={j.id} + job={j} + variant="table" + live={liveIds.has(j.id)} + /> + ))} </tbody> </table> </div> diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx @@ -4,8 +4,7 @@ import Link from "next/link"; import type { ActiveLaneView } from "yt-dlp-transcript-common/views/activeJobs"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { LANE_DOT, LANE_TEXT, LANE_WORD } from "yt-dlp-transcript-common/views/laneState"; -import { DrainJobButton } from "./DrainJobButton"; -import { CancelJobButton } from "./CancelJobButton"; +import { JobRowActions } from "./JobRow"; // THE LANE STRIP. One line per pipeline, in place of one card per runner. // @@ -14,6 +13,11 @@ import { CancelJobButton } from "./CancelJobButton"; // a line instead of a card, and that it now says WHY it is not working. Four // bordered cards saying "running" above the work with the progress bars is the // layout this replaces. +// +// A LANE IS NOT A JOB, so this is not a JobRow: it keeps its own line (the +// suite pins the section, the heading and the Drain/Cancel on it). What it +// shares with every job row is the actions — its runner job's buttons are +// JobRowActions, the same status rules as /jobs and the card list. export function LaneStrip({ lanes, jobs, @@ -77,10 +81,7 @@ function LaneRow({ > log </Link> - {job.drainable && ( - <DrainJobButton jobId={job.id} draining={job.draining} /> - )} - <CancelJobButton jobId={job.id} /> + <JobRowActions job={job} forceRelease="stuck" /> </span> )} </section> diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx @@ -1,15 +1,7 @@ "use client"; -import Link from "next/link"; -import { useState } from "react"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; -import { JobLogTail } from "../[id]/components/JobLogTail"; -import { jobKindLabel } from "../jobKindLabels"; -import { DrainJobButton } from "./DrainJobButton"; -import { CancelJobButton } from "./CancelJobButton"; -import { ForceReleaseJobButton } from "./ForceReleaseJobButton"; -import { ReorderJobButtons } from "./ReorderJobButtons"; -import { JobProgressBar, TaskProgressBar } from "./JobProgressBars"; +import { JobRow } from "./JobRow"; type Props = { jobs: JobRowView[]; @@ -17,9 +9,9 @@ type Props = { hideVideoId?: boolean; }; -// THE CARD RENDERER for a page's own jobs (channel, video, build, operation -// pages). /jobs draws the same JobRowView as a table row; both read the one -// type, and both get their rows from the one builder (liveJobRows). +// THE CARD LIST for a page's own jobs (channel, video, build, operation +// pages). /jobs draws the same JobRowView as a table row; both are ./JobRow.tsx, +// and both get their rows from the one builder (liveJobRows). export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) { if (jobs.length === 0) return null; return ( @@ -35,6 +27,8 @@ export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) { <JobRow key={j.id} job={j} + variant="card" + show={{ log: true }} hideChannelSlug={hideChannelSlug} hideVideoId={hideVideoId} /> @@ -43,116 +37,3 @@ export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) { </section> ); } - -function JobRow({ - job, - hideChannelSlug, - hideVideoId, -}: { - job: JobRowView; - hideChannelSlug?: boolean; - hideVideoId?: boolean; -}) { - const [showLog, setShowLog] = useState(false); - const statusClass = - job.status === "running" - ? "bg-info-soft text-info" - : "bg-muted text-muted-foreground"; - return ( - <li className="flex flex-col gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0"> - <div className="flex flex-wrap items-center gap-2 text-sm"> - <span - className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusClass}`} - > - {job.status} - </span> - <span className="text-xs font-medium" title={job.kind}> - {jobKindLabel(job.kind)} - </span> - {/* WHO ASKED, beside the kind. "Fetch window" alone is the least - useful row on the page: every window fetch has that kind, and the - question is which tool wanted which clip. */} - {job.detail && ( - <span className="text-xs text-muted-foreground">· {job.detail}</span> - )} - {job.background && ( - <span - className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning" - title="Background work — yields to a manually-triggered sync/download on the same platform" - > - auto - </span> - )} - <Link - href={`/jobs/${job.id}`} - className="font-mono text-xs underline hover:text-foreground" - > - Job {job.id} - </Link> - {!hideChannelSlug && job.channelSlug && ( - <Link - href={`/channels/${job.channelSlug}`} - className="font-mono text-xs underline hover:text-foreground" - > - {job.channelSlug} - </Link> - )} - {!hideVideoId && job.channelSlug && job.videoId && ( - <Link - href={`/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}`} - className="font-mono text-xs underline hover:text-foreground" - > - {job.videoId} - </Link> - )} - {job.status === "queued" && ( - <span className="text-xs text-muted-foreground"> - queue <code className="font-mono">{job.queueKey || "—"}</code> - {job.background && " · waiting behind a manual job"} - </span> - )} - <div className="ml-auto flex items-center gap-2"> - {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && ( - <ReorderJobButtons - jobId={job.id} - canMoveUp={job.canMoveUp} - canMoveDown={job.canMoveDown} - /> - )} - {job.status === "running" && (job.drainable || job.draining) && ( - <DrainJobButton jobId={job.id} draining={job.draining} /> - )} - <CancelJobButton jobId={job.id} /> - {job.status === "running" && ( - // Escape hatch: force-free the scheduler slot for any running job, - // for when a Cancel leaves the queue wedged (see registry.forceRelease). - <ForceReleaseJobButton jobId={job.id} /> - )} - <button - type="button" - onClick={() => setShowLog((s) => !s)} - aria-expanded={showLog} - aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`} - className="px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90" - > - {showLog ? "Hide log" : "Show log"} - </button> - </div> - </div> - {job.progress && <JobProgressBar progress={job.progress} />} - {job.tasks && job.tasks.length > 0 && ( - <ul - aria-label="In-progress operations" - className="flex flex-col gap-1.5 pl-2 border-l-2 border-border" - > - {job.tasks.map((t) => ( - <li key={t.id}> - <TaskProgressBar task={t} /> - </li> - ))} - </ul> - )} - {showLog && <JobLogTail jobId={job.id} initiallyRunning />} - </li> - ); -} diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts @@ -3,11 +3,21 @@ import { cache } from "react"; import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels"; import type { WidgetActionableChannel } from "yt-dlp-transcript-common/views/widgetActionable"; import { getChannelBriefs } from "../requestCache"; +import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { - digestWorkOf, - excludedDownloadIdSet, - type ChannelSnapshot, -} from "yt-dlp-transcript-common/controller/channelSnapshot"; + cleanExtraFormatsBytesOf, + cleanExtraFormatsCountOf, + cleanTranscribedBytesOf, + cleanTranscribedCountOf, + digestReachableCountOf, + digestWarningsCountOf, + incompleteTranscriptCountOf, + metadataScanCountOf, + missingNeverFetchedCountOf, + shortAudioCountOf, + undownloadedCountOf, + untranscribedCountOf, +} from "yt-dlp-transcript-common/views/actionableCounts"; export type ActionableRow = { channel: ChannelBrief; @@ -29,128 +39,36 @@ export type ActionableSummary = { digestWarnings: ActionableRow[]; }; -// How old a channel's report is, in the three states /channels draws: -// "missing" (never generated), "stale" (older than the last sync — so every -// count read off it may be wrong) and "current". -// -// Takes a brief rather than an ActionableRow because /channels holds briefs and -// the two carry the same two fields; `isStaleOrMissing` is this function with -// the two non-current states collapsed. -export function reportStateOf( - brief: Pick<ChannelBrief, "config" | "snapshot">, -): "current" | "stale" | "missing" { - if (!brief.snapshot) return "missing"; - const synced = brief.config.lastSyncedAt; - if (!synced) return "current"; - return new Date(synced).getTime() > - new Date(brief.snapshot.generatedAt).getTime() - ? "stale" - : "current"; -} - -export function isStaleOrMissing(row: ActionableRow): boolean { - return reportStateOf(row.channel) !== "current"; -} - -// Counts that drive the actionable lists exclude IDs that the availability -// check has flagged as deleted / members-only / private — those videos -// can't be acted on, so they shouldn't inflate "needs attention" totals. -// `undownloadedIds` is already filtered at snapshot generation time, but we -// apply the filter again so a stale snapshot can't surface excluded IDs. -function countActionable( - snapshot: ChannelSnapshot | null | undefined, - ids: readonly string[] | undefined, -): number { - if (!snapshot || !ids) return 0; - const excluded = excludedDownloadIdSet(snapshot); - if (excluded.size === 0) return ids.length; - let n = 0; - for (const id of ids) if (!excluded.has(id)) n++; - return n; -} - -export function actionableUndownloadedCount(row: ActionableRow): number { - return countActionable(row.snapshot, row.snapshot?.undownloadedIds); -} - -// Videos the roster says we were told about, never downloaded, and that have -// since left the listing. Deliberately NOT run through countActionable: the -// availability exclusions are keyed on videos we have on disk, and these have no -// dir at all. Default 0 for snapshots written before the bucket existed. -export function actionableMissingNeverFetchedCount(row: ActionableRow): number { - return row.snapshot?.missingNeverFetched?.length ?? 0; -} - -// Listed videos the metadata scan has neither read nor recently failed on — -// the scan operation's backlog, counted at snapshot-generation time so this -// costs no extra read. Deliberately NOT run through countActionable: these -// videos have no directory, so the availability exclusions (which are keyed on -// what is on disk) cannot say anything about them. Default 0 for snapshots -// written before the field existed. -export function actionableMetadataScanCount(row: ActionableRow): number { - return row.snapshot?.metadataScan?.unscanned ?? 0; -} - -export function actionableUntranscribedCount(row: ActionableRow): number { - return countActionable( - row.snapshot, - row.snapshot?.buckets.downloadedNoTranscript, - ); -} - -// Transcribed videos whose transcript is badly truncated (the audio download -// stopped early). Default 0 for snapshots written before the bucket existed. -export function actionableIncompleteTranscriptCount(row: ActionableRow): number { - return row.snapshot?.buckets.incompleteTranscript?.length ?? 0; -} - -// Downloads the duration guard flagged as truncated at the source (short audio -// kept on disk, not transcribed). Default 0 for snapshots predating the bucket. -export function actionableShortAudioCount(row: ActionableRow): number { - return row.snapshot?.buckets.shortAudio?.length ?? 0; -} - -// Cleanup buckets are filtered by "do not clean" at snapshot-generation time, -// so the length is the actionable count directly (default undefined → 0 for -// snapshots written before the bucket existed). -export function actionableCleanTranscribedCount(row: ActionableRow): number { - return row.snapshot?.buckets.transcribedWithAudio?.length ?? 0; -} - -export function actionableCleanExtraFormatsCount(row: ActionableRow): number { - return row.snapshot?.buckets.multipleAudioFormats?.length ?? 0; -} - -// The digest layer's two work lists. The first is "has no digest at the current -// identity" — missing, stale or part-done. `digestWarnings` is "the model -// produced something a human should look at", which includes the total failures -// that write no section and so are invisible to any count of files. -// -// Read through digestWorkOf: the registry's classification is the one the runner -// uses, and it excludes videos with no transcript (blocked) and videos whose -// cues.json is stale (deferred) — work the old `noDigest` bucket offered here -// and the runner then declined. -// -// This IS the digest band's `reachable`, per channel: buildBands.ts:63 folds -// the same call. The name says so now, so nobody re-sources a number that was -// already the right one. -export function actionableDigestReachableCount(row: ActionableRow): number { - return digestWorkOf(row.snapshot).reachable; -} - -export function actionableDigestWarningsCount(row: ActionableRow): number { - return row.snapshot?.buckets.digestWarnings?.length ?? 0; -} - -// Estimated bytes each cleanup would reclaim (default 0 for snapshots written -// before cleanupBytes existed). -export function actionableCleanTranscribedBytes(row: ActionableRow): number { - return row.snapshot?.cleanupBytes?.transcribedWithAudio ?? 0; -} - -export function actionableCleanExtraFormatsBytes(row: ActionableRow): number { - return row.snapshot?.cleanupBytes?.multipleAudioFormats ?? 0; -} +// THE COUNT HELPERS, as wrappers. The counting itself is +// common/views/actionableCounts.ts — pure functions over one snapshot — which +// the channel-row builder calls too, so a dashboard cell and this census cannot +// count a channel two ways. See that module for what each one excludes. +export const actionableUndownloadedCount = (row: ActionableRow): number => + undownloadedCountOf(row.snapshot); +export const actionableMissingNeverFetchedCount = (row: ActionableRow): number => + missingNeverFetchedCountOf(row.snapshot); +export const actionableMetadataScanCount = (row: ActionableRow): number => + metadataScanCountOf(row.snapshot); +export const actionableUntranscribedCount = (row: ActionableRow): number => + untranscribedCountOf(row.snapshot); +export const actionableIncompleteTranscriptCount = (row: ActionableRow): number => + incompleteTranscriptCountOf(row.snapshot); +export const actionableShortAudioCount = (row: ActionableRow): number => + shortAudioCountOf(row.snapshot); +export const actionableCleanTranscribedCount = (row: ActionableRow): number => + cleanTranscribedCountOf(row.snapshot); +export const actionableCleanExtraFormatsCount = (row: ActionableRow): number => + cleanExtraFormatsCountOf(row.snapshot); +// This IS the digest band's `reachable`, per channel: buildBands.ts folds the +// same call. +export const actionableDigestReachableCount = (row: ActionableRow): number => + digestReachableCountOf(row.snapshot); +export const actionableDigestWarningsCount = (row: ActionableRow): number => + digestWarningsCountOf(row.snapshot); +export const actionableCleanTranscribedBytes = (row: ActionableRow): number => + cleanTranscribedBytesOf(row.snapshot); +export const actionableCleanExtraFormatsBytes = (row: ActionableRow): number => + cleanExtraFormatsBytesOf(row.snapshot); // The census rows as the widget's "Needs work" strip counts them: one row per // channel, through the three count helpers above. The filter and the sort are diff --git a/editor/app/lib/formatElapsed.ts b/editor/app/lib/formatElapsed.ts @@ -0,0 +1,14 @@ +// A running clock, compactly: "42s", "3m07s", "2h05m". The operations board's +// runner header and every in-flight row read it. (Not formatDuration's +// "3:07": that one is a media duration, and it renders 0 as "".) +export function formatElapsed(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) { + const rem = s % 60; + return rem === 0 ? `${m}m` : `${m}m${String(rem).padStart(2, "0")}s`; + } + const h = Math.floor(m / 60); + return `${h}h${String(m % 60).padStart(2, "0")}m`; +} diff --git a/editor/app/operations/components/InFlightList.tsx b/editor/app/operations/components/InFlightList.tsx @@ -1,12 +1,32 @@ "use client"; -import Link from "next/link"; import type { AutoQueueKindStatus } from "yt-dlp-transcript-common/views/autoQueueStatus"; -import { type Channel, formatElapsed, leafOrder, leafSentence } from "./dispatch"; +import { fromInFlight } from "yt-dlp-transcript-common/views/jobRows"; +import { JobRow } from "../../jobs/components/JobRow"; +import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes"; +import { type Channel, leafOrder, leafSentence } from "./dispatch"; + +// The JOB kind a lane's unit runs under, so the row's label resolves +// (jobKindLabel) rather than showing a raw lane id. A download unit is its own +// registry job; every other lane's unit is a task on the lane's runner job. +// Mirrors autoRunner.ts's RUNNER_JOB_KINDS / AUTO_DOWNLOAD_UNIT_KIND, which a +// client component cannot import (that module reaches the filesystem); a +// Record over the lane union, so a new lane is a compile error here. +const UNIT_JOB_KIND: Record<AutoQueueKind, string> = { + download: "auto-download-unit", + transcription: "auto-transcribe", + digest: "auto-digest", + backfill: "auto-backfill", +}; // What the runner is doing RIGHT NOW. `runner.inFlight` has always been in the // status payload — video id, owning channel, the leaf that claimed it and when // it started — and the old page rendered a count and threw the rest away. +// +// Each unit is drawn as a JOB ROW (jobs/components/JobRow.tsx, compact), off +// the pure adapter `fromInFlight` — the same row the widget and /jobs draw. A +// download unit is a registry job and links to its page; a transcription, +// digest or backfill unit is a task on the runner's job and does not. export function InFlightList({ status, @@ -30,52 +50,38 @@ export function InFlightList({ {[...items] .sort((a, b) => a.startedAt - b.startedAt) .map((item) => { + // WHICH RULE CLAIMED IT, as the ladder numbers them. A unit that + // is not a video (the download lane's metadata-scan unit, whose + // `videoId` is a synthetic key naming no directory) carries a + // `note`, which fromInFlight says instead — no "rule N" for a leaf + // that exists in no tree, and no link to a video that is not there. const at = leaves.findIndex((l) => l.id === item.leafId); const leaf = at >= 0 ? leaves[at] : null; + const detail = `${at >= 0 ? `rule ${at + 1}` : item.leafId}${ + leaf ? ` · ${leafSentence(leaf, channels)}` : "" + }`; + const row = fromInFlight(item, { + kind: UNIT_JOB_KIND[status.kind], + runnerJobId: status.runner.jobId, + detail, + }); return ( - <li - key={item.videoId} - className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm" - > - <span - aria-hidden="true" - className="size-1.5 shrink-0 self-center rounded-full bg-info animate-pulse motion-reduce:animate-none" + <li key={item.videoId} className="text-sm"> + <JobRow + job={row} + variant="compact" + show={{ + actions: false, + tasks: false, + links: true, + elapsed: true, + // Every unit here is in flight by construction; a pill + // saying so on each line is noise (and wrong for a download + // unit still queued on its platform). + statusPill: false, + }} + now={now} /> - {/* A UNIT THAT IS NOT A VIDEO SAYS SO INSTEAD OF LINKING. - The download lane's metadata-scan unit is channel-scoped: - its `videoId` is a synthetic key naming no directory, so the - usual link would be a 404 and the usual "rule N" would name - a leaf that exists in no tree. It carries a `note`, and a - unit with one renders the note — see AutoRunnerInFlight. */} - {item.note ? ( - <> - <Link - href={`/channels/${item.channelSlug}`} - className="font-mono text-foreground underline underline-offset-2 hover:text-brand" - > - {item.channelSlug} - </Link> - <span className="text-xs text-muted-foreground"> - {item.note} - </span> - </> - ) : ( - <> - <Link - href={`/channels/${item.channelSlug}/videos/${item.videoId}`} - className="font-mono text-foreground underline underline-offset-2 hover:text-brand" - > - {item.channelSlug}/{item.videoId} - </Link> - <span className="text-xs text-muted-foreground"> - {at >= 0 ? `rule ${at + 1}` : item.leafId} - {leaf ? ` · ${leafSentence(leaf, channels)}` : ""} - </span> - </> - )} - <span className="ml-auto tabular-nums text-xs text-muted-foreground"> - {now !== null ? formatElapsed(now - item.startedAt) : "—"} - </span> </li> ); })} diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts @@ -185,17 +185,8 @@ export function formatClock(ms: number): string { // Compact elapsed time: 42s, 3m21s, 14m, 2h06m. Used for uptime and per-unit // age, where a full duration string would swamp the line it sits on. -export function formatElapsed(ms: number): string { - const s = Math.max(0, Math.floor(ms / 1000)); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - if (m < 60) { - const rem = s % 60; - return rem === 0 ? `${m}m` : `${m}m${String(rem).padStart(2, "0")}s`; - } - const h = Math.floor(m / 60); - return `${h}h${String(m % 60).padStart(2, "0")}m`; -} +// Moved to lib/formatElapsed.ts, where the job row reads it too. +export { formatElapsed } from "../../lib/formatElapsed"; // A recency key as a date, marked when it was estimated rather than read. export function formatRecency( diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -10,10 +10,8 @@ import { siteChannelSlugs, } from "yt-dlp-transcript-common/lib/site"; import { - actionableDigestReachableCount, - actionableUndownloadedCount, - actionableUntranscribedCount, getActionableSummary, + widgetActionableRows, } from "./lib/actionable/loadActionable"; import { resolveActiveSite } from "./lib/activeSite"; import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs"; @@ -21,8 +19,13 @@ import { buildWorkersPayload } from "./workers/buildWorkers"; import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; import { widgetSyncInputs } from "./widget/lib/syncInputs"; import { DashboardCockpit } from "./components/dashboard/DashboardCockpit"; -import type { DashboardChannel } from "./components/dashboard/types"; -import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; +import { + buildChannelRowView, + channelVolumeOf, + neutralChannelPriority, + type ChannelRowView, +} from "yt-dlp-transcript-common/views/channelRow"; +import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { ActionableRow } from "./lib/actionable/loadActionable"; export const dynamic = "force-dynamic"; @@ -43,12 +46,6 @@ function loadChangelog(): string | null { } } -function toMs(iso: string | undefined): number | null { - if (!iso) return null; - const n = Date.parse(iso); - return Number.isNaN(n) ? null : n; -} - export default async function Dashboard({ searchParams, }: { @@ -87,49 +84,35 @@ export default async function Dashboard({ ); // Read once for the whole table, not once per row. const locations = getSettings().storage.locations; - const channels: DashboardChannel[] = rows.map((r) => ({ - slug: r.channel.slug, - handling: r.channel.config.handling, - // From the snapshot rather than a corpus walk, so it lags a sync by the - // snapshot scheduler's debounce. - videoCount: r.snapshot?.totals.videos ?? 0, - lastSyncedAt: toMs(r.channel.config.lastSyncedAt), - hasUrl: Boolean(r.channel.config.url), - undownloaded: actionableUndownloadedCount(r), - untranscribed: actionableUntranscribedCount(r), - digestReachable: actionableDigestReachableCount(r), - media: (() => { - const m = mediaBySlug.get(r.channel.slug); - return m && m.status !== "in-place" - ? { - status: m.status, - target: m.target, - detail: m.detail, - // The named location, matched against the configured roots here - // because the settings are here and the table is a client - // component. A pure prefix match, never a probe. - locationLabel: locationLabelOfDataDir(m.target, locations), - } - : null; - })(), - })); + // The shared channel row (common/views/channelRow.ts), drawn by the shared + // table with the dashboard's six columns. No pipeline bands and a neutral + // priority: this table draws neither. + const channels: ChannelRowView[] = rows.map((r) => { + const media = mediaBySlug.get(r.channel.slug) ?? null; + return buildChannelRowView({ + slug: r.channel.slug, + config: r.channel.config, + // From the snapshot rather than a corpus walk, so the Videos figure lags + // a sync by the snapshot scheduler's debounce. + snapshot: r.snapshot, + playlistCount: null, + bands: [], + priority: neutralChannelPriority(), + media, + volume: channelVolumeOf(r.channel.config.dataDir, locations), + // The named location, matched against the configured roots from the + // media TARGET, as this table always has. A pure prefix match, never a + // probe. + mediaLocationLabel: media + ? locationLabelOfDataDir(media.target, locations) + : undefined, + }); + }); - // "Needs work" payload, same shape the widget endpoint the cockpit polls - // returns, scoped to the active site for the SSR seed. - const actionable: WidgetActionablePayload = { - channels: channels - .filter((c) => c.undownloaded > 0 || c.untranscribed > 0) - .map((c) => ({ - slug: c.slug, - undownloaded: c.undownloaded, - untranscribed: c.untranscribed, - digestReachable: c.digestReachable, - })) - .sort( - (a, b) => - b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed), - ), - }; + // "Needs work" payload, same shape (and the same two functions) the + // /api/view/widgetActionable handler the cockpit polls serves, scoped to the + // active site for the SSR seed. + const actionable = buildWidgetActionablePayload(widgetActionableRows(rows)); const [jobs, sync] = await Promise.all([ buildActiveJobsPayload(), diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -1,7 +1,7 @@ "use client"; import { Fragment, useState, type CSSProperties, type ReactNode } from "react"; -import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { fmtTime } from "../lib/relativeTime"; import type { @@ -9,11 +9,7 @@ import type { DiskStatusView, } from "yt-dlp-transcript-common/views/activeJobs"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; -import type { - JobProgressMetric, - JobTaskKind, -} from "yt-dlp-transcript-common/jobs/registry"; -import { jobKindLabel } from "../../jobs/jobKindLabels"; +import { JobRow } from "../../jobs/components/JobRow"; import type { WorkersPayload, WorkerView } from "yt-dlp-transcript-common/views/workers"; import { InlineActionButton } from "../../components/actions/InlineActionButton"; import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; @@ -1017,10 +1013,15 @@ function ActiveJobsStrip({ <li key={j.id}> <JobRow job={j} - compact={compact} - jobBar={jobBar} - headingProgress={headingProgress} - eta={eta} + variant="compact" + show={{ + tasks: !compact, + jobBar, + headingProgress, + eta, + actions: false, + links: false, + }} /> </li> ))} @@ -1030,226 +1031,6 @@ function ActiveJobsStrip({ ); } -function JobRow({ - job, - compact, - jobBar, - headingProgress, - eta, -}: { - job: JobRowView; - compact: boolean; - jobBar: boolean; - headingProgress: boolean; - eta: boolean; -}) { - const statusClass = - job.status === "running" - ? "bg-info-soft text-info" - : "bg-muted text-muted-foreground"; - return ( - <div className="flex flex-col gap-1"> - <div className="flex flex-wrap items-center gap-1.5 text-xs"> - <span - className={`uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusClass}`} - > - {job.status} - </span> - <span className="font-mono" title={job.kind}> - {jobKindLabel(job.kind)} - </span> - {job.background && ( - <span - className="uppercase tracking-wide px-1 py-0.5 rounded text-[9px] bg-warning-soft text-warning" - title="Background work — yields to a manual sync/download" - > - auto - </span> - )} - {job.channelSlug && ( - <span className="font-mono text-muted-foreground truncate max-w-[12rem]"> - {job.channelSlug} - </span> - )} - {headingProgress && job.progress && ( - <span className="font-mono text-muted-foreground shrink-0"> - {jobProgressText(job.progress, eta)} - </span> - )} - </div> - {jobBar && job.progress && ( - <JobProgressBar progress={job.progress} showEta={eta} /> - )} - {!compact && - job.tasks && - job.tasks.length > 0 && - job.tasks.map((t) => <TaskBar key={t.id} task={t} />)} - </div> - ); -} - -// Per-task-kind glyph/fill, as Records over JobTaskKind so a new kind is a -// compile error rather than silently rendering as a transcription. -const TASK_KIND_VERB: Record<JobTaskKind, string> = { - download: "\u2193", - transcribe: "\u270e", - digest: "\u00b6", - backfill: "\u21ba", - // A right arrow: a relocate task is a channel's media moving to another disk. - relocate: "\u21e2", -}; - -const TASK_KIND_FILL: Record<JobTaskKind, string> = { - download: "bg-success/60", - transcribe: "bg-success", - digest: "bg-info", - backfill: "bg-warning", - relocate: "bg-info/70", -}; - -// Per-metric glyph for the compact widget line. A Record over JobProgressMetric -// so a new metric is a compile error, not a mislabelled bar (the two copies of -// this ternary previously had to be kept in sync by hand). -const METRIC_PREFIX: Record<JobProgressMetric, string> = { - downloads: "\u2193 ", - transcripts: "", - digests: "\u00b6 ", - backfills: "\u21ba ", - scans: "\u2315 ", -}; - -// One-line textual summary of a job's batch progress, e.g. "↓ 5/10 · ~2m left". -// Shared by the job heading (headingProgress) and the job bar's caption so the -// label/ETA formatting stays in one place. -function jobProgressText( - progress: NonNullable<JobRowView["progress"]>, - showEta: boolean, -): string { - const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`; - const remaining = progress.target - progress.current; - const etaText = - !showEta || remaining <= 0 - ? null - : typeof progress.etaSeconds === "number" - ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` - : "…"; - return etaText ? `${label} · ${etaText}` : label; -} - -function JobProgressBar({ - progress, - showEta, -}: { - progress: NonNullable<JobRowView["progress"]>; - showEta: boolean; -}) { - const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`; - const remaining = progress.target - progress.current; - const etaText = - !showEta || remaining <= 0 - ? null - : typeof progress.etaSeconds === "number" - ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` - : "…"; - return ( - <div className="flex flex-col gap-0.5"> - <div - role="progressbar" - aria-valuemin={progress.initial} - aria-valuemax={progress.target} - aria-valuenow={progress.current} - aria-label={label} - className="relative h-1.5 w-full overflow-hidden rounded bg-muted" - > - <div - className="absolute inset-y-0 left-0 bg-success" - style={{ width: `${progress.pct}%` }} - /> - </div> - <div className="flex items-baseline justify-between gap-2 text-[11px] text-muted-foreground"> - <span>{label}</span> - {etaText && <span className="shrink-0">{etaText}</span>} - </div> - </div> - ); -} - -function TaskBar({ - task, -}: { - task: NonNullable<JobRowView["tasks"]>[number]; -}) { - const now = useNow(); - const probing = task.phase === "probing"; - const verb = probing ? "🔍" : TASK_KIND_VERB[task.kind]; - // While probing, fill against the estimated probe duration (violet "scanning" - // bar) rather than the frozen download fraction. See TaskProgressBar. - const probeFraction = - probing && - now !== null && - typeof task.probeStartedAt === "number" && - typeof task.probeEtaSeconds === "number" && - task.probeEtaSeconds > 0 - ? Math.max( - 0, - Math.min(1, (now - task.probeStartedAt) / (task.probeEtaSeconds * 1000)), - ) - : undefined; - const hasFraction = probing - ? typeof probeFraction === "number" - : typeof task.fraction === "number"; - const fraction = probing ? probeFraction : task.fraction; - const pct = hasFraction ? Math.round((fraction as number) * 100) : 0; - const fillClass = probing - ? "bg-violet-400 dark:bg-violet-500" - : TASK_KIND_FILL[task.kind]; - const pulseClass = probing - ? "bg-violet-400 dark:bg-violet-500" - : "bg-warning"; - const elapsed = - now === null - ? null - : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) || - "0:00"; - const meta = [ - elapsed, - hasFraction ? `${pct}%` : null, - probing ? "probing" : task.detail, - ] - .filter(Boolean) - .join(" · "); - return ( - <div className="flex flex-col gap-0.5 pl-2 border-l-2 border-border"> - <div className="flex items-baseline justify-between gap-2 text-[11px]"> - <span className="truncate"> - <span className="text-muted-foreground">{verb} </span> - <span className="font-mono">{task.label}</span> - </span> - <span className="font-mono text-muted-foreground shrink-0">{meta}</span> - </div> - <div - role="progressbar" - aria-valuenow={hasFraction ? pct : undefined} - aria-label={`${task.label}`} - className="relative h-1 w-full overflow-hidden rounded bg-muted" - > - {hasFraction ? ( - <div - className={`absolute inset-y-0 left-0 ${fillClass}`} - style={{ width: `${pct}%` }} - /> - ) : ( - // No fraction yet (just started, an engine that reports no %, or the - // first audio probe): a full-width pulse reads as "working, no - // progress number" — distinct from the emerald fill so it can't be - // mistaken for ~33%. - <div className={`absolute inset-0 animate-pulse ${pulseClass}`} /> - )} - </div> - </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 diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md @@ -832,3 +832,88 @@ docs) at `5b43dc8d`, **1650** after `81deae69` (+2 docs wiring tests); `test:scr diarization, attribution, scheduler, cadence-ui, storage-locations, channel-storage, workers, worker-remote, parakeet, parakeet-partial, chough, transcription-app-migration, disk-space, channel-priority, settings — **157/157 passed, exit 0, 9.9 min**, first run, nothing re-run. + +## Slice 3a, as shipped — one drawing per noun (2026-09-24) + +Branch `one-core/phase-3-s3a` off `1e27f7c3`, unmerged; ten commits (the eight below, +this record `b4890c78`, and the review fixes `001f91bc`). + +| sha | what | +|---|---| +| `ab7a4739` | `common/views/channelRow.ts` (`ChannelRowView`, `buildChannelRowView`, `reportStateOf` moved, `ChannelRowPriority`, `ChannelRowMedia`, `channelVolumeOf`) + `actionableCounts.ts`, both tested; `loadActionable` count helpers become wrappers | +| `155efeb9` | rack split: `ChannelsRack.tsx` (chrome) + shared `ChannelsTable.tsx`; `channelColumns.tsx` registry; `/channels` builds rows with the builder | +| `01f08670` | dashboard on the shared table; "Needs work" seed = `buildWidgetActionablePayload(widgetActionableRows(rows))`; `PrioritizeButton` moved; `dashboard/ChannelsTable.tsx` + `types.ts` deleted | +| `f63260d8` | `ChannelWorkTable` = server shell over the shared table; `Row` and `isStaleOrMissing` deleted | +| `0d20c42a` | `jobs/components/JobRow.tsx` (table / card / compact) + `JobRowActions` + `JobRowHeading`; JobsTable, RunningJobsList, LaneStrip on them; compact bars; `lib/formatElapsed.ts` | +| `797078c2` | `AutoRunnerInFlight.jobId` (type + the two `onChildJob` callbacks); `fromInFlight` + `source: "runner"`; InFlightList on `JobRow compact` | +| `4a784486` | widget `ActiveJobsStrip` on `JobRow compact`; its private row, bars, glyph tables deleted | +| `c944475f` | fix: column ids/presets move to plain `channelColumnPresets.ts` (see deviation 1) | + +**Gates.** tsc clean after every commit. common **1677** (1663 + 5 actionableCounts + 6 +channelRow + 3 fromInFlight); editor unit **67**; `test:scripts` 156 + 1 skip. **e2e**, one +detached run from the worktree root, 41 spec files (both lists; every named file exists): +**280 passed, 0 failed, 0 skipped, 0 flaky, exit 0, 14.7 min** — the second run; the first +was stopped in its first minute on the bug `c944475f` fixes. **Numbers** +(`phase3-view-numbers.ts`, primary's `transcripts/` + `settings.json`, read-only): main +`1e27f7c3` (a clean detached checkout) and the branch at `4a784486`, back to back — **diff +empty, 5,925 bytes each**. The `autoQueueStatus` view gains the optional per-unit `jobId`; +the numbers tool does not cover that view. + +Deviations: +1. **A column registry by id, not column objects.** A server shell cannot pass `cell: (row) => + ReactNode`. And a server component that imports a VALUE from a `"use client"` module gets + a client reference, not the value (`WORK_COLUMNS.filter is not a function` on every + operation page — invisible to tsc), so the ids, sort keys, `PipelineColumn` and presets + live in the directive-free `channelColumnPresets.ts`; the cells stay in the client registry. +2. **The rack split** into `ChannelsRack` (focus / volume / instrument bars, scroll region + + `--thead-h`, selection + free-up, the deck outside the scroll box) and the shared table; + group-header `colSpan` is counted from the drawn columns. +3. **LaneStrip keeps its lane line**; its runner job's buttons are `JobRowActions`, so it now + also offers Force-release while that job runs, and Cancel only while running/queued. +4. **Runner `jobId`**: three lines in `autoRunner.ts`; the private `childJobIds` map stays + private. A `source: "runner"` row links `Job <id>` only when `inRegistry`. +5. `formatElapsed` went to `editor/app/lib/formatElapsed.ts` (dispatch.ts re-exports), not + `common/lib/format.ts`: that file was the parallel slice's, and it is not `formatDuration`. +6. `channelVolumeOf` (the page's `volumeOf`) lives in `channelRow.ts`; the builder takes an + optional `mediaLocationLabel` so the dashboard still names the badge's location from the + media target. +7. Work tables use the rack's report cell (`report age for <slug>`, "stale"/"missing"); + an unset last sync still reads "never". Actions are left-aligned everywhere. +8. The dashboard's Sync is `ChannelSyncButton` (same `sync <slug>`, same `role="alert"`). +9. Widget job lines now show `detail`, as every other job row does. +10. Group sections still carry `ChannelStat` (config included) to the client — pre-existing, + untouched. +11. Commits 1–7 and the fix carry an Opus 5.5 trailer, not the header's; left as they are. +12. **Card actions follow the table's rule** (`JobRowActions`): a RunningJobsList card now + offers Retry on a failed replayable job, Force-release on a stuck one, no Cancel on a + finished one, and its status pill takes `statusColor` (done/failed coloured, not muted). +13. **Dashboard cosmetics**: the handling cell is the rack's (`font-mono text-[11px]` muted); + the slug link lost `underline-offset-2 hover:text-brand`; rows lost `align-top`; Sync is + `ChannelSyncButton`, which drains its stream and reads "job …" / "done (job …)" where the + old inline button cancelled the stream and said "queued". + +**Builds** (at `b4890c78`): editor `next build` exit 0, route table lists +`ƒ /api/view/[name]`; export `next build` exit 0. + +**Bisect hazard.** From `f63260d8` up to (not including) `c944475f`, every operation page +throws at render (`WORK_COLUMNS.filter is not a function`). History is not rewritten; skip +that range when bisecting. + +**Review fixes (`001f91bc`), from the slice review, all eight in one commit:** +1. Dim only in the rack (`sticky`): the dashboard and work tables never dimmed, and a + build-excluded channel in a work list is not out of any pipeline. +2. /channels projects the group sections to `{slug}` (`ChannelGroupSectionView`, in + `common/views/channelGroupSections.ts`) before they reach the client — deviation 10 is + resolved: no ChannelConfig reaches the browser from /channels. +3. Work tables' report cell: "stale · <stamp>" / "missing", stamp in `title` (the rack's + is unchanged) — deviation 7 no longer loses the date. +4. `suppressHydrationWarning` through `Td` on the date cells. +5. Compact JobRow: one-line heading, truncated `detail` with the full text in `title` + (deviation 9's overflow). +6. InFlightList: each unit's real job kind (`auto-download-unit`, `auto-transcribe`, + `auto-digest`, `auto-backfill`, a `Record<AutoQueueKind, string>` mirroring + autoRunner's, which a client cannot import) and no status pill (`show.statusPill`); + `fromInFlight` keeps `status: "running"`. +7. Lane line: Force-release only when the runner job is stuck (deviation 3 narrowed). +8. Dead exports removed (loadActionable's `reportStateOf` re-export, function-form column + labels + `ChannelHeadCtx`, JobRow's internal helpers).