Archilyzer · Source

archilyzer

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

commit e8d12bdc06e4d914f63ebe28e9322fb9ae55c406
parent 3bea5b510b9e7fdde09b27bbdbedce0fe79c1fcf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 20 Jul 2026 15:36:01 -0400

Widget: Sync button + last-sync & scheduler readouts (configurable)

Add five independent, default-off URL flags to the monitor widget, each
toggleable in the builder and the in-widget gear:

- sync    — channel-aware Sync button (per-channel stream-drain when pinned
            to ?channel=X, else syncAllChannelsAction sweep w/ queued/skipped)
- lastsync — last-sync readout: "Last full sync" from a new persisted
            lastSyncAllAt marker, plus "Last channel sync" when newer
- sched   — auto-sync scheduler strip (on/off · next · last run) via
            buildScheduleView
- abstime — readouts show absolute locale time instead of relative "5m ago"
- syncask — window.confirm before a full Sync-all sweep

Persist lastSyncAllAt in SchedulerState (written at the end of
syncAllChannelsAction). New lightweight /api/widget/sync route feeds the two
readouts (few scalars, 15s poll floor). Sync/controls/readouts also keep the
widget from collapsing to "Idle". Existing links render unchanged.

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

Diffstat:
Mcommon/jobs/syncSchedulerState.ts | 19+++++++++++++++++--
Meditor/CHANGELOG.md | 1+
Aeditor/app/api/widget/sync/route.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/actions.ts | 10++++++++++
Meditor/app/widget/components/MonitorWidget.tsx | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/app/widget/components/WidgetConfigForm.tsx | 29+++++++++++++++++++++++++++++
Meditor/app/widget/components/WidgetControls.tsx | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/app/widget/lib/config.ts | 35+++++++++++++++++++++++++++++++++++
Meditor/e2e/widget.spec.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 488 insertions(+), 14 deletions(-)

diff --git a/common/jobs/syncSchedulerState.ts b/common/jobs/syncSchedulerState.ts @@ -46,6 +46,10 @@ export type SchedulerState = { // per-channel, job). Drives the savedVideoBackup.intervalMinutes cadence. // null = never run. See editor/app/scheduler/runTick.ts. lastSavedVideoBackupAt: number | null; + // Epoch ms of the last manual full "Sync all" sweep (syncAllChannelsAction). + // A global freshness marker distinct from any single channel's lastSyncedAt; + // surfaced by the monitor widget's last-sync readout. null = never run. + lastSyncAllAt: number | null; }; export const SCHEDULER_RUN_LOG_LIMIT = 50; @@ -63,7 +67,12 @@ export function emptyChannelSyncState(): ChannelSyncState { } export function emptySchedulerState(): SchedulerState { - return { channels: {}, runs: [], lastSavedVideoBackupAt: null }; + return { + channels: {}, + runs: [], + lastSavedVideoBackupAt: null, + lastSyncAllAt: null, + }; } // Read the state file, tolerating a missing/corrupt file by returning an empty @@ -89,7 +98,12 @@ export async function readSchedulerState(paths: Paths): Promise<SchedulerState> const runs: SchedulerRun[] = Array.isArray(r.runs) ? r.runs.map(coerceRun).slice(0, SCHEDULER_RUN_LOG_LIMIT) : []; - return { channels, runs, lastSavedVideoBackupAt: num(r.lastSavedVideoBackupAt) }; + return { + channels, + runs, + lastSavedVideoBackupAt: num(r.lastSavedVideoBackupAt), + lastSyncAllAt: num(r.lastSyncAllAt), + }; } // Write the state atomically (tmp file + rename), creating the .scheduler dir on @@ -102,6 +116,7 @@ export async function writeSchedulerState( channels: state.channels, runs: state.runs.slice(0, SCHEDULER_RUN_LOG_LIMIT), lastSavedVideoBackupAt: state.lastSavedVideoBackupAt ?? null, + lastSyncAllAt: state.lastSyncAllAt ?? null, }; await mkdir(path.dirname(paths.schedulerStateFile), { recursive: true }); const tmp = `${paths.schedulerStateFile}.tmp-${process.pid}`; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The monitor widget can now start a sync and shows sync freshness + scheduler health — each behind its own flag.** The widget was start-a-sync-less and said nothing about how fresh your channels were. Five new opt-in URL flags, all toggleable in the builder and the in-widget gear (all default off, so existing links are unchanged): **(1) a channel-aware Sync button** (`sync=1`) — pinned to one channel (`channel=X`) it runs that channel's streaming sync; otherwise it sweeps all channels (`syncAllChannelsAction`), briefly showing `Queued X · skipped Y`. **(2) A last-sync readout** (`lastsync=1`) — `Last full sync: …` from a new persisted `lastSyncAllAt` marker written at the end of each Sync-all sweep, plus a `Last channel sync: …` line when an individual channel synced more recently. **(3) A scheduler-status strip** (`sched=1`) — `Auto-sync on/off · next … · last run …`, derived from the same `buildScheduleView` the `/scheduler` page uses. **(4) An absolute-time toggle** (`abstime=1`) — readouts show locale timestamps instead of relative "5m ago". **(5) A Sync-all confirm** (`syncask=1`) — a `window.confirm` before a full sweep. The two readouts poll a new lightweight `/api/widget/sync` route (a few scalars, 15s floor) and, with the Sync button/controls, keep the widget from collapsing to "Idle" — sync health is exactly what you check when nothing's running. See `editor/app/widget/lib/config.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls,WidgetConfigForm}.tsx`, the new `editor/app/api/widget/sync/route.ts`, `common/jobs/syncSchedulerState.ts` (`lastSyncAllAt`), `editor/app/channels/actions.ts`, and `editor/e2e/widget.spec.ts`. - **The audio-integrity check now tightens its probe interval when a source starts serving corruption, then relaxes as it stabilises.** Previously the integrity probe ran on a fixed cadence (default 60s) for the whole download, so up to ~60s of bytes were downloaded — and discarded — between a corruption event and the checkpoint that caught it. The interval is now adaptive (AIMD, like TCP congestion control, inverted): each **malformed** checkpoint **halves** the live interval (60→30→15→10s, floored at the existing `AUDIO_CHECK_INTERVAL_MIN_SECONDS` of 10s), so a misbehaving source gets probed more aggressively and wastes fewer bytes per rollback; a run of clean checkpoints then **steps it back up** additively (+15s after every 2 clean probes) toward the configured interval. The reduced cadence persists across yt-dlp relaunches for the rest of the download run. Fully backward compatible — a clean download never leaves the configured interval. Tunable via constants in `common/lib/channelConfig.ts` (`AUDIO_CHECK_INTERVAL_BACKOFF_FACTOR_DEFAULT`, `AUDIO_CHECK_INTERVAL_RECOVER_STEP_SECONDS`, `AUDIO_CHECK_INTERVAL_RECOVER_AFTER_CLEAN`) plus test-only env overrides. See `common/ytdlp/audioCheckCadence.ts` (pure AIMD math + `audioCheckCadence.test.ts`) and `common/ytdlp/audioCheckedDownload.ts` (`resolveKnobs`, the watcher loop, and the advance/malformed checkpoint branches). - **Docker build mode is now real: build every site in parallel, then deploy them serially.** The `Docker` build mode (Settings → Build pipeline) was previously a stub that fell back to the basic build. It now runs a proper pipeline, driven by a new **Build all sites** control on the Deploy page (one job, one log, one Cancel). The shared, corpus-scale work — the search index, the per-site staging, and the downloadable archive zips — runs **once on the host**; then each site's `compose + next build` runs in its **own container in parallel** (capped by the **Max parallel builds** setting), each writing an isolated per-site `out/` under `export/.export-builds/<siteId>/`; then the built sites **deploy serially** on the host (R2 upload + `wrangler pages deploy`), tolerant of a single site failing. Containers are read-only over the shared corpus/index/archive cache and run as your host user so outputs aren't root-owned. The image (`Dockerfile.build`, tag from **Build image**) is built/reused via Docker layer caching; when no container engine is available the action falls back to a serial host build+deploy. New env knobs: `DOCKER_BIN` (e.g. `podman`), `DOCKER_BUILD_MEMORY`/`DOCKER_BUILD_CPUS` (per-container caps). See `editor/app/deploy/buildDeployCore.ts` (`runDockerBuildAllPhase`/`runDockerDeployAllPhase`), `editor/app/build/buildAction.ts` (`buildAndDeployAllSitesAction`/`buildAllSitesAction`), `editor/app/deploy/components/BuildAllSitesButton.tsx`, `Dockerfile.build`, `docker/build-site.sh`, `common/bin/build-archives.ts`, and **[DEPLOY_DOCKER.md](../DEPLOY_DOCKER.md)**. diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { listChannels } from "yt-dlp-transcript-common/controller/channels"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { buildScheduleView } from "yt-dlp-transcript-common/jobs/syncScheduler"; +import { readSchedulerState } from "yt-dlp-transcript-common/jobs/syncSchedulerState"; + +export const dynamic = "force-dynamic"; + +// Backs the monitor widget's last-sync and scheduler-status strips. Keeps the +// payload tiny (a handful of scalars) rather than shipping the full +// scheduler-status payload on every poll — the widget only needs freshness +// markers and a one-line scheduler summary. Reuses the same helpers the +// /scheduler status page does so the two never drift. +export type WidgetSyncPayload = { + // Epoch ms of the last manual full "Sync all" sweep. null = never. + lastSyncAllAt: number | null; + // Newest per-channel lastSyncedAt across all channels. null = none synced. + lastIndividualSyncAt: number | null; + scheduler: { + enabled: boolean; + lastRunAt: number | null; // last scheduled sweep (state.runs[0].at) + nextRunAt: number | null; // soonest eligible channel's nextDueAt + overdue: boolean; // some eligible channel is due now + }; +}; + +export async function GET() { + const paths = getPaths(); + const settings = getSettings(); + const state = await readSchedulerState(paths); + const now = Date.now(); + const channels = await listChannels(paths); + + let lastIndividualSyncAt: number | null = null; + for (const c of channels) { + const last = c.config.lastSyncedAt; + if (!last) continue; + const parsed = Date.parse(last); + if (Number.isNaN(parsed)) continue; + if (lastIndividualSyncAt === null || parsed > lastIndividualSyncAt) { + lastIndividualSyncAt = parsed; + } + } + + const view = buildScheduleView({ + channels: channels.map((c) => ({ slug: c.slug, config: c.config })), + scheduler: settings.syncScheduler, + state, + now, + }); + const eligible = view.filter((v) => v.autoSyncEligible); + let nextRunAt: number | null = null; + let overdue = false; + for (const v of eligible) { + if (v.overdue) overdue = true; + if (v.nextDueAt !== null && (nextRunAt === null || v.nextDueAt < nextRunAt)) { + nextRunAt = v.nextDueAt; + } + } + + return NextResponse.json({ + lastSyncAllAt: state.lastSyncAllAt, + lastIndividualSyncAt, + scheduler: { + enabled: settings.syncScheduler.enabled, + lastRunAt: state.runs[0]?.at ?? null, + nextRunAt, + overdue, + }, + } satisfies WidgetSyncPayload); +} diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -25,6 +25,10 @@ import { } from "yt-dlp-transcript-common/lib/site"; import { activeSyncSlugs } from "yt-dlp-transcript-common/jobs/syncJobs"; import { + readSchedulerState, + writeSchedulerState, +} from "yt-dlp-transcript-common/jobs/syncSchedulerState"; +import { CHANNEL_FORM_FIELDS, parseChannelForm, } from "./components/parseChannelForm"; @@ -223,6 +227,12 @@ export async function syncAllChannelsAction(): Promise<SyncAllResult> { // runManagedFunction's onLog regardless. void result.stream.cancel(); } + // Record the sweep's freshness marker for the monitor widget's last-sync + // readout. Read-modify-write right before the write keeps the clobber window + // vs. a concurrent scheduler tick minimal (single-user editor — acceptable). + const state = await readSchedulerState(paths); + state.lastSyncAllAt = Date.now(); + await writeSchedulerState(paths, state); return { queued, skipped }; } diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -11,6 +11,7 @@ import { jobKindLabel } from "../../jobs/jobKindLabels"; import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView"; import { InlineActionButton } from "../../actionable/components/InlineActionButton"; import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { WidgetSyncPayload } from "../../api/widget/sync/route"; import { buildWidgetQuery, type WidgetConfig } from "../lib/config"; import { WidgetConfigForm } from "./WidgetConfigForm"; import { WidgetControls } from "./WidgetControls"; @@ -93,6 +94,8 @@ export function MonitorWidget({ // a reload re-parses the same config. const [config, setConfig] = useState<WidgetConfig>(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 @@ -126,6 +129,16 @@ export function MonitorWidget({ 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. + const { data: syncData, refetch: refetchSync } = + usePolledPayload<WidgetSyncPayload>( + "/api/widget/sync", + config.lastSync || config.scheduler, + 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 @@ -167,8 +180,12 @@ export function MonitorWidget({ // 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). - if (config.hideIdle && nothingActive && !disk?.low && !config.controls) { + // 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 ( <div className="relative p-2 text-xs text-muted-foreground" @@ -183,12 +200,23 @@ export function MonitorWidget({ return ( <div className="relative flex flex-col gap-3 p-2 text-foreground"> {settingsLayer} - {config.controls && ( + {(config.controls || config.sync) && ( <WidgetControls + controls={config.controls} + sync={config.sync} + channel={config.channel} + confirmSyncAll={config.syncConfirm} paused={workersPayload?.paused ?? false} onWorkersChange={refetchWorkers} + onSynced={refetchSync} /> )} + {config.lastSync && syncData && ( + <LastSyncStrip data={syncData} now={now} absolute={config.syncTimeAbsolute} /> + )} + {config.scheduler && syncData && ( + <SchedulerStrip data={syncData} now={now} absolute={config.syncTimeAbsolute} /> + )} {config.disk && disk?.enabled && <DiskStrip disk={disk} />} {config.cleanable && cleanablePayload && ( <CleanableStrip bytes={cleanablePayload.bytes} /> @@ -329,6 +357,118 @@ function DiskStrip({ disk }: { disk: DiskStatusView }) { ); } +// Compact relative label. `deltaMs = now - target`: positive is past ("5m +// ago"), negative is future ("in 5m"). Steps s → m → h → d. +function relativeTime(deltaMs: number): string { + const past = deltaMs >= 0; + const s = Math.round(Math.abs(deltaMs) / 1000); + const val = + s < 60 + ? `${s}s` + : s < 3600 + ? `${Math.round(s / 60)}m` + : s < 86400 + ? `${Math.round(s / 3600)}h` + : `${Math.round(s / 86400)}d`; + return past ? `${val} ago` : `in ${val}`; +} + +// Format an epoch-ms marker per the widget's abstime flag. Absolute is +// now-independent (locale string); relative depends on the live clock, so it +// returns null until `now` is set (post-mount) to avoid an SSR/hydration +// mismatch and a nonsensical "0s ago" flash. Callers render "…" for null. +function fmtTime( + ms: number, + now: number | null, + absolute: boolean, +): string | null { + if (absolute) return new Date(ms).toLocaleString(); + if (now === null) return null; + return relativeTime(now - ms); +} + +// Last-sync freshness readout: the last full "Sync all" sweep, plus the newest +// individual channel sync when it is more recent than that sweep (a per-channel +// Sync since the last global one). +function LastSyncStrip({ + data, + now, + absolute, +}: { + data: WidgetSyncPayload; + now: number | null; + absolute: boolean; +}) { + const full = + data.lastSyncAllAt === null + ? "never" + : (fmtTime(data.lastSyncAllAt, now, absolute) ?? "…"); + const showChannel = + data.lastIndividualSyncAt !== null && + (data.lastSyncAllAt === null || + data.lastIndividualSyncAt > data.lastSyncAllAt); + return ( + <section + aria-label="Last sync" + className="flex flex-col gap-0.5 rounded border border-border bg-card px-2 py-1 text-xs text-muted-foreground" + > + <span className="tabular-nums">Last full sync: {full}</span> + {showChannel && ( + <span className="tabular-nums"> + Last channel sync:{" "} + {fmtTime(data.lastIndividualSyncAt as number, now, absolute) ?? "…"} + </span> + )} + </section> + ); +} + +// Auto-sync scheduler status: on/off (colored dot), next due, last run. Dims to +// a neutral line when the scheduler is disabled. +function SchedulerStrip({ + data, + now, + absolute, +}: { + data: WidgetSyncPayload; + now: number | null; + absolute: boolean; +}) { + const s = data.scheduler; + const nextText = s.overdue + ? "due now" + : s.nextRunAt !== null + ? (fmtTime(s.nextRunAt, now, absolute) ?? "…") + : "—"; + const lastText = + s.lastRunAt !== null ? (fmtTime(s.lastRunAt, now, absolute) ?? "…") : "never"; + return ( + <section + aria-label="Auto-sync scheduler" + className={`flex items-center gap-2 rounded border px-2 py-1 text-xs ${ + s.enabled + ? "border-border bg-card text-muted-foreground" + : "border-border bg-card text-muted-foreground/60" + }`} + > + <span + className={`inline-block h-2 w-2 shrink-0 rounded-full ${ + s.enabled ? "bg-success" : "bg-muted-foreground/50" + }`} + /> + <span className="tabular-nums"> + Auto-sync {s.enabled ? "on" : "off"} + {s.enabled && ( + <> + {" · "}next {nextText} + {" · "}last run {lastText} + </> + )} + </span> + </section> + ); +} + type CleanablePayload = { bytes: number }; function CleanableStrip({ bytes }: { bytes: number }) { diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx @@ -94,6 +94,35 @@ export function WidgetConfigForm({ /> </fieldset> + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium mb-1">Sync</legend> + <Check + label="Sync button" + checked={config.sync} + onChange={(v) => onChange({ sync: v })} + /> + <Check + label="Confirm before Sync all" + checked={config.syncConfirm} + onChange={(v) => onChange({ syncConfirm: v })} + /> + <Check + label="Last-sync readout" + checked={config.lastSync} + onChange={(v) => onChange({ lastSync: v })} + /> + <Check + label="Scheduler status" + checked={config.scheduler} + onChange={(v) => onChange({ scheduler: v })} + /> + <Check + label="Absolute timestamps" + checked={config.syncTimeAbsolute} + onChange={(v) => onChange({ syncTimeAbsolute: v })} + /> + </fieldset> + <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Channel filter (slug, optional)</span> <input diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx @@ -1,30 +1,149 @@ "use client"; +import { useState } from "react"; import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton"; import { DrainAllButton } from "../../jobs/components/DrainAllButton"; import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton"; +import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions"; +import { syncAction } from "../../channels/[slug]/pipelineActions"; -// Opt-in interactive controls for the monitor widget (enabled with controls=1). -// Reuses the same actions/buttons as the Workers and Active Jobs pages so a -// pinned widget can free the GPU (pause), wind work down (drain), or recover -// failures (retry) without opening the full app. `onWorkersChange` refetches the -// widget's worker payload so the pause/resume label flips immediately instead of -// waiting for the next poll. +// Opt-in interactive controls for the monitor widget. Two independent +// capabilities, each behind its own flag: +// - `controls` → Pause/Resume + Drain + Retry (reused verbatim from the +// Workers / Active Jobs pages), so a pinned widget can free the GPU, wind +// work down, or recover failures without opening the full app. +// - `sync` → a channel-aware Sync button: pinned to one channel it syncs just +// that channel (draining the per-channel stream); otherwise it sweeps all +// channels. +// `onWorkersChange` refetches the worker payload so the pause/resume label flips +// immediately; `onSynced` refetches the last-sync/scheduler readouts so they +// update as soon as a sweep is queued. export function WidgetControls({ + controls, + sync, + channel, + confirmSyncAll, paused, onWorkersChange, + onSynced, }: { + controls: boolean; + sync: boolean; + channel?: string; + confirmSyncAll: boolean; paused: boolean; onWorkersChange: () => void | Promise<void>; + onSynced: () => void | Promise<void>; }) { return ( <section aria-label="Controls" className="flex flex-wrap items-center gap-1.5" > - <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} /> - <DrainAllButton /> - <RetryAllFailedButton /> + {controls && ( + <> + <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} /> + <DrainAllButton /> + <RetryAllFailedButton /> + </> + )} + {sync && ( + <SyncButton + channel={channel} + confirmSyncAll={confirmSyncAll} + onSynced={onSynced} + /> + )} </section> ); } + +// The Sync button. Channel-aware: with `channel` set it runs the streaming +// per-channel sync (draining the stream to completion, exactly as +// ChannelSyncButton); otherwise it runs the full Sync-all sweep (optionally +// gated by a confirm) and briefly surfaces the queued/skipped counts. +function SyncButton({ + channel, + confirmSyncAll, + onSynced, +}: { + channel?: string; + confirmSyncAll: boolean; + onSynced: () => void | Promise<void>; +}) { + const [running, setRunning] = useState(false); + const [error, setError] = useState<string | null>(null); + const [result, setResult] = useState<SyncAllResult | null>(null); + + async function handleClick() { + setError(null); + setResult(null); + if (channel) { + setRunning(true); + try { + const res = await syncAction(channel); + if (!res.ok) { + setError(res.error); + return; + } + // Drain the stream to completion; the job keeps running server-side. + const reader = res.stream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } catch (e) { + setError((e as Error).message); + } finally { + setRunning(false); + await onSynced(); + } + return; + } + if (confirmSyncAll && !window.confirm("Sync all channels now?")) return; + setRunning(true); + try { + const res = await syncAllChannelsAction(); + setResult(res); + } catch (e) { + setError((e as Error).message); + } finally { + setRunning(false); + await onSynced(); + } + } + + const label = channel ? "Sync" : "Sync all"; + const ariaLabel = channel ? `sync ${channel}` : "sync all channels"; + return ( + <div className="flex items-center gap-2"> + <button + type="button" + onClick={handleClick} + disabled={running} + aria-label={ariaLabel} + className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {running ? "Syncing…" : label} + </button> + {result && ( + <span + aria-label="sync all result" + className="text-xs text-muted-foreground tabular-nums" + title={ + result.skipped.length === 0 + ? undefined + : result.skipped.map((s) => `${s.slug}: ${s.reason}`).join("\n") + } + > + Queued {result.queued.length} · skipped {result.skipped.length} + </span> + )} + {error && ( + <span role="alert" className="text-xs text-destructive"> + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts @@ -42,6 +42,21 @@ export type WidgetConfig = { // Show the in-widget settings gear that opens the config overlay in place. On // by default; turn off to bake a locked-down shared/embedded link. settings: boolean; + // Show the interactive Sync button. Channel-aware: syncs just `channel` when + // the widget is pinned to one, else sweeps all channels. Off by default. + sync: boolean; + // Show the last-sync readout strip (last full sync-all + last individual + // channel sync when newer). Off by default. + lastSync: boolean; + // Show the auto-sync scheduler status strip (on/off, next due, last run). Off + // by default. + scheduler: boolean; + // Readouts show absolute (locale) time instead of relative "5m ago". Off by + // default (relative). + syncTimeAbsolute: boolean; + // window.confirm before a full Sync-all sweep. Off by default. (A pinned + // single-channel Sync is never gated.) + syncConfirm: boolean; }; export const WIDGET_DEFAULTS: WidgetConfig = { @@ -61,6 +76,11 @@ export const WIDGET_DEFAULTS: WidgetConfig = { controls: false, actionable: false, settings: true, + sync: false, + lastSync: false, + scheduler: false, + syncTimeAbsolute: false, + syncConfirm: false, }; // Next's searchParams give each key as string | string[] | undefined. @@ -106,6 +126,11 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig { controls: parseBool(params.controls, WIDGET_DEFAULTS.controls), actionable: parseBool(params.act, WIDGET_DEFAULTS.actionable), settings: parseBool(params.gear, WIDGET_DEFAULTS.settings), + sync: parseBool(params.sync, WIDGET_DEFAULTS.sync), + lastSync: parseBool(params.lastsync, WIDGET_DEFAULTS.lastSync), + scheduler: parseBool(params.sched, WIDGET_DEFAULTS.scheduler), + syncTimeAbsolute: parseBool(params.abstime, WIDGET_DEFAULTS.syncTimeAbsolute), + syncConfirm: parseBool(params.syncask, WIDGET_DEFAULTS.syncConfirm), }; } @@ -141,5 +166,15 @@ export function buildWidgetQuery(config: WidgetConfig): string { sp.set("act", config.actionable ? "1" : "0"); if (config.settings !== WIDGET_DEFAULTS.settings) sp.set("gear", config.settings ? "1" : "0"); + if (config.sync !== WIDGET_DEFAULTS.sync) + sp.set("sync", config.sync ? "1" : "0"); + if (config.lastSync !== WIDGET_DEFAULTS.lastSync) + sp.set("lastsync", config.lastSync ? "1" : "0"); + if (config.scheduler !== WIDGET_DEFAULTS.scheduler) + sp.set("sched", config.scheduler ? "1" : "0"); + if (config.syncTimeAbsolute !== WIDGET_DEFAULTS.syncTimeAbsolute) + sp.set("abstime", config.syncTimeAbsolute ? "1" : "0"); + if (config.syncConfirm !== WIDGET_DEFAULTS.syncConfirm) + sp.set("syncask", config.syncConfirm ? "1" : "0"); return sp.toString(); } diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -99,6 +99,42 @@ test("controls=1 adds interactive Pause/Resume, Drain, and Retry buttons", async ).toBeVisible({ timeout: 10_000 }); }); +test("sync=1 shows a channel-aware Sync button", async ({ page }) => { + // No channel pinned → sweeps all channels. + await page.goto("/widget?sync=1"); + const syncAll = page.getByRole("button", { name: "sync all channels" }); + await expect(syncAll).toBeVisible(); + await expect(syncAll).toHaveText("Sync all"); + + // Empty corpus → the sweep queues nothing and reports its counts. + await syncAll.click(); + await expect(page.getByLabel("sync all result")).toContainText( + "Queued 0 · skipped 0", + { timeout: 10_000 }, + ); + + // Pinned to one channel → the button targets just that channel. + await page.goto("/widget?sync=1&channel=test-youtube"); + const syncOne = page.getByRole("button", { name: "sync test-youtube" }); + await expect(syncOne).toBeVisible(); + await expect(syncOne).toHaveText("Sync"); +}); + +test("lastsync=1 shows the last-sync readout", async ({ page }) => { + await page.goto("/widget?lastsync=1"); + const strip = page.getByRole("region", { name: "Last sync" }); + await expect(strip).toBeVisible({ timeout: 10_000 }); + // Empty corpus with no sweep recorded → never. + await expect(strip).toContainText("Last full sync: never"); +}); + +test("sched=1 shows the auto-sync scheduler strip", async ({ page }) => { + await page.goto("/widget?sched=1"); + const strip = page.getByRole("region", { name: "Auto-sync scheduler" }); + await expect(strip).toBeVisible({ timeout: 10_000 }); + await expect(strip).toContainText(/Auto-sync (on|off)/); +}); + test("section params gate what renders", async ({ page }) => { await page.goto("/widget?jobs=0"); await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); @@ -210,4 +246,21 @@ test("display toggles serialize into the link and preview", async ({ page }) => .check(); await expect(url).toHaveValue(/[?&]controls=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/); + + // The Sync fieldset flags each serialize under their own key. + await page.getByRole("checkbox", { name: "Sync button" }).check(); + await expect(url).toHaveValue(/[?&]sync=1(&|$)/); + await expect(preview).toHaveAttribute("src", /[?&]sync=1(&|$)/); + + await page.getByRole("checkbox", { name: "Confirm before Sync all" }).check(); + await expect(url).toHaveValue(/[?&]syncask=1(&|$)/); + + await page.getByRole("checkbox", { name: "Last-sync readout" }).check(); + await expect(url).toHaveValue(/[?&]lastsync=1(&|$)/); + + await page.getByRole("checkbox", { name: "Scheduler status" }).check(); + await expect(url).toHaveValue(/[?&]sched=1(&|$)/); + + await page.getByRole("checkbox", { name: "Absolute timestamps" }).check(); + await expect(url).toHaveValue(/[?&]abstime=1(&|$)/); });