commit d8546f78676c00d28e5a2955bb77e51e10f13d8c
parent 3bea5b510b9e7fdde09b27bbdbedce0fe79c1fcf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 20 Jul 2026 15:36:06 -0400
Merge feat/widget-sync-readouts: widget Sync button + last-sync/scheduler readouts
Diffstat:
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(&|$)/);
});