Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
20 kB ยท 565 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566#!/usr/bin/env node// Record a scripted walkthrough of the local dashboard as a video, with a// visible cursor and click ripples, through Playwright.//// node scripts/record.mjs create-status-page// node scripts/record.mjs create-status-page --theme dark// node scripts/record.mjs create-status-page --remotion// node scripts/record.mjs --list//// Tutorials live in scripts/tutorials/<name>.mjs: a `meta` export and a// default function of steps. Needs the seeded stack from// `node scripts/dev-stack.mjs up`; writes .recordings/<name>[.dark].mp4.// --remotion captures at 2x without the in-page cursor and writes// .recordings/remotion/<name>/{video.mp4,timeline.json,thumbnail.png} for// scripts/videos.// Tutorials reset their own rows, so only a local database is accepted.//// Other recorders cut to the dashboard through this one (record-slack.mjs// --dashboard): they pass a module path instead of a tutorial name, the// export that holds the steps, the output name, and the story's time, which// the browser's clock shows (in UTC) instead of the real one:// node scripts/record.mjs scripts/slack/threads/incident.mjs \// --export working --name working --clock 2026-10-25T10:21:00Z --remotion
import { spawnSync } from "node:child_process";import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync,} from "node:fs";import { createRequire } from "node:module";import { basename, dirname, join, resolve } from "node:path";import { fileURLToPath, pathToFileURL } from "node:url";
const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");const tutorialsDir = join(repo, "scripts", "tutorials");
const BOOLEAN_FLAGS = new Set(["remotion", "no-cursor", "list"]);const args = process.argv.slice(2);const flags = {};const names = [];for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = args[i + 1]; if (BOOLEAN_FLAGS.has(key) || next === undefined || next.startsWith("--")) flags[key] = true; else flags[key] = args[++i]; } else names.push(a);}const fail = (message) => { console.error(message); process.exit(2);};
const available = readdirSync(tutorialsDir, { withFileTypes: true }) .filter((f) => f.isFile()) .map((f) => f.name) .filter((f) => f.endsWith(".mjs")) .map((f) => f.slice(0, -4));// A path to a module (ends in .mjs) instead of a tutorial name.const external = names.length === 1 && names[0].endsWith(".mjs") && existsSync(names[0]) ? resolve(names[0]) : null;if ( flags.list || names.length !== 1 || (!external && !available.includes(names[0]))) { console.error( `usage: record.mjs <tutorial | path.mjs> [--theme light|dark] [--remotion] [--scale 1] [--no-cursor] [--base url] [--width 1440 --height 900] [--speed 1] [--export steps --name out --clock ISO]\ntutorials: ${available.join(", ")}`, ); process.exit(flags.list ? 0 : 2);}for (const key of ["export", "name", "clock"]) if (flags[key] === true) fail(`--${key} needs a value`);const name = flags.name ?? basename(names[0], ".mjs");if (!/^[\w-]+$/.test(name)) fail(`--name must be a plain file name, got ${name}`);const clock = flags.clock !== undefined ? new Date(flags.clock) : null;if (clock && Number.isNaN(clock.getTime())) fail(`--clock must be an ISO date, got ${flags.clock}`);const base = (flags.base ?? "http://localhost:3001").replace(/\/$/, "");const db = flags.db ?? "http://127.0.0.1:8080";if (!["localhost", "127.0.0.1"].includes(new URL(db).hostname)) fail(`refusing non-local database ${db}: tutorials delete their own rows`);const theme = flags.theme ?? "light";if (theme !== "light" && theme !== "dark") fail(`--theme must be light or dark, got ${theme}`);const positive = (key, fallback) => { const value = Number(flags[key] ?? fallback); if (!Number.isFinite(value) || value <= 0) fail(`--${key} must be a positive number, got ${flags[key]}`); return value;};const speed = positive("speed", 1);const remotion = Boolean(flags.remotion);// scripts/videos only has light compositions.if (remotion && theme === "dark") fail("--remotion records light mode only");const scale = positive("scale", remotion ? 2 : 1);const showCursor = !remotion && !flags["no-cursor"];const fps = 30;const out = resolve(flags.out ?? ".recordings");
// The repo pins playwright in apps/screenshot-service; a global install also works.async function loadPlaywright() { try { return await import("playwright"); } catch { const req = createRequire( join(repo, "apps/screenshot-service/package.json"), ); return req("playwright"); }}
async function sql(statements) { const res = await fetch(db, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ statements }), }); if (!res.ok) throw new Error(`sqld ${res.status} at ${db}`); return (await res.json()).map((r) => { if (r.error) throw new Error(r.error.message); return r.results; });}
// Loaded before the browser starts, so a broken tutorial or a missing service// fails in a second instead of half-way through a recording.let tutorial;try { tutorial = await import( pathToFileURL(external ?? join(tutorialsDir, `${names[0]}.mjs`)).href );} catch (e) { fail(`tutorial ${names[0]} does not load: ${e.message}`);}const steps = tutorial[flags.export ?? "default"];if (typeof steps !== "function") fail(`${names[0]} has no function export ${flags.export ?? "default"}`);const { validateMeta } = await import( pathToFileURL(join(tutorialsDir, "lib", "meta.mjs")).href);const problems = validateMeta(tutorial.meta, name);if (problems.length) fail(problems.join("\n"));// A tutorial may need a narrower window, e.g. for a card that runs wide.const width = positive("width", tutorial.meta.viewport?.width ?? 1440);const height = positive("height", tutorial.meta.viewport?.height ?? 900);if (tutorial.meta.requires?.includes("checker")) { const checker = process.env.CHECKER_URL ?? "http://localhost:8090"; const answer = await fetch(checker, { signal: AbortSignal.timeout(2000), }).catch(() => null); if (!answer) fail( `${name} needs the checker, nothing answers at ${checker}: run apps/checker and start the dashboard with CHECKER_URL and CRON_SECRET (see the screenshot skill)`, );}
const probe = await fetch(`${base}/login`).catch(() => null);if (probe?.status !== 200) { console.error( `no dashboard at ${base} (${probe?.status ?? "unreachable"}); run node scripts/dev-stack.mjs up`, ); process.exit(1);}
const hideDevChrome = () => { const mount = () => { const style = document.createElement("style"); style.textContent = `nextjs-portal, [data-slot=tailwind-indicator] { display: none !important; }`; document.head.appendChild(style); }; if (document.head) mount(); else addEventListener("DOMContentLoaded", mount);};
// Fake cursor: headless capture has none. Position survives full navigations.const cursorScript = () => { const mount = () => { if (document.getElementById("__rec-cursor")) return; const style = document.createElement("style"); style.textContent = ` #__rec-cursor { position: fixed; z-index: 2147483647; pointer-events: none; width: 22px; height: 22px; margin: -3px 0 0 -4px; transition: transform 120ms ease-out; filter: drop-shadow(0 1px 2px rgb(0 0 0 / .35)); } #__rec-cursor.down { transform: scale(.85); } .__rec-ripple { position: fixed; z-index: 2147483646; pointer-events: none; width: 36px; height: 36px; margin: -18px 0 0 -18px; border-radius: 9999px; background: rgb(59 130 246 / .35); animation: __rec-ripple 450ms ease-out forwards; } @keyframes __rec-ripple { from { transform: scale(.3); opacity: 1 } to { transform: scale(1.4); opacity: 0 } }`; document.head.appendChild(style); const cursor = document.createElement("div"); cursor.id = "__rec-cursor"; cursor.innerHTML = `<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 2l16 10.5-7 1.5 4 7.5-3 1.5-4-7.5L4 20z" fill="#000" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>`; const [x, y] = JSON.parse( sessionStorage.getItem("__rec-pos") ?? "[-50,-50]", ); cursor.style.left = `${x}px`; cursor.style.top = `${y}px`; document.body.appendChild(cursor); addEventListener( "mousemove", (e) => { cursor.style.left = `${e.clientX}px`; cursor.style.top = `${e.clientY}px`; sessionStorage.setItem( "__rec-pos", JSON.stringify([e.clientX, e.clientY]), ); }, true, ); addEventListener( "mousedown", (e) => { cursor.classList.add("down"); const r = document.createElement("div"); r.className = "__rec-ripple"; r.style.left = `${e.clientX}px`; r.style.top = `${e.clientY}px`; document.body.appendChild(r); setTimeout(() => r.remove(), 500); }, true, ); addEventListener("mouseup", () => cursor.classList.remove("down"), true); }; if (document.body) mount(); else addEventListener("DOMContentLoaded", mount);};
const { chromium } = await loadPlaywright();// Chromium ignores HTTPS_PROXY; external images (avatars, favicons) need it.const proxy = process.env.HTTPS_PROXY ?? process.env.https_proxy;// The headless shell screencasts at 1x unless the scale factor is forced.const browser = await chromium.launch({ args: [`--force-device-scale-factor=${scale}`], ...(proxy && { proxy: { server: proxy, bypass: "localhost,127.0.0.1" } }),});const tmp = join(out, `.tmp-${process.pid}`);mkdirSync(tmp, { recursive: true });const context = await browser.newContext({ baseURL: base, viewport: { width, height }, deviceScaleFactor: scale, colorScheme: theme, // The story's clock reads in UTC, as the Slack messages beside it do. ...(clock && { timezoneId: "UTC" }),});// Date.now() and new Date() show the story's time; timers keep running.if (clock) await context.clock.setFixedTime(clock);await context.addCookies([ { name: "authjs.session-token", value: flags.token ?? "seed-session-max", url: base, }, { name: "workspace-slug", value: flags.workspace ?? "love-openstatus", url: base, },]);await context.addInitScript((t) => localStorage.setItem("theme", t), theme);await context.addInitScript(hideDevChrome);if (showCursor) await context.addInitScript(cursorScript);
const page = await context.newPage();let mouse = { x: width / 2, y: height / 2 };
// CDP screencast instead of Playwright's video: full-resolution frames whose// timestamps share the wall clock with the timeline events below.const frames = [];const cdp = await context.newCDPSession(page);cdp.on("Page.screencastFrame", ({ data, metadata, sessionId }) => { const file = join(tmp, `f${String(frames.length).padStart(6, "0")}.jpg`); writeFileSync(file, Buffer.from(data, "base64")); frames.push({ file, t: metadata.timestamp ?? Date.now() / 1000 }); cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => {});});await cdp.send("Page.startScreencast", { format: "jpeg", quality: 92, maxWidth: width * scale, maxHeight: height * scale,});
const now = () => Date.now() / 1000;const events = [];const log = (event) => events.push(event);page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) log({ type: "nav", t: now(), url: frame.url() });});
const wait = (ms) => page.waitForTimeout(ms / speed);
const easeInOut = (p) => (p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2);
// Real wheel steps on an ease-in-out curve: the page glides instead of// jumping, and the screencast catches every step.// Paced by the clock, not a step count: each wheel call costs tens of ms.async function wheel(dy) { const duration = (Math.min(1.2, Math.max(0.4, 0.35 + Math.abs(dy) / 2000)) * 1000) / speed; const started = Date.now(); let done = 0; while (done !== dy) { const p = Math.min(1, (Date.now() - started) / duration); const next = p === 1 ? dy : dy * easeInOut(p); await page.mouse.wheel(0, next - done); done = next; await page.waitForTimeout(16); }}
// Glide in a straight line and log it, so the Remotion cursor follows.async function glide(x, y) { const distance = Math.hypot(x - mouse.x, y - mouse.y); const t = now(); await page.mouse.move(x, y, { steps: Math.max(10, Math.round(distance / 12)), }); log({ type: "move", t, t1: now(), from: [mouse.x, mouse.y], to: [x, y] }); mouse = { x, y };}
// Bring an element to the reading line by scrolling, never by a jump cut.async function scrollTo(locator) { const margin = 96; for (let attempt = 0; attempt < 3; attempt++) { const box = await locator.boundingBox(); if (!box) break; if (box.y >= margin && box.y + box.height <= height - margin) return; const dy = box.y + box.height / 2 - height * 0.45; // The wheel scrolls whatever is under the pointer: bring it over the // target's column first, so a sidebar or popover does not take the scroll. const x = Math.min(width - 24, Math.max(24, box.x + box.width / 2)); if (Math.abs(x - mouse.x) > 48) await glide(x, Math.min(height - 120, Math.max(120, mouse.y))); const t = now(); await wheel(dy); await page.waitForTimeout(150); log({ type: "scroll", t, t1: now(), dy }); const after = await locator.boundingBox(); if (!after || Math.abs(after.y - box.y) < 1) break; } // Nested scroll areas the wheel cannot reach still get there, abruptly. await locator.scrollIntoViewIfNeeded();}
// Glide to the element's centre, so the viewer can follow the hand.async function moveTo(locator) { await locator.waitFor({ state: "visible" }); await scrollTo(locator); const box = await locator.boundingBox(); await glide(box.x + box.width / 2, box.y + box.height / 2); return box;}
const rect = (box) => [box.x, box.y, box.width, box.height];
// A broken image in a tutorial is worse than no tutorial: fail the run.async function settle() { await page.waitForFunction( () => [...document.images].every((img) => img.complete), null, { timeout: 15000 }, ); // Off-canvas panels (collapsed right sidebar) keep their images in the DOM, // shifted sideways; below-the-fold images still count, a scroll shows them. const broken = await page.evaluate(() => [...document.images] .filter((img) => { if (img.naturalWidth !== 0) return false; if (!img.checkVisibility({ visibilityProperty: true })) return false; const r = img.getBoundingClientRect(); return r.width > 0 && r.right > 0 && r.left < innerWidth; }) .map((img) => img.currentSrc || img.src), ); if (broken.length) throw new Error( `images failed to load:\n ${[...new Set(broken)].join("\n ")}`, );}
const rec = { page, sql, wait, moveTo, settle, async goto(path) { await page.goto(path, { waitUntil: "networkidle" }); await settle(); }, async click(locator) { const box = await moveTo(locator); await wait(250); log({ type: "click", t: now(), at: [mouse.x, mouse.y], box: rect(box) }); await page.mouse.down(); await wait(80); await page.mouse.up(); await wait(400); }, async type(locator, text) { await rec.click(locator); const t = now(); await locator.pressSequentially(text, { delay: 70 / speed }); const box = await locator.boundingBox(); log({ type: "type", t, t1: now(), box: rect(box), text }); await wait(300); }, // Fail the run unless the app confirms the action, e.g. a "Saved" toast. async expect(text) { await page.getByText(text, { exact: true }).first().waitFor(); }, // Caption for what follows; ignored by the plain MP4. step(label) { log({ type: "step", t: now(), label }); }, // Compile routes in Next dev before the camera rolls, not mid-click. async warm(paths) { for (const path of paths) await context.request.get(path); }, // The still behind the video's thumbnail: this moment, without cursor or // toasts, keeping `focus` in frame. Without a call, the final state is used. async thumbnail(focus) { if (!remotion) return; if (focus) await scrollTo(focus); const box = focus ? await focus.boundingBox() : null; rec.thumb = { png: await page.screenshot({ style: "#__rec-cursor, .__rec-ripple, [data-sonner-toaster] { display: none !important; }", }), focus: box && rect(box), }; }, // Open the first page and mark where the video starts; everything before // (login, first load, cleanup) is cut. The label is the opening caption. async start(path, label) { if (path) await rec.goto(path); await settle(); rec.startAt = now(); if (label) rec.step(label); await wait(1000); },};Object.assign( rec, ( await import(pathToFileURL(join(tutorialsDir, "lib", "ui.mjs")).href) ).createUi(rec),);
const stem = `${name}${theme === "dark" ? ".dark" : ""}`;const failureShot = join(out, `${stem}.failure.png`);rmSync(failureShot, { force: true });let failed = null;let shot = false;try { await steps(rec); // Hold on the result: the last action's outcome (a posted update, a saved // form) lands after its confirmation toast, and the video ends here. await rec.settle(); await rec.wait(2000); if (remotion && !rec.thumb) await rec.thumbnail();} catch (e) { failed = e; shot = await page .screenshot({ path: failureShot }) .then(() => true) .catch(() => false);}await cdp.send("Page.stopScreencast").catch(() => {});const endAt = now();await context.close();await browser.close();
if (failed) { rmSync(tmp, { recursive: true, force: true }); console.error(`tutorial ${name} failed, no video written: ${failed.message}`); if (shot) console.error(`last frame: ${failureShot}`); process.exit(1);}
// Variable-rate frames โ constant-rate video: each frame holds until the next.// The frame showing at start() opens the video; earlier ones are cut.const startAt = rec.startAt ?? frames[0]?.t ?? endAt;const opening = frames.findLastIndex((f) => f.t <= startAt);const shown = frames.slice(Math.max(opening, 0));const lines = shown.flatMap((f, i) => { const next = shown[i + 1]?.t ?? endAt; return [ `file '${f.file}'`, `duration ${(next - Math.max(f.t, startAt)).toFixed(4)}`, ];});lines.push(`file '${shown.at(-1).file}'`);const list = join(tmp, "frames.txt");writeFileSync(list, lines.join("\n"));
const dir = remotion ? join(out, "remotion", stem) : out;mkdirSync(dir, { recursive: true });const file = remotion ? join(dir, "video.mp4") : join(dir, `${stem}.mp4`);const ffmpeg = spawnSync("ffmpeg", [ "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", list, "-vf", `fps=${fps},format=yuv420p`, "-c:v", "libx264", "-crf", remotion ? "14" : "18", "-movflags", "+faststart", file,]);rmSync(tmp, { recursive: true, force: true });if (ffmpeg.error || ffmpeg.status !== 0) { rmSync(file, { force: true }); console.error( `ffmpeg failed: ${ffmpeg.error?.message ?? ffmpeg.stderr?.toString()}`, ); process.exit(1);}
if (remotion) { const timeline = { name, meta: tutorial.meta, theme, fps, viewport: { width, height }, scale, duration: endAt - startAt, thumbnail: { focus: rec.thumb?.focus ?? null }, events: events .filter((e) => e.t >= startAt) .map((e) => ({ ...e, t: e.t - startAt, ...(e.t1 !== undefined && { t1: e.t1 - startAt }), })), }; writeFileSync(join(dir, "timeline.json"), JSON.stringify(timeline, null, 2)); writeFileSync(join(dir, "thumbnail.png"), rec.thumb.png);}console.log(file);