Archilyzer · Source

archilyzer

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

commit 7fde7d82cfe13702f803cb43e070e604a5891d2c
parent a9d6c9f91502f41e1d8e276a3c1e40ecd62e4aeb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 13:40:54 -0400

review fixes: rack-only dim, slug-only sections, report dates, one-line compact rows

From the slice 3a review:
1. Dim build-excluded / paused rows only in the rack (`sticky`); the dashboard
   and the work tables never dimmed and do not now.
2. /channels projects the group sections to `{slug}` before they cross to the
   client (`ChannelGroupSectionView`), so no ChannelConfig reaches the browser
   through the grouped view either.
3. Work tables' report cell keeps the word and the date: "stale · <stamp>",
   "missing", the stamp in `title`. The rack's cell is unchanged.
4. `suppressHydrationWarning` passes through Td for the date cells (formatStamp
   runs in an SSR'd client component now), as JobRow's dates already do.
5. Compact JobRow: the heading stays on one line and the detail truncates,
   with the full text in `title`.
6. InFlightList labels each unit with its lane's real job kind
   (auto-download-unit / auto-transcribe / auto-digest / auto-backfill) and
   hides the status pill (`show.statusPill`, default on elsewhere).
7. The lane line offers Force-release only on a stuck runner job
   (`JobRowActions forceRelease="stuck"`).
8. Dead exports removed: loadActionable's reportStateOf re-export, the
   function-form column label and ChannelHeadCtx, and JobRow's internal
   fmtDuration / queuePlace / JobRowHeading.

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

Diffstat:
Mcommon/views/channelGroupSections.ts | 8++++++++
Meditor/app/channels/components/ChannelGroupHeaderRow.tsx | 4++--
Meditor/app/channels/components/ChannelGroupLine.tsx | 6+++---
Meditor/app/channels/components/ChannelsRack.tsx | 4++--
Meditor/app/channels/components/ChannelsTable.tsx | 25+++++++++++++++++--------
Meditor/app/channels/components/channelColumns.tsx | 39++++++++++++++++++++++++---------------
Meditor/app/channels/page.tsx | 14+++++++++++---
Meditor/app/jobs/components/JobRow.tsx | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Meditor/app/jobs/components/LaneStrip.tsx | 2+-
Meditor/app/lib/actionable/loadActionable.ts | 5-----
Meditor/app/operations/components/InFlightList.tsx | 20+++++++++++++++++++-
11 files changed, 141 insertions(+), 63 deletions(-)

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/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 @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; import { BandLegend } 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 { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar"; import { ChannelSelectionDeck, @@ -49,7 +49,7 @@ export function ChannelsRack({ // 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; + 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 diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -6,7 +6,7 @@ import { bandSentence, type OperationBand, } from "yt-dlp-transcript-common/views/pipeline/band"; -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"; @@ -218,7 +218,7 @@ export function ChannelsTable({ 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?: ChannelGroupSection[] | null; + sections?: ChannelGroupSectionView[] | null; siteId?: string; // 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. @@ -323,11 +323,7 @@ export function ChannelsTable({ continue; } const col = CHANNEL_COLUMNS[id]; - const label = - labels[id] ?? - (typeof col.label === "function" - ? col.label({ labels, sticky }) - : col.label); + const label = labels[id] ?? col.label; const className = [ col.th?.className ?? "", sticky ? (col.th?.stickyClassName ?? "") : "", @@ -523,8 +519,15 @@ function ChannelTableRow({ // 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.excludeFromBuild || c.priority.tier === "paused" ? " opacity-60" : ""; + sticky && (c.excludeFromBuild || c.priority.tier === "paused") + ? " opacity-60" + : ""; const ctx: ChannelCellCtx = { dim, stickyBg, @@ -592,6 +595,7 @@ function ChannelTableRow({ className={`${cell.className ?? ""}${col.dim === false ? "" : dim}`} ariaLabel={cell.ariaLabel} title={cell.title} + suppressHydrationWarning={cell.suppressHydrationWarning} > {cell.content} </Td>, @@ -676,18 +680,23 @@ function Td({ ariaLabel, title, pad, + suppressHydrationWarning, }: { 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={`${pad} align-middle ${className}`} aria-label={ariaLabel} title={title} + suppressHydrationWarning={suppressHydrationWarning} > {children} </td> diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx @@ -74,17 +74,15 @@ export type ChannelCell = { className?: string; ariaLabel?: string; title?: string; + // Set on locale-formatted date cells; see Td in ./ChannelsTable.tsx. + suppressHydrationWarning?: boolean; }; -export type ChannelHeadCtx = { - labels: ChannelColumnLabels; - sticky: 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 | ((ctx: ChannelHeadCtx) => string); + label: string; align?: "left" | "right"; sortKey?: ChannelSortKey; // `stickyClassName` applies only in the rack (`sticky`), where the Slug @@ -244,6 +242,7 @@ export const CHANNEL_COLUMNS: Record< 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"), }), @@ -268,16 +267,26 @@ export const CHANNEL_COLUMNS: Record< 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.", }, - cell: (c) => ({ - ariaLabel: `report age for ${c.slug}`, - className: `whitespace-nowrap text-xs tabular-nums ${ - c.report.state === "current" ? "text-muted-foreground" : "text-warning" - }`, - content: - c.report.state === "current" - ? formatStamp(c.report.generatedAt) - : c.report.state, - }), + // 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 diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -46,7 +46,10 @@ import { 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 { resolveActiveSite } from "../lib/activeSite"; @@ -308,14 +311,19 @@ export default async function ChannelsPage({ 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, stats.filter((st) => shownSlugs.has(st.slug)), briefs, settings, - ) + ).map((s) => ({ + ...s, + channels: s.channels.map((c) => ({ slug: c.slug })), + })) : null; const freshness = summariseFreshness( briefs.filter((b) => shownSlugs.has(b.slug)), diff --git a/editor/app/jobs/components/JobRow.tsx b/editor/app/jobs/components/JobRow.tsx @@ -38,6 +38,7 @@ export type JobRowShow = { 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"; @@ -52,6 +53,7 @@ const DEFAULTS: Record<Variant, JobRowShow> = { links: true, log: false, elapsed: false, + statusPill: true, }, card: { tasks: true, @@ -62,6 +64,7 @@ const DEFAULTS: Record<Variant, JobRowShow> = { links: true, log: false, elapsed: false, + statusPill: true, }, compact: { tasks: true, @@ -72,10 +75,11 @@ const DEFAULTS: Record<Variant, JobRowShow> = { links: false, log: false, elapsed: false, + statusPill: true, }, }; -export function fmtDuration(ms: number): string { +function fmtDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const s = ms / 1000; if (s < 60) return `${s.toFixed(1)}s`; @@ -84,7 +88,7 @@ export function fmtDuration(ms: number): string { } // "next in line" reads better than "1st"; past that, an ordinal. -export function queuePlace(position: number): string { +function queuePlace(position: number): string { if (position === 1) return "next in line"; const rem100 = position % 100; const rem10 = position % 10; @@ -136,7 +140,15 @@ function videoHref(job: JobRowView): string | null { // 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 }: { job: JobRowView }) { +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 && ( @@ -155,9 +167,8 @@ export function JobRowActions({ job }: { job: JobRowView }) { {(job.status === "running" || job.status === "queued") && ( <CancelJobButton jobId={job.id} /> )} - {(job.status === "running" || Boolean(job.stuck)) && ( - <ForceReleaseJobButton jobId={job.id} /> - )} + {((forceRelease === "running-or-stuck" && job.status === "running") || + Boolean(job.stuck)) && <ForceReleaseJobButton jobId={job.id} />} </> ); } @@ -180,7 +191,7 @@ function AutoBadge({ compact }: { compact?: boolean }) { // 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. -export function JobRowHeading({ +function JobRowHeading({ job, compact = false, links = true, @@ -188,7 +199,9 @@ export function JobRowHeading({ hideVideoId, headingProgress = false, eta = false, + statusPill = true, }: { + statusPill?: boolean; job: JobRowView; compact?: boolean; links?: boolean; @@ -201,24 +214,36 @@ export function JobRowHeading({ 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 - ? `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 ? "font-mono" : "text-xs font-medium"} + className={compact ? "shrink-0 font-mono" : "text-xs font-medium"} title={job.kind} > {job.kind ? jobKindLabel(job.kind) : "—"} </span> - {job.detail && ( - <span className="text-xs text-muted-foreground">· {job.detail}</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}> @@ -314,10 +339,11 @@ export function JobRow({ ); return ( <div className="flex flex-col gap-1"> - <div className="flex flex-wrap items-center gap-1.5 text-xs"> + <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} @@ -384,7 +410,9 @@ function CardRow({ </div> )} </div> - {show.jobBar && job.progress && <JobProgressBar progress={job.progress} />} + {show.jobBar && job.progress && ( + <JobProgressBar progress={job.progress} /> + )} {show.tasks && <TaskList job={job} />} {showLog && <JobLogTail jobId={job.id} initiallyRunning />} </li> @@ -422,7 +450,10 @@ function TableRow({ 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"> + <Link + href={`/jobs/${j.id}`} + className="underline hover:text-foreground" + > {j.id} </Link> </td> diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx @@ -81,7 +81,7 @@ function LaneRow({ > log </Link> - <JobRowActions job={job} /> + <JobRowActions job={job} forceRelease="stuck" /> </span> )} </section> diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts @@ -4,7 +4,6 @@ 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 { reportStateOf } from "yt-dlp-transcript-common/views/channelRow"; import { cleanExtraFormatsBytesOf, cleanExtraFormatsCountOf, @@ -40,10 +39,6 @@ export type ActionableSummary = { digestWarnings: ActionableRow[]; }; -// `reportStateOf` lives in common/views/channelRow.ts now, beside the row -// builder that reads it; re-exported so every importer keeps its path. -export { reportStateOf }; - // 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 diff --git a/editor/app/operations/components/InFlightList.tsx b/editor/app/operations/components/InFlightList.tsx @@ -3,8 +3,22 @@ import type { AutoQueueKindStatus } from "yt-dlp-transcript-common/views/autoQueueStatus"; 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. @@ -47,7 +61,7 @@ export function InFlightList({ leaf ? ` · ${leafSentence(leaf, channels)}` : "" }`; const row = fromInFlight(item, { - kind: status.kind, + kind: UNIT_JOB_KIND[status.kind], runnerJobId: status.runner.jobId, detail, }); @@ -61,6 +75,10 @@ export function InFlightList({ 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} />