Archilyzer · Source

archilyzer

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

commit f40684a54269b56b0881a9b29830e88e3adc3cb8
parent 33f338c4760dfe4776408a717138af0184f13dbb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 19:24:46 -0400

editor: one client poller — usePolledPayload moves to lib/, three loops fold onto it

`widget/lib/usePolledPayload.ts` → `lib/usePolledPayload.ts` (git mv). It is
the client side of the one polling route: every poll now goes to
`/api/view/<name>` through it, cadence still a call-site argument.

Folded:
- JobsTable (1 s): `usePolledPayload<ActiveJobsPayload>("/api/view/activeJobs",
  polling, POLL_MS, null)`. `polling` mirrors `anyLive` via state adjusted during
  render, because the poll's result feeds the rows `anyLive` is derived from.
  An idle page still makes no requests; `data` is never nulled, so the last
  poll's `recent` rows still hold a finished job; freshest-snapshot-wins and
  mergeJobRows are untouched.
- useOperationsStatus (3 s) and SyncConsole (5 s): both were SSR-seeded and
  waited one interval before the first fetch; the hook gains
  `{ immediate: false }` to keep that, and its `refetch` gains the unmount guard
  both loops carried by hand. They were setInterval loops (a slow response
  could stack requests); the hook schedules each tick after the last settles.

Client URLs move to /api/view/*: pulse.ts, JobsTable, WorkersView,
useOperationsStatus, SyncConsole, DashboardCockpit (4), MonitorWidget (5).
`cache: "no-store"` stays on every call. auto-refresh.spec.ts counts the
BROWSER's pulse requests by path, so its one path check follows the client to
/api/view/pulse; no server-side assertion at an old path was touched.

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

Diffstat:
Meditor/app/components/dashboard/DashboardCockpit.tsx | 10+++++-----
Meditor/app/components/pulse.ts | 4++--
Meditor/app/jobs/components/JobsTable.tsx | 46++++++++++++++++++++++------------------------
Aeditor/app/lib/usePolledPayload.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/components/sync/SyncConsole.tsx | 33++++++++++++++++-----------------
Meditor/app/operations/components/useOperationsStatus.ts | 45+++++++++++++++++----------------------------
Meditor/app/widget/components/MonitorWidget.tsx | 12++++++------
Deditor/app/widget/lib/usePolledPayload.ts | 64----------------------------------------------------------------
Meditor/app/workers/components/WorkersView.tsx | 2+-
Meditor/e2e/auto-refresh.spec.ts | 4+++-
10 files changed, 165 insertions(+), 148 deletions(-)

diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx @@ -4,7 +4,7 @@ import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJob import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers"; import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; -import { usePolledPayload, useNow } from "../../widget/lib/usePolledPayload"; +import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { PipelineBand } from "./PipelineBand"; import { NeedsWorkPanel } from "./NeedsWorkPanel"; import { QuickAddPanel } from "./QuickAddPanel"; @@ -37,26 +37,26 @@ export function DashboardCockpit({ const now = useNow(); const { data: jobs } = usePolledPayload<ActiveJobsPayload>( - "/api/jobs/active", + "/api/view/activeJobs", true, FAST_MS, initial.jobs, ); const { data: workers, refetch: refetchWorkers } = usePolledPayload<WorkersPayload>( - "/api/workers", + "/api/view/workers", true, FAST_MS, initial.workers, ); const { data: actionable } = usePolledPayload<WidgetActionablePayload>( - "/api/widget/actionable", + "/api/view/widgetActionable", true, SLOW_MS, initial.actionable, ); const { data: sync, refetch: refetchSync } = usePolledPayload<WidgetSyncPayload>( - "/api/widget/sync", + "/api/view/widgetSync", true, SLOW_MS, initial.sync, diff --git a/editor/app/components/pulse.ts b/editor/app/components/pulse.ts @@ -57,8 +57,8 @@ async function poll(): Promise<void> { inFlight = true; try { const url = state.rev - ? `/api/pulse?rev=${encodeURIComponent(state.rev)}` - : "/api/pulse"; + ? `/api/view/pulse?rev=${encodeURIComponent(state.rev)}` + : "/api/view/pulse"; const res = await fetch(url, { cache: "no-store" }); if (!res.ok) return; const data = (await res.json()) as PulsePayload; diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx @@ -3,6 +3,7 @@ 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"; @@ -121,12 +122,28 @@ export function JobsTable({ }) { // null until mount → render everything (matches server HTML). const [filters, setFilters] = useState<JobsFilterState | null>(null); - const [polled, setPolled] = useState<ActiveJobsPayload | null>(null); useEffect(() => { setFilters(loadJobsFilters()); }, []); + // Whether the head poll runs. It is `anyLive` below, carried in state + // because the poll's result feeds the rows `anyLive` is derived from — the + // hook has to be called before the rows exist. Seeded from the props, which + // is exactly what `anyLive` is on the first render (no poll has landed). + const [polling, setPolling] = useState( + () => + initial.jobs.some(isLive) || + initial.recent.some(isLive) || + history.some(isLive), + ); + const { data: polled } = usePolledPayload<ActiveJobsPayload>( + "/api/view/activeJobs", + polling, + POLL_MS, + null, + ); + // FRESHEST SNAPSHOT WINS. A /jobs render served from the router cache // (staleTimes.dynamic) can be OLDER than the client's last poll, so naively // adopting a new `initial` prop would show a finished job as running again. @@ -146,29 +163,10 @@ export function JobsTable({ // the last poll's `recent` rows are what keeps a job that just finished on // screen until the paged tail catches up. const anyLive = head.some(isLive) || history.some(isLive); - - useEffect(() => { - if (!anyLive) return; - let cancelled = false; - let timer: ReturnType<typeof setTimeout> | null = null; - async function tick() { - try { - const res = await fetch("/api/jobs/active", { cache: "no-store" }); - if (res.ok && !cancelled) { - setPolled((await res.json()) as ActiveJobsPayload); - } - } catch { - // transient — keep polling - } finally { - if (!cancelled) timer = setTimeout(tick, POLL_MS); - } - } - void tick(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [anyLive]); + // Adjust-state-during-render (React's documented pattern for state derived + // from the previous render): React re-renders before committing, so the poll + // starts and stops on the same render the old `useEffect(…, [anyLive])` did. + if (polling !== anyLive) setPolling(anyLive); // Channel-less jobs by kind, so each lane's line can carry its own runner's // log link and controls. diff --git a/editor/app/lib/usePolledPayload.ts b/editor/app/lib/usePolledPayload.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// Shared client polling primitives. Extracted from the monitor widget so the +// dashboard cockpit could reuse the ~1s poll it proved out, and moved up to +// lib/ in one-core phase 3 slice 2 when it became THE client poller: the +// widget, the dashboard, /jobs, the operations board and the sync console all +// poll `/api/view/<name>` through this one hook, each at its own cadence (the +// cadence is a call-site argument on purpose — there is no batch endpoint). + +// Live wall-clock that re-renders once a second; null until mounted so SSR and +// the first client render agree (no Date.now() hydration mismatch). +export 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; +} + +// Generic poller: fetches `url` every `pollMs` while enabled, swallowing +// transient errors. Disabled (enabled=false) stops the timer and KEEPS the last +// value — it never nulls `data` — which /jobs relies on: its last poll's +// `recent` rows hold a just-finished job on screen after polling stops. +// Returns the latest payload plus a `refetch` so a control action can refresh +// it immediately instead of waiting for the next poll tick. +// +// The next tick is scheduled only after the previous fetch settles, so a slow +// response can never stack requests behind it. +// +// `immediate: false` skips the fetch on (re)subscribe and waits one interval +// first — for surfaces whose SSR seed is by construction fresh, where the +// first poll would re-fetch what the server just rendered. +export type PollOptions = { immediate?: boolean }; + +export function usePolledPayload<T>( + url: string, + enabled: boolean, + pollMs: number, + initial: T | null, + { immediate = true }: PollOptions = {}, +): { data: T | null; refetch: () => Promise<void> } { + const [data, setData] = useState<T | null>(initial); + // A `refetch` awaited by a control action can settle after the surface + // unmounted (the operations board and the sync console both guarded this by + // hand before they folded onto the hook). + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const refetch = useCallback(async () => { + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return; + const next = (await res.json()) as T; + if (mounted.current) setData(next); + } catch { + // transient — ignore + } + }, [url]); + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let timer: ReturnType<typeof setTimeout> | null = null; + async function tick() { + try { + const res = await fetch(url, { cache: "no-store" }); + if (res.ok && !cancelled) setData((await res.json()) as T); + } catch { + // transient — keep polling + } finally { + if (!cancelled) timer = setTimeout(tick, pollMs); + } + } + // By default fetch immediately on (re)subscribe, then poll on the + // interval. Sections seeded from the server get a harmless refresh; those + // with a null initial render on the first tick instead of after a full + // interval. + if (immediate) void tick(); + else timer = setTimeout(tick, pollMs); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [url, enabled, pollMs, immediate]); + return { data, refetch }; +} diff --git a/editor/app/operations/components/sync/SyncConsole.tsx b/editor/app/operations/components/sync/SyncConsole.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { SchedulerStatusPayload } from "yt-dlp-transcript-common/views/schedulerStatus"; +import { usePolledPayload } from "../../../lib/usePolledPayload"; import { BulkCadenceBar } from "./BulkCadenceBar"; import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; @@ -11,7 +12,7 @@ import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; // OperationDetail renders it where a runner or a sweep lane would otherwise be. // // TWO POLLS ON THIS PAGE, deliberately not merged: this one is 5s over -// /api/scheduler/status (the schedule, whose facts move on the order of +// /api/view/schedulerStatus (the schedule, whose facts move on the order of // minutes) and the rail above is 3s over the auto-queue payload (lane state, // which moves per dispatch). One combined endpoint would make the cheaper // reader pay the more expensive reader's cadence, and buildSchedulerStatusPayload @@ -21,33 +22,31 @@ export function SyncConsole({ }: { initial: SchedulerStatusPayload; }) { - const [data, setData] = useState<SchedulerStatusPayload>(initial); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<string | null>(null); // Slugs ticked for a bulk cadence edit. Kept as a Set of slugs (not indices) // so the 5s poll reordering or dropping a row can't retarget a selection. const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); + // Seeded by SSR; the first poll waits one interval, as before. + const { data: polled, refetch: refresh } = + usePolledPayload<SchedulerStatusPayload>( + "/api/view/schedulerStatus", + true, + 5000, + initial, + { immediate: false }, + ); + // Never null: the hook starts from `initial` and never clears it. + const data = polled ?? initial; + // Guards the Run-now button's own state (busy/message) after unmount; the + // payload's guard is the hook's. const mounted = useRef(true); - - const refresh = useCallback(async () => { - try { - const res = await fetch("/api/scheduler/status", { cache: "no-store" }); - if (!res.ok) return; - const next = (await res.json()) as SchedulerStatusPayload; - if (mounted.current) setData(next); - } catch { - /* transient; the next poll retries */ - } - }, []); - useEffect(() => { mounted.current = true; - const id = setInterval(refresh, 5000); return () => { mounted.current = false; - clearInterval(id); }; - }, [refresh]); + }, []); const runNow = useCallback(async () => { setBusy(true); diff --git a/editor/app/operations/components/useOperationsStatus.ts b/editor/app/operations/components/useOperationsStatus.ts @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/autoQueueStatus"; +import { usePolledPayload } from "../../lib/usePolledPayload"; // THE ONE POLL. Every operations surface — the board and each operation page — // reads the same payload from the same endpoint on the same 3-second cadence, @@ -11,37 +12,25 @@ import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/auto // are read TOGETHER, and two polls would let the rail and the lane below it // disagree about the same moment. // -// The endpoint keeps its /api/auto-queue/* path. It is addressed directly by -// three specs and by nothing user-facing, so renaming it would be churn with a -// test bill and no reader. +// It polls /api/view/autoQueueStatus. The old /api/auto-queue/status path is a +// rewrite onto the same handler (next.config.ts) — three specs still address +// it there, and that is the rewrite's regression test. export function useOperationsStatus(initial: AutoQueueStatusPayload): { data: AutoQueueStatusPayload; refresh: () => Promise<void>; } { - const [data, setData] = useState<AutoQueueStatusPayload>(initial); - const mounted = useRef(true); - - const refresh = useCallback(async () => { - try { - const res = await fetch("/api/auto-queue/status", { cache: "no-store" }); - if (!res.ok) return; - const next = (await res.json()) as AutoQueueStatusPayload; - if (mounted.current) setData(next); - } catch { - /* transient; next poll retries */ - } - }, []); - - useEffect(() => { - mounted.current = true; - const id = setInterval(refresh, 3000); - return () => { - mounted.current = false; - clearInterval(id); - }; - }, [refresh]); - - return { data, refresh }; + // Seeded by SSR, so the first poll waits one interval (`immediate: false`), + // as the hand-rolled loop this replaced did. Always enabled: the board is + // live whenever it is open. + const { data, refetch } = usePolledPayload<AutoQueueStatusPayload>( + "/api/view/autoQueueStatus", + true, + 3000, + initial, + { immediate: false }, + ); + // Never null: the hook starts from `initial` and never clears it. + return { data: data ?? initial, refresh: refetch }; } // "React is live on this subtree" — the same signal `now !== null` gives the diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -2,7 +2,7 @@ import { Fragment, useState, type CSSProperties, type ReactNode } from "react"; import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format"; -import { usePolledPayload, useNow } from "../lib/usePolledPayload"; +import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { fmtTime } from "../lib/relativeTime"; import type { ActiveJobsPayload, @@ -69,14 +69,14 @@ export function MonitorWidget({ // no clue that it was the gate rather than the disk gate being off. Same // pre-existing shape as the sync payload's gate below. const { data: jobsPayload } = usePolledPayload<ActiveJobsPayload>( - "/api/jobs/active", + "/api/view/activeJobs", config.jobs || config.disk, pollMs, initialJobs, ); const { data: workersPayload, refetch: refetchWorkers } = usePolledPayload<WorkersPayload>( - "/api/workers", + "/api/view/workers", workersEnabled, pollMs, initialWorkers, @@ -84,7 +84,7 @@ export function MonitorWidget({ // One poll serves both cleanable sections: the total-bytes strip and the // per-channel "Needs cleaning" list come from the same payload. const { data: cleanablePayload } = usePolledPayload<CleanablePayload>( - "/api/widget/cleanable", + "/api/view/cleanable", config.cleanable || config.cleanChannels, pollMs, null, @@ -93,7 +93,7 @@ export function MonitorWidget({ // every channel snapshot each tick is heavier than the other endpoints — so // poll it no faster than every 15s regardless of the configured cadence. const { data: actionablePayload } = usePolledPayload<WidgetActionablePayload>( - "/api/widget/actionable", + "/api/view/widgetActionable", config.actionable, Math.max(pollMs, 15000), null, @@ -108,7 +108,7 @@ export function MonitorWidget({ // list because the backfill pause reads its lane state from here. const { data: syncData, refetch: refetchSync } = usePolledPayload<WidgetSyncPayload>( - "/api/widget/sync", + "/api/view/widgetSync", config.lastSync || config.scheduler || config.backfill || config.controls, Math.max(pollMs, 15000), null, diff --git a/editor/app/widget/lib/usePolledPayload.ts b/editor/app/widget/lib/usePolledPayload.ts @@ -1,64 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -// Shared client polling primitives, extracted from the monitor widget so the -// dashboard cockpit (editor/app/components/dashboard) can reuse the exact same -// ~1s poll pattern the widget proved out against the /api endpoints. - -// Live wall-clock that re-renders once a second; null until mounted so SSR and -// the first client render agree (no Date.now() hydration mismatch). -export 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; -} - -// Generic poller: fetches `url` every `pollMs` while enabled, swallowing -// transient errors. Disabled (enabled=false) leaves the initial value as-is. -// Returns the latest payload plus a `refetch` so a control action can refresh -// it immediately instead of waiting for the next poll tick. -export function usePolledPayload<T>( - url: string, - enabled: boolean, - pollMs: number, - initial: T | null, -): { data: T | null; refetch: () => Promise<void> } { - const [data, setData] = useState<T | null>(initial); - const refetch = useCallback(async () => { - try { - const res = await fetch(url, { cache: "no-store" }); - if (res.ok) setData((await res.json()) as T); - } catch { - // transient — ignore - } - }, [url]); - useEffect(() => { - if (!enabled) return; - let cancelled = false; - let timer: ReturnType<typeof setTimeout> | null = null; - async function tick() { - try { - const res = await fetch(url, { cache: "no-store" }); - if (res.ok && !cancelled) setData((await res.json()) as T); - } catch { - // transient — keep polling - } finally { - if (!cancelled) timer = setTimeout(tick, pollMs); - } - } - // Fetch immediately on (re)subscribe, then poll on the interval. Sections - // seeded from the server get a harmless refresh; those with a null initial - // render on the first tick instead of after a full interval. - void tick(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [url, enabled, pollMs]); - return { data, refetch }; -} diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx @@ -44,7 +44,7 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) { const refetch = useCallback(async () => { try { - const res = await fetch("/api/workers", { cache: "no-store" }); + const res = await fetch("/api/view/workers", { cache: "no-store" }); if (res.ok) setPayload((await res.json()) as WorkersPayload); } catch { // transient — keep polling diff --git a/editor/e2e/auto-refresh.spec.ts b/editor/e2e/auto-refresh.spec.ts @@ -104,7 +104,9 @@ test.describe("auto-refresh behavior", () => { let pulses = 0; page.on("request", (req) => { const url = new URL(req.url()); - if (url.pathname === "/api/pulse") { + // The client polls /api/view/pulse; /api/pulse is a rewrite onto it + // for pollers we cannot update. This counts what the browser SENDS. + if (url.pathname === "/api/view/pulse") { pulses++; return; }