Archilyzer · Source

archilyzer

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

commit 8d53d72e5f42f9459636a3ed102052383979b582
parent 4c8e658fbbea820d68e39447ecc71c01800a7e38
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 16:24:22 -0400

Merge branch 'main' (a9f5961e, slice 3a) into one-core/phase-3-s4b

Conflicts only in plans/one-core-phase-3.md and editor/CHANGELOG.md; both
sides kept, slice 3a's record and entry before slice 4b's.

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 | 3+--
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/STATE.md | 12++++++++----
Mplans/one-core-phase-3.md | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
36 files changed, 3022 insertions(+), 1933 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. - **`site.json`, each channel's `config.json` and the per-video sidecars now have one schema each, and the two config files have generated key tables.** **`SITE.md`** and **`CHANNEL.md`** (new, repo root) list every key with its default and meaning, generated by `common/bin/file-schemas-docs.ts` and checked by a test. Nothing an operator has configured reads or saves differently: every live `site.json` and `config.json`, and a 1,763-file sample of sidecars, read and write back byte-for-byte as before. **Fixed:** a social-channel fetch no longer undoes Configure-form edits made while it was running (it used to write back the whole config it read when it started). Every change to a channel's config now re-reads the file at the moment it saves and changes only its own fields, so a sync stamping its time and a form save made at the same moment both land. Two writes to the same file from the editor no longer share one temporary file. - **`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. @@ -55,12 +56,10 @@ - **A scope naming an operation that no longer exists no longer arms a sweep that runs forever doing nothing.** Settings sanitation keeps an unknown operation id, and the resolver then matches nothing with it — so the sweep starts, reports itself armed, and holds at zero. Unknown ids are now dropped at the moment of arming, with the console saying which; a scope that names *only* unknown operations is refused outright rather than started. - **The feed and the gate stopped sounding alike.** *Start sweep* and *Pause Backfill* were the same shape of phrase for two acts whose costs to undo differ by a week of GPU time. The feed **sweeps** — *Sweep every channel* / *Stop sweeping* — and the gate **holds** — *Hold the lane* / *Resume the lane*. The lane heading says what it holds and what this panel does with it: *Speakers · sweep*. The settings fieldset no longer implies scope lives there, and points at the console instead. - **The plan costs nothing to draw.** The page was already reading every channel's snapshot for the comparison rail and throwing the rest away — third cycle running that this has been true. The run's own planner could not be used: it *walks the corpus* when a channel has never been reported (~474,559 file touches, ~4 seconds) and this refreshes every three seconds. So the preview counts off the snapshots with the run's own arithmetic — verified against the live archive at 68 channels, 78,757 outstanding: **identical totals, identical per-channel counts, identical ordering** — and a channel nothing has reported on is drawn as *not reported yet* rather than as a confident zero. The guard test that keeps the corpus walk out of render paths grepped for one identifier and would have let the sweep planner straight through; it now names every function that reaches it. - - **One word was standing in for three different things, and hiding a fourth.** "Backfill" named a *lane* (three operations sharing a CPU queue), a *per-channel action*, and — on the download stage — a completely unrelated **download job** for subtitle tracks. Nobody arms, pauses or runs "a backfill"; it is a queue key. Every screen where an operator reads a **figure** now reads an **operation name**: the channel transit line's station is *Speakers*, its stage panel is *Speaker work · 3 operations* with a *Run speaker work* button, the download stage says *Fetch missing subtitle tracks*, and the lane card and `/auto-queue` name their members. The lane name survives in exactly one place — the settings fieldset and the lane card, where the one shared pause and the one shared sweep live — and there it now **lists what it holds**, because "these operations share a queue and a pause" is a true and load-bearing fact rather than a leaked implementation detail. - **The transit line's Speakers station stopped summing three operations into one number.** It set its coverage by adding every operation on the lane together — the one thing this codebase forbids everywhere else — which on the live corpus meant adding diarization (one audio pass per video, 4 done of 11,338) to speaker-names-from-the-transcript (**~1 model call per transcript chunk**, 1 done of 11,338) and printing the total under a label that named the queue. The numeral now belongs to exactly one operation, and each member states itself, with its own band and its own denominator, in the station foot. - **An armed operation now says what one unit of it costs.** The fact that hid: speaker-names-from-the-transcript is switched **on**, can reach 11,337 videos of one channel — on the order of **194,000 model calls corpus-wide** — has completed one video, and read as a quiet row on every screen, because "11,337 reachable" is the same shape of number whether the unit is an audio pass or a per-chunk model call. Each operation declares its cost basis, printed beside its backlog wherever it is armed. Deliberately **no threshold and no editorialising**: what is affordable is the operator's call, and a "this is a lot" cutoff would be a magic number the next operation gets wrong. Nothing here changes a setting. - **Every pipeline is now visible on /channels, not just two of them.** The table printed two bare integers — `Downloads` and `Transcripts` — and said nothing whatsoever about the four derived pipelines, which were collapsed everywhere else behind the word *backfill*. All six now draw the same **state band** the comparison rail on /auto-queue uses, at table scale: one fill per population, `can run now` the only saturated colour on the page, and pattern (solid / hatched / dotted / hollow) carrying the meaning ahead of hue, because no four-colour palette clears all-pairs colour-blindness. **Not a percent bar, deliberately** — digest sits at 0 done on every large channel and diarization is 99.96% media-gone, so "% complete" renders `0%` on all 68 rows and says nothing; what varies, and what an operator needs, is the *shape* of the remainder. Every pipeline column **sorts by what can run now**, which answers a question the page has never been able to answer: *which channel has the most diarizable audio left right now* previously meant opening 68 channel pages one at a time. The costs nothing extra to draw — `/channels` was already reading every channel's full snapshot for its counts and throwing the rest away. - - **`docker compose up -d` now stands up a working archive.** The repo had two Dockerfiles and neither ran the app: one fans per-site export builds out across containers, the other runs sharded e2e. So the only way to host this was to install the whole Unix toolchain by hand, which is why the Windows instructions said "use WSL2 and follow the Linux steps". There is now a runtime image and a compose stack — the editor plus Caddy by default, with the published site, the project homepage and umtool behind compose **profiles**, so somebody who only wants an archive runs two containers rather than five. First boot creates the volumes, downloads a speech model, and seeds a `settings.json` **carrying one enabled worker**: the defaults ship `workers: []`, zero workers means zero transcription slots, and a fresh container that looks healthy and silently transcribes nothing is the worst possible first run. Two things are deliberately not baked into the image and cannot be: the corpus, and the export site — that site is a static render *of* a corpus, and there is no corpus at image-build time, so `docker/publish-site.sh` builds it at run time into the volume the `site` service serves. - **Nothing the container runs is reachable from outside the machine until you say so.** The editor has no authentication of any kind, shells out to yt-dlp, and deletes media — so **no application container publishes a port at all**. Caddy is the single front door, and every one of the four ports it publishes binds `127.0.0.1` by default, the public sites included; opening one is a deliberate edit of a single line in `.env`. Because a bind address is exactly the sort of thing that gets changed in a hurry, there is also a rail: if a private app is bound off-loopback with nothing checking credentials, **the containers refuse to start** — both the app and Caddy, which is the process that actually opens the ports — and print the four ways to fix it. `basic_auth` is built into Caddy so a password needs nothing installed; Tinyauth and Authelia attach through `forward_auth` as documented drop-in overlays; `ARCHILYZER_AUTH_MODE=none` is the one explicit escape hatch for people who already have their own front door. - **The GPU is usable from the container, including on AMD.** Alongside the default CPU whisper.cpp image there is a **Vulkan** target running parakeet.cpp — one build that covers AMD (RADV), Intel and NVIDIA, needing nothing on the host but a render node at `/dev/dri` and no vendor container toolkit — and a CUDA target for NVIDIA whisper. Measured on an RX 6600 XT, through the app's own overlapping-window wrapper: **3.4 s against 36.3 s** for the same 33-second clip pinned to the CPU. That gap is also the thing to watch for, because the failure here is silent — a Vulkan container with no `/dev/dri` does not error, it transcribes correctly on the CPU about ten times slower. The entrypoint prints which one it got on every boot, and the image ships `vulkaninfo` so you can ask directly. 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/STATE.md b/plans/STATE.md @@ -3,8 +3,12 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Last updated:** 2026-09-23 — **one-core Phase 3 slices 2 and 4a are on `main` @ -`ae2fa5a9`, and gate B is done.** Base `54cf1b31`; two branches merged in the order +**Last updated:** 2026-09-24 — **release 2 is LIVE on :3001** (`1e27f7c3` = `b836ea8a` + +two prelude commits; rollout record: +[`one-core-phase-3.md`](one-core-phase-3.md#rollout-2026-09-24--b836ea8a--two-prelude-commits-live-on-3001)), +and **slices 3a + 4b are in flight** on `one-core/phase-3-s3a` and `one-core/phase-3-s4b` +(worktrees off `1e27f7c3`). Previous: 2026-09-23 — **one-core Phase 3 slices 2 and 4a are on +`main` @ `ae2fa5a9`, and gate B is done.** Base `54cf1b31`; two branches merged in the order `one-core/phase-3-s2` → `8d6e84f6`, then `one-core/phase-3-s4a` → `ae2fa5a9` (with `main` merged into it as `7035316c`). Release record, gates and the next release's inventory: [`one-core-phase-3.md`](one-core-phase-3.md#release-2026-09-23--slices-2-and-4a); anchors: @@ -35,8 +39,8 @@ merged into it as `7035316c`). Release record, gates and the next release's inve **Still owed (operator):** the Anilyzer **production** deploy (preview is up, see below); the other five sites to corpus spec 4; the LM chat-only tier (shipped, unconfigured). **Owed -(code):** the backfill lane form's hint text still says 836 reachable (gate B measured 679) — -`LaneSettingsForm.tsx:101`. +(code):** nothing from release 2 — the lane form's 836 hint is live figures since `c0a90a37` / +`1e27f7c3` (679 reachable, 74,411 needing media on 2026-09-24). **Previously:** 2026-09-22 — **the release *curated-tags follow-ups + debts sweep* is on `main` @ `766e0873`.** Base `4ac8ceda`; three branches merged in the order diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md @@ -519,6 +519,56 @@ list its own record (`5b43dc8d`) or the review fix (`81deae69`), and the review settingsDocs wiring tests, so common on that tip is 1650, not 1648. Both records said "unmerged"; both are merged. +### Rollout 2026-09-24 — `b836ea8a` (+ two prelude commits) live on :3001 + +The editor on :3001 had served a `54cf1b31` build since 2026-09-14; this put release 2 in +front of the operator. Editor only — `git diff --stat 54cf1b31..b836ea8a -- umtool` is empty +and umtool's one `common` import (`lib/momentUrl`) is unchanged, so umtool was not rebuilt. + +**Two code commits on `main` first, both to the same stale sentence.** `c0a90a37` — the +backfill lane form (`LaneSettingsForm.tsx`) derives the re-acquire figures from the +widget-sync payload plus one statfs of the transcripts root (`getFreeBytes`, the same call +behind the jobs payload's `disk.freeBytes`); the hand-typed "836 / ~76,270 / 91× / 45 GB" +stays as the fallback when no figures are passed. `1e27f7c3` — found live: the lane-wide +`backfill.reachable` folds attribution-text, whose input is the transcript, so the first +build rendered "78,146 videos still have media on disk". The page now reads the +**diarization** kind's entry from `backfill.kinds` — the one operation whose missing input is +audio, which is what the toggle fetches. Live it reads "679 videos still have media on disk +and 74,411 would need re-downloading — 110× the reachable work, against 269.43 GB free", the +successor of the dated sentence and the same 679 gate B measured. + +**Procedure, as in release 1:** offline round-trip first — +`plans/tools/phase3-settings-numbers.ts live=settings.json`, written block equal to +`jq -S . settings.json` (diff empty, no key added); then `pnpm --filter editor build` detached +into the live `.next` while the old server kept serving; then TERM the process whose cwd is +`editor/`, wait for :3001 to free, `setsid nohup pnpm run start -H 0.0.0.0`. Done twice +(build 7 on `c0a90a37`, build 8 on `1e27f7c3`): `BUILD_ID` `CXAYTdyUXFjcEqCiMCwQ-` → +`M-ujB5s8O4_phVlvBejMG` → `vXFdg-SWIkdh7XpWLEqCQ`, both route tables with `ƒ /api/view/[name]`. + +**Smoke, by hand = `view-route.spec.ts`:** `/api/view/<bogus>` and `/api/view/invalidate-cache` +404; `/api/widget/presets` 200; `/api/view/pulse?rev=` and `/api/pulse?rev=` both +`changed:false` (build 8; on build 7 a download was in flight and the rev never held still); +`/api/widget/cleanable` `cleanableBytes: null` (the old cleanable path is gone, not `-1` — +the spec's fixture value); `/`, `/operations/diarization` (Holding, re-acquire off), `/settings`, +`/storage`, `/jobs`, `/tags` all 200; `settings.json` md5 `8e03ec3f…` unchanged across both +boots; no `ZodError` in either start log. The eight old/new pairs: `pulse`, `workers`, +`widgetSync`, `widgetActionable`, `cleanable` byte-equal on build 7 (ignoring `builtAt` / +`disk.freeBytes` / `now`); `activeJobs`, `autoQueueStatus`, `schedulerStatus` equal once the +live-only fields (`heldMs`, a running download's `fraction` / ETA `detail`) are stripped — a +live server is not the idle fixture. Build 8 changed nothing under `app/api`, so the pair +evidence carries. + +**What the smoke could not clear, and why.** Corpus-wide pages took 47–240 s and several +probes timed out for ~25 min after each boot. The cause is not the build: every boot re-runs +the recency pass (`[recency] dating 8000 of 8883 undated candidates`, once per Next module +graph, memoised in-process), the scheduler re-queues the overdue sync-all (46 syncs held, 2 +running), and a metadata scan resumes — all against a platter drive (`sdb`, the relocated +media root) that an in-flight ffmpeg remux and, later, the 4b numbers script were saturating +(`/proc/pressure/io` "full" 58–66 %; the NVMe was idle). Two restarts in one hour paid that +boot cost twice; `ARCHILYZER_IDLE_BOOT=1` exists for exactly this and was not used because the +lanes were meant to resume. Sharp fails to load in the primary and every worktree +(`require('sharp')` throws); builds do not need it and it predates this release. + ## Next release — slice 3 and slice 4b Not started. The facts below were checked against `ae2fa5a9` on 2026-09-23. @@ -783,6 +833,91 @@ diarization, attribution, scheduler, cadence-ui, storage-locations, channel-stor 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). + ## Slice 4b, as shipped — the rest of the file schemas (2026-09-24) Branch `one-core/phase-3-s4b` off `main` `1e27f7c3`, eight commits, then `main` (`3241fed2`,