"use client"; import { Fragment, useState, type CSSProperties, type ReactNode } from "react"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { usePolledPayload, useNow } from "../../lib/usePolledPayload"; import { fmtTime } from "../lib/relativeTime"; import type { ActiveJobsPayload, DiskStatusView, } from "yt-dlp-transcript-common/views/activeJobs"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; 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"; import type { CleanablePayload, CleanableChannel, } from "yt-dlp-transcript-common/views/cleanable"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; import { buildWidgetQuery, patchWidgetConfig, type WidgetConfig, } from "../lib/config"; import { hasFillRow, type Cell, type Layout, type Row } from "../lib/placement"; import type { SectionId } from "../lib/sections"; import { SectionFrameProvider, WidgetSection, useSectionFrame, } from "./WidgetSection"; import { WidgetMenu } from "./WidgetMenu"; import { WidgetControls } from "./WidgetControls"; // Read-only monitor widget. Reuses the existing ~1s poll pattern from the // /jobs table / WorkersView against the same /api endpoints, but renders a // compact, control-free view meant to fit in a small pinned window or iframe. export function MonitorWidget({ config: initialConfig, initialJobs, initialWorkers, }: { config: WidgetConfig; initialJobs: ActiveJobsPayload | null; initialWorkers: WorkersPayload | null; }) { // The URL (parsed into `initialConfig` server-side) is authoritative, so we // seed state from the prop only — never localStorage — to keep the first // client render byte-identical to the server's. The in-widget settings gear // edits this state and mirrors it back into the address bar (see `patch`), so // a reload re-parses the same config. const [config, setConfig] = useState(initialConfig); const [settingsOpen, setSettingsOpen] = useState(false); // Drives the relative "5m ago" labels in the sync readouts; null until mounted. const now = useNow(); const pollMs = config.pollSeconds * 1000; // The controls row needs live `paused` state, so poll workers whenever either // the Workers section or the controls are shown. const workersEnabled = config.workers || config.controls; // The DISK strip rides this payload too, so the gate can't just be // `config.jobs`: `?jobs=0&disk=1` polled nothing and rendered nothing, with // 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( "/api/view/activeJobs", config.jobs || config.disk, pollMs, initialJobs, ); const { data: workersPayload, refetch: refetchWorkers } = usePolledPayload( "/api/view/workers", workersEnabled, pollMs, initialWorkers, ); // 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( "/api/view/cleanable", config.cleanable || config.cleanChannels, pollMs, null, ); // The worklist changes on job completions (minutes), not seconds, and reading // 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( "/api/view/widgetActionable", config.actionable, Math.max(pollMs, 15000), null, ); // Freshness/scheduler markers move on the order of minutes, so poll no faster // than every 15s regardless of the configured cadence. `refetchSync` lets the // Sync button refresh the readouts the moment a sweep is queued. // // FOUR sections read this one payload, not two. The gate used to name only // lastSync and scheduler, so `/widget?backfill=1` polled nothing and rendered // nothing at all unless one of those happened to be on too. Controls is in the // list because the backfill pause reads its lane state from here. const { data: syncData, refetch: refetchSync } = usePolledPayload( "/api/view/widgetSync", config.lastSync || config.scheduler || config.backfill || config.controls, Math.max(pollMs, 15000), null, ); // Apply a config change and mirror it into the address bar so the widget is // self-describing: a reload re-parses the same config and the link stays // copyable. `history.replaceState` (not router.replace) avoids a server // round-trip and the global AutoRefresh race. An all-default config drops the // query entirely. function patch(p: Partial) { const next = patchWidgetConfig(config, p); setConfig(next); const q = buildWidgetQuery(next); window.history.replaceState(null, "", q ? `?${q}` : window.location.pathname); } const jobs = (jobsPayload?.jobs ?? []).filter( (j) => !config.channel || j.channelSlug === config.channel, ); const workers = workersPayload?.workers ?? []; const disk = jobsPayload?.disk; const actionableChannels = actionablePayload?.channels ?? []; const cleanableChannels = cleanablePayload?.channels ?? []; const nothingActive = (!config.jobs || jobs.length === 0) && (!config.workers || workers.every((w) => !w.busy)); // The settings gear + its in-place overlay. Shared by the idle and full // renders so you can reconfigure a collapsed widget too. Positioned against // the widget's own relative container (or the viewport, for the overlay). const settingsLayer = ( setSettingsOpen((o) => !o)} onClose={() => setSettingsOpen(false)} config={config} onChange={patch} /> ); // A low-disk warning is worth showing even when nothing is running, since it // explains why no downloads start — so it overrides hideIdle. Interactive // controls also stay visible when idle (you may want to pause preemptively), // as do the Sync button and the freshness/scheduler readouts — checking sync // health is exactly what you do when nothing's active. const keepWhenIdle = config.controls || config.sync || config.lastSync || config.scheduler; if (config.hideIdle && nothingActive && !disk?.low && !keepWhenIdle) { return (
Idle {settingsLayer}
); } // What each placeable section renders. Only the `config.columns` layout // decides whether and where it appears — the guards left here are the ones // about data, not configuration (a strip with nothing to show yet). function renderSection(id: SectionId): ReactNode { switch (id) { case "controls": return ( ); case "lastSync": return syncData ? ( ) : null; case "scheduler": return syncData ? ( ) : null; case "disk": return disk?.enabled ? : null; case "cleanable": return cleanablePayload ? ( ) : null; case "cleanChannels": return ( ); case "workers": return ( ); case "jobs": return ( ); case "backfill": return syncData ? : null; case "actionable": return ( ); } } const layout = config.layout; // One fill row anywhere changes what the widget IS: content-height (the // pre-rows behaviour, and still the default) becomes exactly-its-frame, so the // fill rows have a leftover to share. `h-dvh` is right for both a pinned popup // and an