Archilyzer · Source

archilyzer

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

commit d1c783cde9dc61ae5af5a588b517a4d669f612b2
parent 1914e869fcf6d6537fe61a71c1c8844541880eb4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 29 Jun 2026 10:00:55 -0400

feat(deploy): rework deploy page with build-then-deploy, batch builds, build-mode toggle

Reworks /deploy into a build/deploy lifecycle (Release notes -> Build & deploy
-> Individual steps -> Build multiple sites). Adds a one-click build-then-deploy
composite job (single combined log + cancel; cancel mid-build skips deploy), a
multi-site batch panel with a live per-site log lane each, and a persisted
Basic/Docker build-mode toggle. Docker mode is seamed but falls back to the basic
build with a notice; the container pipeline is a follow-up.

- common/lib/settings.ts: BuildPipelineSettings block (mode/maxParallelBuilds/
  dockerImage/dockerfile) with default+sanitize, wired through get/writeSettings
- common/lib/paths.ts: exportBuildsDir seam (env-overridable)
- common/jobs/runChild.ts: line-buffered child streaming with SIGTERM->SIGKILL
- editor/app/deploy/buildDeployCore.ts: mode-routing runBuildPhase + runDeployIntoLog
- editor/app/build/buildAction.ts: buildAndDeployAction composite; buildExportAction
  onto the mode seam
- deploy/settings UI + new components; e2e: deploy-page spec, updated build/settings

tsc clean across editor/export/common; 16 e2e pass.

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

Diffstat:
Acommon/jobs/runChild.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/paths.ts | 8++++++++
Mcommon/lib/settings.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/build/buildAction.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Aeditor/app/deploy/buildDeployCore.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/deploy/buildModeAction.ts | 26++++++++++++++++++++++++++
Aeditor/app/deploy/components/BuildDeployButton.tsx | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/deploy/components/BuildModeToggle.tsx | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/deploy/components/BuildSitesPanel.tsx | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/deploy/components/JobLane.tsx | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/deploy/deployAction.ts | 32+++++++++++++-------------------
Meditor/app/deploy/page.tsx | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Meditor/app/settings/actions.ts | 16++++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/build.spec.ts | 12++----------
Aeditor/e2e/deploy-page.spec.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/settings.spec.ts | 32++++++++++++++++++++++++++++++++
18 files changed, 1116 insertions(+), 82 deletions(-)

diff --git a/common/jobs/runChild.ts b/common/jobs/runChild.ts @@ -0,0 +1,78 @@ +import { execa, type ResultPromise } from "execa"; + +export type RunChildOpts = { + command: string; + args: string[]; + cwd: string; + env?: Record<string, string | undefined>; + // Optional prefix prepended to every streamed line (e.g. "[testsite] "), so a + // single combined log can interleave several children legibly. + label?: string; +}; + +// Spawn a child process and stream its combined stdout/stderr into `onLog`, +// resolving with the exit code. Used inside a managed function's `fn` to run a +// sub-command as part of a larger job (build-then-deploy, multi-phase builds) +// while keeping ONE job/stream/cancel. Mirrors streamCommand's execa usage +// (all:true, buffer:false, reject:false) so behavior matches the top-level +// managed command. On `signal` abort the child is SIGTERM'd, then SIGKILL'd +// after a grace period — the same escalation registry.cancel() applies to the +// top-level child. Resolves the abort case with the child's (likely non-zero) +// exit code; the caller gates on `signal.aborted` to decide what to do next. +export async function runChildIntoLog( + onLog: (line: string) => void, + signal: AbortSignal, + opts: RunChildOpts, +): Promise<number> { + const prefix = opts.label ?? ""; + const child: ResultPromise = execa(opts.command, opts.args, { + cwd: opts.cwd, + env: opts.env, + all: true, + buffer: false, + reject: false, + }); + + // Line-buffer: execa chunks aren't line-aligned, and the managed-function + // onLog appends a newline per call, so emitting raw chunks would inject + // spurious newlines mid-line. Accumulate and emit only COMPLETE lines (without + // the trailing "\n", which onLog re-adds), holding any partial until more data + // arrives or the child exits. The optional label prefixes each line. + let buf = ""; + const flushLines = () => { + let nl = buf.indexOf("\n"); + while (nl !== -1) { + onLog(prefix + buf.slice(0, nl)); + buf = buf.slice(nl + 1); + nl = buf.indexOf("\n"); + } + }; + child.all?.on("data", (chunk: Buffer) => { + buf += chunk.toString("utf8"); + flushLines(); + }); + child.all?.on("error", () => {}); + + let killTimer: ReturnType<typeof setTimeout> | null = null; + const onAbort = () => { + child.kill("SIGTERM"); + killTimer = setTimeout(() => { + if (child.killed === false) child.kill("SIGKILL"); + }, 5_000); + killTimer.unref?.(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + + try { + const result = await child; + return result.exitCode ?? 1; + } finally { + if (buf.length > 0) { + onLog(prefix + buf); + buf = ""; + } + if (killTimer) clearTimeout(killTimer); + signal.removeEventListener("abort", onAbort); + } +} diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -68,6 +68,11 @@ export type Paths = { exportSharedTranscriptsDir: string; exportSharedSubsDir: string; exportSitesIndexDir: string; + // Per-site extracted static output (`out/`) from an isolated (Docker) build, + // keyed exportBuildsDir/<siteId>. Sibling of .export-index. The wrangler deploy + // reads a site's bundle from here in docker mode (in basic mode the deploy + // reads export/out directly). Env-overridable so test data stays isolated. + exportBuildsDir: string; settingsFile: string; chartsConfigFile: string; ytdlpBin: string; @@ -133,6 +138,9 @@ export function getPaths(): Paths { exportSharedTranscriptsDir: path.join(exportSharedDir, "transcripts"), exportSharedSubsDir: path.join(exportSharedDir, "subs"), exportSitesIndexDir: path.join(exportIndexDir, "sites"), + exportBuildsDir: + process.env.EXPORT_BUILDS_DIR ?? + path.join(path.dirname(exportPublicDir), ".export-builds"), settingsFile: process.env.SETTINGS_FILE ?? path.join(monorepoRoot, "settings.json"), chartsConfigFile: diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -120,6 +120,29 @@ export type SiteSettings = { // sync scheduler runs the backup on the configured cadence. See // common/controller/backupSavedVideos.ts. savedVideoBackup: SavedVideoBackupSettings; + // How the static export is built: "basic" reuses the single export/ tree and + // serializes builds on one queue (the long-standing behavior); "docker" runs + // each site's build in an isolated container for safe parallelism. The Docker + // pipeline itself is a follow-up; this block persists the chosen mode plus the + // container/concurrency knobs the deploy page and the future orchestrator read. + buildPipeline: BuildPipelineSettings; +}; + +// "basic" — `pnpm run build` in export/, serialized on the build queue (shared +// output tree → no safe parallelism). +// "docker" — isolated per-site container builds (follow-up); enables real +// parallel multi-site builds capped by maxParallelBuilds. +export type BuildMode = "basic" | "docker"; + +export type BuildPipelineSettings = { + mode: BuildMode; + // Cap on concurrent per-site container builds in docker mode. Ignored in basic + // mode (which is always serial). Clamped to [1, BUILD_MAX_PARALLEL_MAX]. + maxParallelBuilds: number; + // Tag of the reusable build image (built once, reused for every site). + dockerImage: string; + // Dockerfile path relative to the monorepo root, used to (re)build the image. + dockerfile: string; }; export type SavedVideoBackupSettings = { @@ -355,6 +378,50 @@ export function sanitizeSavedVideoBackup( }; } +export const BUILD_MAX_PARALLEL_DEFAULT = 2; +export const BUILD_MAX_PARALLEL_MAX = 16; +export const DEFAULT_BUILD_IMAGE = "yt-dlp-transcript-browser-build"; +export const DEFAULT_BUILD_DOCKERFILE = "Dockerfile.build"; + +export function isBuildMode(v: unknown): v is BuildMode { + return v === "basic" || v === "docker"; +} + +export function defaultBuildPipeline(): BuildPipelineSettings { + return { + mode: "basic", + maxParallelBuilds: BUILD_MAX_PARALLEL_DEFAULT, + dockerImage: DEFAULT_BUILD_IMAGE, + dockerfile: DEFAULT_BUILD_DOCKERFILE, + }; +} + +// Coerce a raw settings.buildPipeline value into a clean BuildPipelineSettings, +// falling back to defaults for missing/ill-typed fields. +export function sanitizeBuildPipeline(value: unknown): BuildPipelineSettings { + const d = defaultBuildPipeline(); + if (!value || typeof value !== "object") return d; + const r = value as Record<string, unknown>; + const dockerImage = + typeof r.dockerImage === "string" && r.dockerImage.trim() + ? r.dockerImage.trim() + : d.dockerImage; + const dockerfile = + typeof r.dockerfile === "string" && r.dockerfile.trim() + ? r.dockerfile.trim() + : d.dockerfile; + return { + mode: isBuildMode(r.mode) ? r.mode : d.mode, + maxParallelBuilds: clampPositiveInt( + r.maxParallelBuilds, + d.maxParallelBuilds, + BUILD_MAX_PARALLEL_MAX, + ), + dockerImage, + dockerfile, + }; +} + function defaults(): SiteSettings { return { adminTitle: DEFAULT_ADMIN_TITLE, @@ -374,6 +441,7 @@ function defaults(): SiteSettings { autoQueue: defaultAutoQueue(), socialLinks: [], savedVideoBackup: defaultSavedVideoBackup(), + buildPipeline: defaultBuildPipeline(), }; } @@ -537,6 +605,7 @@ export function getSettings(): SiteSettings { merged.autoQueue = sanitizeAutoQueue(merged.autoQueue); merged.socialLinks = parseSocialLinks(merged.socialLinks); merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup); + merged.buildPipeline = sanitizeBuildPipeline(merged.buildPipeline); // Workers. When the file predates the worker model (no `workers` key), // synthesize a default list from the (now-settled) active app + per-app // configs so existing installs behave identically. Otherwise sanitize the @@ -708,6 +777,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> { autoQueue: sanitizeAutoQueue(next.autoQueue), socialLinks, savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup), + buildPipeline: sanitizeBuildPipeline(next.buildPipeline), }; const tmp = `${file}.tmp-${process.pid}`; await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n"); diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`. - **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`. - **Auto-queue rules with no bucket now draw from *all* of a runner's buckets, and auto-download can resume partial downloads.** A policy-tree rule left at the **"all buckets (default)"** setting (previously just labeled *default*) now draws from the **union** of every bucket that runner kind tracks — deduped, in priority order — instead of only the single primary bucket. This fixes channels (e.g. an Odysee channel mid-download) that quietly stopped being auto-downloaded once their remaining work drifted entirely into **partially-downloaded** videos: those have a `.part` file but no completed audio, so they live in the `partialDownloads` bucket and were **absent from `undownloadedIds`** — the only bucket auto-download used to load. The download runner now loads `partialDownloads` alongside `undownloadedIds` (partials first, so in-progress downloads resume via `downloadOneManaged` before fresh ones start), and exposes `partialDownloads` as a selectable bucket in the policy editor so you can dedicate a high-priority rule to resuming partials. The per-kind bucket lists are consolidated behind a single `bucketsForKind` source of truth shared by the runner, the per-rule pending-count helper, and the editor's bucket picker (so they can't drift). Note: a *bucketless* auto-transcribe rule now also drains `failedListed` after `downloadedNoTranscript` (it already loaded both); platform rate-limit backoff is unchanged and remains an independent reason a throttled platform may pause. See `common/jobs/autoQueuePolicy.ts` (`buildPendingByLeaf` + `bucketsForKind` + unit tests), `common/controller/autoRunner.ts`, and `editor/app/auto-queue/{page.tsx,components/PolicyTreeEditor.tsx}`. - **Hub homepage redesigned into a cross-site landing; the homepage page-creator is removed.** The hub's home page is now a single mobile-first cross-site landing (headline KPIs and one stacked activity chart with Metric [Transcribed/Downloaded] · Breakdown [By site/By channel] · Bucket [Week/Month/Cumulative] · Range [90d/12mo/All] · Display [Share/Counts] controls, plus a metric-aware site-links grid with sparklines and a "#1 this month" badge), built from a small `homepage-summary.json` pre-computed by `compose-homepage`. The separate `/stats` dashboard route folds into it. Consequently the hub's **Markdown-pages subsystem is dropped**: **Manage → Homepage** now edits only branding (the Pages list, New-page, and the page editor are gone), and the homepage config no longer carries a `nav`. The page server actions (`saveHomepagePageAction`/`deleteHomepagePageAction`), `editor/app/homepage/pages/*`, `PageEditor.tsx`, `common/lib/{homepagePages,homepageConstants}.ts`, and `paths.homepagePagesDir` are removed. See `editor/app/homepage/{page.tsx,actions.ts}`, `common/bin/compose-homepage.ts`, `common/lib/{homepageSummary,homepageChart}.ts`, and the `homepage/` package. (Re-addable later if needed.) diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts @@ -15,13 +15,19 @@ import { archiveCombinedLiveChat, } from "yt-dlp-transcript-common/controller/archiveLiveChat"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite } from "yt-dlp-transcript-common/lib/site"; import { - runManagedCommand, runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { + resolveOutDir, + runBuildPhase, + runDeployIntoLog, +} from "../deploy/buildDeployCore"; const DEFAULT_BUILD_QUEUE = "build"; +const DEPLOY_QUEUE = "deploy"; export async function buildIndexAction( queueKey?: string, @@ -59,22 +65,69 @@ export async function buildExportAction( queueKey?: string, ): Promise<StreamActionResult> { const paths = getPaths(); - if (!siteId.trim()) { + const id = siteId.trim(); + if (!id) { return { ok: false, error: "Select a site to build" }; } - return runManagedCommand({ + // Run as a managed function (not a raw command) so the build can route on the + // persisted build mode (basic vs docker) via runBuildPhase, and so the same + // child-streaming path is shared with build-then-deploy. Cancel still works: + // the abort signal is forwarded to the child by runChildIntoLog. + return runManagedFunction({ kind: "build-export", queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(), paths, - cwd: paths.exportDir, - command: "pnpm", - args: ["run", "build"], - env: { - ...process.env, - NODE_ENV: "production", - TRANSCRIPTS_DIR: paths.transcriptsDir, - EXPORT_PUBLIC_DIR: paths.exportPublicDir, - SITE_ID: siteId.trim(), + fn: async (onLog, signal) => { + const code = await runBuildPhase(onLog, signal, id, paths); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Build failed (exit ${code})`); + }, + }); +} + +// One-click build-then-deploy: build the active site and, only if the build +// succeeds (and wasn't cancelled), deploy it — all in ONE managed job so the UI +// shows a single combined streamed log with a single Cancel. Serialized on the +// deploy queue so it never overlaps a standalone deploy or another build-deploy. +export async function buildAndDeployAction( + siteId: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + const id = siteId.trim(); + if (!id) { + return { ok: false, error: "Select a site to build and deploy" }; + } + const site = getSite(id, paths); + if (!site.cloudflareProject) { + return { + ok: false, + error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`, + }; + } + return runManagedFunction({ + kind: "build-deploy", + queueKey: DEPLOY_QUEUE, + paths, + fn: async (onLog, signal) => { + onLog("=== Build ===\n"); + const buildCode = await runBuildPhase(onLog, signal, id, paths); + // A cancel mid-build must NOT proceed to deploy. + if (signal.aborted) return; + if (buildCode !== 0) { + throw new Error(`Build failed (exit ${buildCode}) — not deploying.`); + } + onLog("\n=== Deploy ===\n"); + const deployCode = await runDeployIntoLog( + onLog, + signal, + site, + resolveOutDir(id, paths), + paths, + ); + if (signal.aborted) return; + if (deployCode !== 0) { + throw new Error(`Deploy failed (exit ${deployCode}).`); + } }, }); } diff --git a/editor/app/deploy/buildDeployCore.ts b/editor/app/deploy/buildDeployCore.ts @@ -0,0 +1,86 @@ +// Shared, server-only build/deploy primitives used by the build/deploy server +// actions (buildAction.ts, deployAction.ts). These take an `onLog` callback and +// an AbortSignal, so they CANNOT live in a "use server" module (every export +// there becomes a server action, which forbids non-serializable args). Keep them +// here as plain helpers and import them into the thin action wrappers. + +import path from "node:path"; +import { runChildIntoLog } from "yt-dlp-transcript-common/jobs/runChild"; +import type { Paths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import type { Site } from "yt-dlp-transcript-common/lib/site"; + +// Where the static output that should be deployed for a site lives. Today the +// build always writes export/out (basic build), so deploy reads it in both +// modes. The Docker follow-up will write each site's `out/` to +// paths.exportBuildsDir/<siteId> and switch this to return that per-site dir in +// docker mode; until then docker mode falls back to a basic build (see +// runBuildPhase), so export/out is correct everywhere. +export function resolveOutDir(_siteId: string, paths: Paths): string { + return path.join(paths.exportDir, "out"); +} + +// Run the build phase for one site, streaming into `onLog`, returning the exit +// code. Routes on the persisted build mode: "basic" runs `pnpm run build` in +// export/ (serialized upstream on the build queue, since the export/ tree is +// shared); "docker" is a follow-up — until the container pipeline lands it logs +// a notice and falls back to the basic build so builds always succeed. +export async function runBuildPhase( + onLog: (line: string) => void, + signal: AbortSignal, + siteId: string, + paths: Paths, +): Promise<number> { + const { mode } = getSettings().buildPipeline; + if (mode === "docker") { + onLog( + "[notice] Docker build mode isn't wired up yet — running the basic build " + + "for now. Parallel container builds arrive in a follow-up.\n", + ); + } + return runChildIntoLog(onLog, signal, { + command: "pnpm", + args: ["run", "build"], + cwd: paths.exportDir, + env: { + ...process.env, + NODE_ENV: "production", + TRANSCRIPTS_DIR: paths.transcriptsDir, + EXPORT_PUBLIC_DIR: paths.exportPublicDir, + SITE_ID: siteId, + }, + }); +} + +// Deploy a previously-built static bundle (`outDir`) to the site's Cloudflare +// Pages project, streaming into `onLog`, returning the exit code. Runs on the +// host with the host's Cloudflare credentials (process.env) — deploy never runs +// inside a container, so container wrangler auth is never needed. +export async function runDeployIntoLog( + onLog: (line: string) => void, + signal: AbortSignal, + site: Site, + outDir: string, + paths: Paths, +): Promise<number> { + return runChildIntoLog(onLog, signal, { + command: "pnpm", + args: [ + "dlx", + "wrangler", + "pages", + "deploy", + outDir, + "--project-name", + site.cloudflareProject as string, + ], + cwd: paths.exportDir, + env: { + ...process.env, + NODE_ENV: "production", + TRANSCRIPTS_DIR: paths.transcriptsDir, + EXPORT_PUBLIC_DIR: paths.exportPublicDir, + SITE_ID: site.siteId, + }, + }); +} diff --git a/editor/app/deploy/buildModeAction.ts b/editor/app/deploy/buildModeAction.ts @@ -0,0 +1,26 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { + getSettings, + isBuildMode, + writeSettings, + type BuildMode, +} from "yt-dlp-transcript-common/lib/settings"; + +// Persist the build-mode choice (Basic vs Docker) from the deploy page so it +// becomes the default for every subsequent build. Only the mode is touched here; +// the image/concurrency knobs are edited on the Settings page. writeSettings +// re-sanitizes the whole object regardless. +export async function setBuildModeAction( + mode: BuildMode, +): Promise<{ ok: boolean }> { + if (!isBuildMode(mode)) return { ok: false }; + const settings = getSettings(); + await writeSettings({ + ...settings, + buildPipeline: { ...settings.buildPipeline, mode }, + }); + revalidatePath("/deploy"); + return { ok: true }; +} diff --git a/editor/app/deploy/components/BuildDeployButton.tsx b/editor/app/deploy/components/BuildDeployButton.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { buildAndDeployAction } from "../../build/buildAction"; +import { cancelJobAction } from "../../jobs/actions"; + +type Props = { + // The active site to build+deploy, or null under "all sites". + siteId: string | null; + siteTitle: string | null; + // The site's Cloudflare Pages project, or null if it has none (can't deploy). + cloudflareProject: string | null; +}; + +// One-click build-then-deploy for the active site: builds, and on success +// deploys — one combined streamed log, one Cancel (see buildAndDeployAction). +export function BuildDeployButton({ + siteId, + siteTitle, + cloudflareProject, +}: Props) { + const ready = Boolean(siteId && cloudflareProject); + + let notice: React.ReactNode; + if (!siteId) { + notice = ( + <p className="text-sm text-amber-600 dark:text-amber-400"> + Select a specific site from the sidebar to build &amp; deploy. + </p> + ); + } else if (!cloudflareProject) { + notice = ( + <p className="text-sm text-amber-600 dark:text-amber-400"> + <strong>{siteTitle ?? siteId}</strong> has no Cloudflare Pages project + configured — set one on the site&apos;s page to deploy. + </p> + ); + } else { + notice = ( + <p className="text-sm text-zinc-500"> + Builds then deploys <strong>{siteTitle ?? siteId}</strong> ( + <code>{siteId}</code> → <code>{cloudflareProject}</code>). + </p> + ); + } + + return ( + <StreamActionLog + trigger={() => buildAndDeployAction(siteId as string)} + cancelAction={cancelJobAction} + buttonLabel="Build & deploy" + runningLabel="Building & deploying…" + label="Build and deploy" + disabled={!ready} + extraControls={notice} + /> + ); +} diff --git a/editor/app/deploy/components/BuildModeToggle.tsx b/editor/app/deploy/components/BuildModeToggle.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState, useTransition } from "react"; +import type { BuildMode } from "yt-dlp-transcript-common/lib/settings"; +import { setBuildModeAction } from "../buildModeAction"; + +// Segmented Basic | Docker control. Persists the choice as the global build-mode +// default (setBuildModeAction → settings.json) so every subsequent build uses it. +export function BuildModeToggle({ mode: initialMode }: { mode: BuildMode }) { + const [mode, setMode] = useState<BuildMode>(initialMode); + const [pending, startTransition] = useTransition(); + + function choose(next: BuildMode) { + if (next === mode || pending) return; + setMode(next); + startTransition(async () => { + const res = await setBuildModeAction(next); + if (!res.ok) setMode(initialMode); + }); + } + + const options: { value: BuildMode; label: string }[] = [ + { value: "basic", label: "Basic" }, + { value: "docker", label: "Docker" }, + ]; + + return ( + <div className="flex flex-col gap-1.5"> + <span className="text-xs font-medium text-zinc-500">Build mode</span> + <div + role="group" + aria-label="Build mode" + className="inline-flex w-fit rounded-md border border-zinc-300 dark:border-zinc-700 overflow-hidden text-sm" + > + {options.map((opt) => { + const active = mode === opt.value; + return ( + <button + key={opt.value} + type="button" + aria-pressed={active} + disabled={pending} + onClick={() => choose(opt.value)} + className={ + "px-3 py-1.5 font-medium transition-colors disabled:opacity-60 " + + (active + ? "bg-zinc-900 text-zinc-100 dark:bg-zinc-100 dark:text-zinc-900" + : "bg-transparent text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800") + } + > + {opt.label} + </button> + ); + })} + </div> + {mode === "docker" && ( + <span className="text-xs text-amber-600 dark:text-amber-400"> + Docker builds are a follow-up — runs the basic build for now. + </span> + )} + </div> + ); +} diff --git a/editor/app/deploy/components/BuildSitesPanel.tsx b/editor/app/deploy/components/BuildSitesPanel.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { buildExportAction, buildAndDeployAction } from "../../build/buildAction"; +import { JobLane } from "./JobLane"; + +export type SiteOption = { + siteId: string; + siteTitle: string; + cloudflareProject: string | null; +}; + +type Lane = { siteId: string; title: string; deploy: boolean; key: string }; + +// Batch build (and optionally deploy) several sites at once. Each selected site +// launches its own managed job, rendered as its own live JobLane. In Basic mode +// the jobs share the build/deploy queue and run one at a time (the export/ tree +// is shared); Docker mode (a follow-up) unlocks true parallelism. +export function BuildSitesPanel({ + sites, + serial, +}: { + sites: SiteOption[]; + serial: boolean; +}) { + const [selected, setSelected] = useState<Set<string>>(new Set()); + const [deployAfter, setDeployAfter] = useState(false); + const [lanes, setLanes] = useState<Lane[] | null>(null); + // Bumps so re-running the same selection remounts fresh lanes. + const [run, setRun] = useState(0); + + function toggle(siteId: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(siteId)) next.delete(siteId); + else next.add(siteId); + return next; + }); + } + + function launch() { + const chosen = sites.filter((s) => selected.has(s.siteId)); + if (chosen.length === 0) return; + const nextRun = run + 1; + setRun(nextRun); + setLanes( + chosen.map((s) => ({ + siteId: s.siteId, + title: s.siteTitle, + deploy: deployAfter && Boolean(s.cloudflareProject), + key: `${nextRun}:${s.siteId}`, + })), + ); + } + + const missingProject = + deployAfter && + sites.some((s) => selected.has(s.siteId) && !s.cloudflareProject); + + return ( + <div className="flex flex-col gap-4"> + <div className="flex flex-col gap-2 rounded-md border border-zinc-200 dark:border-zinc-800 p-3"> + <div className="flex flex-wrap gap-x-6 gap-y-2"> + {sites.map((s) => ( + <label + key={s.siteId} + className="flex items-center gap-2 text-sm" + > + <input + type="checkbox" + checked={selected.has(s.siteId)} + onChange={() => toggle(s.siteId)} + aria-label={`Select ${s.siteTitle}`} + /> + <span className="font-medium">{s.siteTitle}</span> + <code className="text-xs text-zinc-500">{s.siteId}</code> + {!s.cloudflareProject && ( + <span className="text-xs text-amber-600 dark:text-amber-400"> + no deploy target + </span> + )} + </label> + ))} + {sites.length === 0 && ( + <span className="text-sm italic text-zinc-500"> + No sites configured. + </span> + )} + </div> + </div> + + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + onClick={launch} + disabled={selected.size === 0} + className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {deployAfter ? "Build & deploy selected" : "Build selected"} + </button> + <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-300"> + <input + type="checkbox" + checked={deployAfter} + onChange={(e) => setDeployAfter(e.target.checked)} + /> + Deploy after build + </label> + <span className="text-xs text-zinc-500"> + {selected.size} selected + </span> + </div> + + {missingProject && ( + <p className="text-xs text-amber-600 dark:text-amber-400"> + Some selected sites have no Cloudflare Pages project — those will build + only (deploy is skipped for them). + </p> + )} + {serial && ( + <p className="text-xs text-zinc-500"> + Basic mode runs these one at a time (the build output tree is shared). + Switch to Docker mode for true parallel builds — a follow-up. + </p> + )} + + {lanes && lanes.length > 0 && ( + <div className="flex flex-col gap-2"> + {lanes.map((lane) => ( + <JobLane + key={lane.key} + title={lane.title} + subtitle={`${lane.siteId}${lane.deploy ? " · build + deploy" : " · build"}`} + trigger={() => + lane.deploy + ? buildAndDeployAction(lane.siteId) + : buildExportAction(lane.siteId) + } + /> + ))} + </div> + )} + </div> + ); +} diff --git a/editor/app/deploy/components/JobLane.tsx b/editor/app/deploy/components/JobLane.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { cancelJobAction } from "../../jobs/actions"; + +type LaneStatus = + | "starting" + | "queued" + | "running" + | "done" + | "failed" + | "cancelled" + | "error"; + +type Props = { + title: string; + subtitle: string; + // Launched once on mount. Returns the managed-job descriptor (or an error). + trigger: () => Promise<StreamActionResult>; +}; + +const CHIP: Record<LaneStatus, { label: string; cls: string }> = { + starting: { label: "Starting…", cls: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" }, + queued: { label: "Queued", cls: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" }, + running: { label: "Running", cls: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300" }, + done: { label: "Done", cls: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300" }, + failed: { label: "Failed", cls: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300" }, + cancelled: { label: "Cancelled", cls: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" }, + error: { label: "Error", cls: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300" }, +}; + +// A compact, auto-starting lane for one site's build (or build+deploy). Reuses +// the managed-job stream + done promise: the stream feeds the log, `done` gives +// the terminal status. A light poll surfaces queued/running + queue position +// before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in +// miniature so several lanes can run at once on the batch panel. +export function JobLane({ title, subtitle, trigger }: Props) { + const [status, setStatus] = useState<LaneStatus>("starting"); + const [log, setLog] = useState(""); + const [error, setError] = useState<string | null>(null); + const [jobId, setJobId] = useState<string | null>(null); + const [queuePos, setQueuePos] = useState<number | null>(null); + const [cancelling, setCancelling] = useState(false); + const preRef = useRef<HTMLPreElement | null>(null); + const startedRef = useRef(false); + + useEffect(() => { + // Strict-mode mounts effects twice in dev; guard so the job launches once. + if (startedRef.current) return; + startedRef.current = true; + let cancelled = false; + let stopped = false; + let pollTimer: ReturnType<typeof setTimeout> | null = null; + + async function poll(id: string) { + try { + const res = await fetch( + `/api/jobs/${encodeURIComponent(id)}/log?from=999999999`, + { cache: "no-store" }, + ); + if (res.ok) { + const data = (await res.json()) as { + status: string; + queuePosition?: number; + }; + if (!cancelled) { + if (data.status === "queued") { + setStatus("queued"); + setQueuePos( + typeof data.queuePosition === "number" && data.queuePosition > 0 + ? data.queuePosition + : null, + ); + } else if (data.status === "running") { + setStatus("running"); + setQueuePos(null); + } + } + } + } catch { + /* transient; keep polling */ + } + if (!cancelled && !stopped) pollTimer = setTimeout(() => poll(id), 1000); + } + + (async () => { + let result: StreamActionResult; + try { + result = await trigger(); + } catch (e) { + if (!cancelled) { + setStatus("error"); + setError((e as Error).message); + } + return; + } + if (cancelled) return; + if (!result.ok) { + setStatus("error"); + setError(result.error); + return; + } + setJobId(result.jobId); + setStatus("running"); + poll(result.jobId); + + const reader = result.stream.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value && !cancelled) setLog((prev) => prev + value); + } + } catch { + /* stream tear-down */ + } + const term = await result.done; + stopped = true; + if (pollTimer) clearTimeout(pollTimer); + if (!cancelled) { + setQueuePos(null); + setStatus( + term.status === "done" + ? "done" + : term.status === "failed" + ? "failed" + : "cancelled", + ); + } + })(); + + return () => { + cancelled = true; + if (pollTimer) clearTimeout(pollTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Keep the log scrolled to the bottom as it grows. + useEffect(() => { + const el = preRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [log]); + + async function handleCancel() { + if (!jobId) return; + setCancelling(true); + try { + await cancelJobAction(jobId); + } finally { + setCancelling(false); + } + } + + const chip = CHIP[status]; + const live = status === "queued" || status === "running" || status === "starting"; + + return ( + <div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"> + <div className="flex items-center gap-3 px-3 py-2"> + <span + className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${chip.cls}`} + > + {status === "queued" && queuePos + ? `Queued · ${queuePos}` + : chip.label} + </span> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm font-medium">{title}</div> + <div className="truncate text-xs text-zinc-500">{subtitle}</div> + </div> + {live && jobId && ( + <button + type="button" + onClick={handleCancel} + disabled={cancelling} + aria-label={`Cancel ${title}`} + className="shrink-0 rounded border border-red-300 dark:border-red-800 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50" + > + {cancelling ? "Cancelling…" : "Cancel"} + </button> + )} + </div> + {error && ( + <div + role="alert" + className="mx-3 mb-2 rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-2 py-1.5 text-xs text-red-700 dark:text-red-300" + > + {error} + </div> + )} + {(log || live) && ( + <details className="px-3 pb-2"> + <summary className="cursor-pointer text-xs text-zinc-500 select-none"> + Log + </summary> + <pre + ref={preRef} + role="log" + aria-label={`${title} output`} + className="mt-1 h-48 overflow-auto whitespace-pre-wrap rounded border border-zinc-200 dark:border-zinc-800 bg-zinc-100 dark:bg-zinc-950 p-2 text-xs font-mono" + > + {log || "Waiting for output…"} + </pre> + </details> + )} + </div> + ); +} diff --git a/editor/app/deploy/deployAction.ts b/editor/app/deploy/deployAction.ts @@ -3,9 +3,10 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSite } from "yt-dlp-transcript-common/lib/site"; import { - runManagedCommand, + runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { resolveOutDir, runDeployIntoLog } from "./buildDeployCore"; const DEPLOY_QUEUE = "deploy"; @@ -23,27 +24,20 @@ export async function deployExportAction( error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`, }; } - return runManagedCommand({ + return runManagedFunction({ kind: "deploy-export", queueKey: DEPLOY_QUEUE, paths, - cwd: paths.exportDir, - command: "pnpm", - args: [ - "dlx", - "wrangler", - "pages", - "deploy", - "out", - "--project-name", - site.cloudflareProject, - ], - env: { - ...process.env, - NODE_ENV: "production", - TRANSCRIPTS_DIR: paths.transcriptsDir, - EXPORT_PUBLIC_DIR: paths.exportPublicDir, - SITE_ID: site.siteId, + fn: async (onLog, signal) => { + const code = await runDeployIntoLog( + onLog, + signal, + site, + resolveOutDir(site.siteId, paths), + paths, + ); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Deploy failed (exit ${code}).`); }, }); } diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx @@ -8,9 +8,13 @@ import { suggestNextVersion, } from "yt-dlp-transcript-common/lib/changelog"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { listSiteIds, listSites } from "yt-dlp-transcript-common/lib/site"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { BuildExportButton } from "./components/BuildExportButton"; +import { BuildDeployButton } from "./components/BuildDeployButton"; +import { BuildModeToggle } from "./components/BuildModeToggle"; +import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; import { DeployButton } from "./components/DeployButton"; import { resolveActiveSite } from "../lib/activeSite"; @@ -51,42 +55,36 @@ export default async function DeployPage({ const hasUnreleased = unreleased.length > 0; const lastDeployedAt = getLastDeployedAt(); const existingQueues = getRegistry().activeQueueNames(); + const buildMode = getSettings().buildPipeline.mode; + const sites = listSites(paths); const { site } = await searchParams; const active = resolveActiveSite(site, listSiteIds(paths)); // Build/deploy target the single active site; null under "all sites". const activeSiteId = active.isAll ? null : active.siteId; - const activeSiteTitle = activeSiteId - ? (listSites(paths).find((s) => s.siteId === activeSiteId)?.siteTitle ?? - activeSiteId) - : null; + const activeSite = activeSiteId + ? sites.find((s) => s.siteId === activeSiteId) + : undefined; + const activeSiteTitle = activeSite?.siteTitle ?? activeSiteId ?? null; + const activeCloudflareProject = activeSite?.cloudflareProject ?? null; return ( <div className="flex flex-col gap-8 max-w-3xl"> - <h1 className="text-2xl font-semibold">Deploy</h1> + <div className="flex flex-col gap-1"> + <h1 className="text-2xl font-semibold">Deploy</h1> + <p className="text-sm text-zinc-500"> + Cut a release, then build &amp; deploy the active site — or batch + several sites at once below. + </p> + </div> + {/* 1 — Release: stage the changelog before shipping. */} <section className="flex flex-col gap-3"> <div> - <h2 className="text-lg font-semibold">Build static export</h2> + <h2 className="text-lg font-semibold">Release notes</h2> <p className="text-sm text-zinc-500"> - Spawns <code>pnpm run build</code> in <code>export/</code> — runs - the index build, then <code>next build</code> to produce the - static site at <code>export/out/</code>. - </p> - </div> - <BuildExportButton - existingQueues={existingQueues} - siteId={activeSiteId} - siteTitle={activeSiteTitle} - /> - </section> - - <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> - <div> - <h2 className="text-lg font-semibold">Pending export changes</h2> - <p className="text-sm text-zinc-500"> - Bullets currently sitting under <code>## [Unreleased]</code> in{" "} - <code>export/CHANGELOG.md</code>. These become the body of the - next cut release. + Bullets under <code>## [Unreleased]</code> in{" "} + <code>export/CHANGELOG.md</code> become the next cut release. Cut it + before you build &amp; deploy. </p> </div> {hasUnreleased && source ? ( @@ -98,16 +96,6 @@ export default async function DeployPage({ No pending changes yet. </div> )} - </section> - - <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> - <div> - <h2 className="text-lg font-semibold">Cut release</h2> - <p className="text-sm text-zinc-500"> - Turns the current <code>## [Unreleased]</code> heading into a - dated semver heading. - </p> - </div> <CutReleaseForm workspace="export" lastVersion={lastVersion} @@ -116,14 +104,13 @@ export default async function DeployPage({ /> </section> + {/* 2 — Build & deploy (primary path) for the active site. */} <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> <div> - <h2 className="text-lg font-semibold">Deploy static export</h2> + <h2 className="text-lg font-semibold">Build &amp; deploy</h2> <p className="text-sm text-zinc-500"> - Runs <code>pnpm run deploy</code> in <code>export/</code>, - publishing the most recently built static site at{" "} - <code>export/out/</code>. Build first if needed using{" "} - <code>Build static export</code> at the top of this page. + Builds the static export, then deploys it to Cloudflare Pages in one + step. Cancelling before the build finishes skips the deploy. </p> <p className="text-sm text-zinc-500 mt-1"> Last deployed:{" "} @@ -132,7 +119,70 @@ export default async function DeployPage({ : "never"} </p> </div> - <DeployButton siteId={activeSiteId} siteTitle={activeSiteTitle} /> + <BuildModeToggle mode={buildMode} /> + <BuildDeployButton + siteId={activeSiteId} + siteTitle={activeSiteTitle} + cloudflareProject={activeCloudflareProject} + /> + </section> + + {/* 3 — Individual steps, for when you only want one half. */} + <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> + <details> + <summary className="cursor-pointer text-lg font-semibold select-none"> + Individual steps + </summary> + <div className="flex flex-col gap-6 pt-4"> + <div className="flex flex-col gap-3"> + <div> + <h3 className="font-semibold">Build static export</h3> + <p className="text-sm text-zinc-500"> + Spawns <code>pnpm run build</code> in <code>export/</code> — + index build, then <code>next build</code> to{" "} + <code>export/out/</code>. No deploy. + </p> + </div> + <BuildExportButton + existingQueues={existingQueues} + siteId={activeSiteId} + siteTitle={activeSiteTitle} + /> + </div> + <div className="flex flex-col gap-3"> + <div> + <h3 className="font-semibold">Deploy static export</h3> + <p className="text-sm text-zinc-500"> + Publishes the most recently built site at{" "} + <code>export/out/</code> to Cloudflare Pages. Build first. + </p> + </div> + <DeployButton + siteId={activeSiteId} + siteTitle={activeSiteTitle} + /> + </div> + </div> + </details> + </section> + + {/* 4 — Batch: build (and optionally deploy) several sites at once. */} + <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> + <div> + <h2 className="text-lg font-semibold">Build multiple sites</h2> + <p className="text-sm text-zinc-500"> + Pick sites to build (and optionally deploy) together. Each gets its + own live log lane. + </p> + </div> + <BuildSitesPanel + sites={sites.map((s) => ({ + siteId: s.siteId, + siteTitle: s.siteTitle, + cloudflareProject: s.cloudflareProject ?? null, + }))} + serial={buildMode === "basic"} + /> </section> </div> ); diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -5,6 +5,8 @@ import { AUTO_REFRESH_INTERVAL_MAX_SECONDS, AUTO_REFRESH_INTERVAL_MIN_SECONDS, DEFAULT_REPORT_DEBOUNCE_PRESET, + defaultBuildPipeline, + isBuildMode, isReportDebouncePreset, getSettings, MIN_FREE_DISK_GB_MAX, @@ -175,6 +177,19 @@ export async function saveSettingsAction( socialLinks.push({ ...link, svg }); } + // Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside + // writeSettings, so we only read the form here (NaN/blank → default). The + // deploy-page toggle also writes `mode`; whichever saves last wins. + const dB = defaultBuildPipeline(); + const buildModeRaw = String(formData.get("buildMode") ?? "").trim(); + const buildPipeline = { + mode: isBuildMode(buildModeRaw) ? buildModeRaw : dB.mode, + maxParallelBuilds: + Number.parseInt(String(formData.get("maxParallelBuilds") ?? "").trim(), 10), + dockerImage: String(formData.get("dockerImage") ?? "").trim() || dB.dockerImage, + dockerfile: String(formData.get("dockerfile") ?? "").trim() || dB.dockerfile, + }; + const next: SiteSettings = { adminTitle, maxTranscriptPageBytes: parsed, @@ -201,6 +216,7 @@ export async function saveSettingsAction( // Preserve the saved-video backup config on an unrelated settings save (the // Saved Videos page edits it). writeSettings re-sanitizes it regardless. savedVideoBackup: getSettings().savedVideoBackup, + buildPipeline, }; try { await writeSettings(next); diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -241,6 +241,53 @@ export function SettingsForm({ initial, apps }: Props) { </p> </fieldset> <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3"> + <legend className="px-1 text-sm font-medium">Build pipeline</legend> + <p className="text-xs text-zinc-500"> + How the static export is built. <strong>Basic</strong> runs the build + in <code>export/</code> and serializes builds on one queue.{" "} + <strong>Docker</strong> will run each site&apos;s build in an isolated + container for safe parallel multi-site builds — the container pipeline + is a follow-up, so Docker currently falls back to a basic build. The + mode can also be toggled on the{" "} + <a href="/deploy" className="underline"> + Deploy + </a>{" "} + page. + </p> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Build mode</span> + <select + name="buildMode" + defaultValue={initial.buildPipeline.mode} + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" + > + <option value="basic">Basic — serial build queue</option> + <option value="docker"> + Docker — isolated parallel builds (follow-up) + </option> + </select> + </label> + <Field + label="Max parallel builds (Docker mode)" + name="maxParallelBuilds" + defaultValue={String(initial.buildPipeline.maxParallelBuilds)} + type="number" + hint="Cap on concurrent per-site container builds in Docker mode (1–16). Ignored in Basic mode, which is always serial." + /> + <Field + label="Docker image tag" + name="dockerImage" + defaultValue={initial.buildPipeline.dockerImage} + hint="Tag of the reusable build image, built once and reused for every site." + /> + <Field + label="Dockerfile path" + name="dockerfile" + defaultValue={initial.buildPipeline.dockerfile} + hint="Path (relative to the repo root) used to build the image." + /> + </fieldset> + <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3"> <legend className="px-1 text-sm font-medium">Social links</legend> <p className="text-xs text-zinc-500"> Default social links shown in every site&apos;s footer. Each site can diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts @@ -82,21 +82,13 @@ test("build static export lives on the deploy page, not the build page", async ( ).toHaveCount(0); await expect(page.getByRole("heading", { name: "Build index" })).toBeVisible(); - // Now at the top of the deploy page, above the changelog preview. + // On the deploy page, under the "Individual steps" disclosure. await page.goto("/deploy"); + await page.getByText("Individual steps").click(); const exportHeading = page.getByRole("heading", { name: "Build static export", }); - const pendingHeading = page.getByRole("heading", { - name: "Pending export changes", - }); await expect(exportHeading).toBeVisible(); - await expect(pendingHeading).toBeVisible(); - - // The export section comes before the pending-changes section. - const exportBox = await exportHeading.boundingBox(); - const pendingBox = await pendingHeading.boundingBox(); - expect(exportBox!.y).toBeLessThan(pendingBox!.y); const exportButton = page.getByRole("button", { name: "Build static export", diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from "@playwright/test"; +import { resetData, writeSite } from "./helpers"; + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("deploy page reads as a lifecycle: release → build & deploy → batch", async ({ + page, +}) => { + await writeSite("testsite", { cloudflareProject: "proj" }); + await page.goto("/deploy?site=testsite"); + + const release = page.getByRole("heading", { name: "Release notes" }); + const buildDeploy = page.getByRole("heading", { name: "Build & deploy" }); + const batch = page.getByRole("heading", { name: "Build multiple sites" }); + await expect(release).toBeVisible(); + await expect(buildDeploy).toBeVisible(); + await expect(batch).toBeVisible(); + + // Order: release sits above build & deploy, which sits above the batch panel. + const releaseY = (await release.boundingBox())!.y; + const buildDeployY = (await buildDeploy.boundingBox())!.y; + const batchY = (await batch.boundingBox())!.y; + expect(releaseY).toBeLessThan(buildDeployY); + expect(buildDeployY).toBeLessThan(batchY); +}); + +test("Build & deploy is enabled only when the active site has a Cloudflare project", async ({ + page, +}) => { + await writeSite("with-proj", { cloudflareProject: "proj" }); + await writeSite("no-proj", {}); + + // With a Cloudflare project → enabled. + await page.goto("/deploy?site=with-proj"); + await expect( + page.getByRole("button", { name: "Build & deploy" }), + ).toBeEnabled(); + + // Without one → disabled, with an explanatory notice. + await page.goto("/deploy?site=no-proj"); + await expect( + page.getByRole("button", { name: "Build & deploy" }), + ).toBeDisabled(); + await expect( + page.getByText(/has no Cloudflare Pages project configured/i), + ).toBeVisible(); +}); + +test("build-mode toggle persists the choice and shows the Docker follow-up note", async ({ + page, +}) => { + await writeSite("testsite", { cloudflareProject: "proj" }); + await page.goto("/deploy?site=testsite"); + + const group = page.getByRole("group", { name: "Build mode" }); + await expect(group.getByRole("button", { name: "Basic" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await group.getByRole("button", { name: "Docker" }).click(); + await expect(group.getByRole("button", { name: "Docker" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(page.getByText(/Docker builds are a follow-up/i)).toBeVisible(); + + // Persisted: reload and the toggle is still on Docker. + await page.goto("/deploy?site=testsite"); + await expect( + page + .getByRole("group", { name: "Build mode" }) + .getByRole("button", { name: "Docker" }), + ).toHaveAttribute("aria-pressed", "true"); +}); + +test("batch panel: selecting sites enables the launch button and reflects deploy intent", async ({ + page, +}) => { + // Deliberately does NOT click the launch button — that would spawn a real + // `pnpm run build`, which the e2e suite never does (cf. build.spec.ts only + // runs the lightweight Build index job). We verify the panel's UI wiring. + await writeSite("alpha", { siteTitle: "Alpha", cloudflareProject: "a" }); + await writeSite("beta", { siteTitle: "Beta", cloudflareProject: "b" }); + await page.goto("/deploy?site=alpha"); + + const launch = page.getByRole("button", { name: "Build selected" }); + await expect(launch).toBeDisabled(); + + await page.getByLabel("Select Alpha").check(); + await page.getByLabel("Select Beta").check(); + await expect(launch).toBeEnabled(); + await expect(page.getByText("2 selected")).toBeVisible(); + + // Opting into deploy renames the launch button. + await page.getByLabel("Deploy after build").check(); + await expect( + page.getByRole("button", { name: "Build & deploy selected" }), + ).toBeVisible(); + + // Basic mode (the default) notes that the batch runs serially. + await expect(page.getByText(/Basic mode runs these one at a time/i)).toBeVisible(); +}); diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts @@ -133,3 +133,35 @@ test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => { "between", ); }); + +test("saves build pipeline settings (mode, concurrency, image)", async ({ + page, +}) => { + await page.goto("/settings"); + await page.getByLabel("Build mode").selectOption("docker"); + await page.getByLabel(/max parallel builds/i).fill("4"); + await page.getByLabel(/docker image tag/i).fill("my-build-image"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const saved = await readJson<{ + buildPipeline?: { + mode: string; + maxParallelBuilds: number; + dockerImage: string; + }; + }>("test-settings.json"); + expect(saved.buildPipeline?.mode).toBe("docker"); + expect(saved.buildPipeline?.maxParallelBuilds).toBe(4); + expect(saved.buildPipeline?.dockerImage).toBe("my-build-image"); + + // The deploy page reflects the persisted mode. + await page.goto("/deploy"); + await expect( + page + .getByRole("group", { name: "Build mode" }) + .getByRole("button", { name: "Docker" }), + ).toHaveAttribute("aria-pressed", "true"); +});