Archilyzer · Source

archilyzer

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

commit e5994b34c7951a2f33c3f808b18326034caf767e
parent 91e4583e2c1517cc7b2f7ff9f24c758724f60427
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 13:00:12 -0400

dashboard: the shared channel table; the widgetActionable copy folded

app/page.tsx builds `ChannelRowView`s with the shared builder (no bands,
neutral priority, the media label still named from the media target) and
seeds "Needs work" with `buildWidgetActionablePayload(widgetActionableRows(
rows))` — the two functions the /api/view/widgetActionable handler serves —
instead of a hand copy of the filter and sort. DashboardCockpit draws the
shared ChannelsTable with DASHBOARD_COLUMNS, `rowLabelPrefix="channel"`, no
sort, no selection; the row's actions are ChannelSyncButton (replacing the
private InlineSyncButton, same `sync <slug>` name), Download missing, and
PrioritizeButton, moved verbatim to components/actions/. Deleted:
components/dashboard/ChannelsTable.tsx and types.ts.

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

Diffstat:
Aeditor/app/components/actions/PrioritizeButton.tsx | 41+++++++++++++++++++++++++++++++++++++++++
Deditor/app/components/dashboard/ChannelsTable.tsx | 192-------------------------------------------------------------------------------
Meditor/app/components/dashboard/DashboardCockpit.tsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Deditor/app/components/dashboard/types.ts | 30------------------------------
Meditor/app/page.tsx | 89++++++++++++++++++++++++++++++++-----------------------------------------------
5 files changed, 142 insertions(+), 279 deletions(-)

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/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/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(),