Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

admin-deploys-page.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin-deploys-page.tsxBlame1546 lines · 4 contributors
f764c07Claude1/**
2 * Block N3 — Platform deploy timeline page + JSON feed.
3 *
4 * GET /admin/deploys — site-admin HTML timeline (last 50 deploys)
5 * GET /admin/deploys/latest.json — `{ latest, asOf }` JSON. Polled by the
6 * layout status pill on every page; SSE
7 * pushes follow via `platform:deploys`.
8 *
9 * The companion POST /admin/deploys/trigger lives in `src/routes/admin-deploys.tsx`
10 * (Block N4 — pre-existing locked file) — we MUST NOT extend that file, so
11 * these GET routes ship as a sibling. Both mount on the same Hono `/` so the
12 * URLs land where the spec expects.
13 *
14 * Backed by `platform_deploys` (drizzle/0046_platform_deploys.sql,
15 * src/db/schema-deploys.ts). Populated by
16 * `POST /api/events/deploy/{started,finished}` in `src/routes/events.ts`,
17 * which the `.github/workflows/hetzner-deploy.yml` workflow calls as it runs.
18 */
19
20import { Hono } from "hono";
9dd96b9Test User21import { raw } from "hono/html";
22import { asc, desc, eq } from "drizzle-orm";
f764c07Claude23import { db } from "../db";
9dd96b9Test User24import {
25 platformDeploys,
26 platformDeploySteps,
27} from "../db/schema-deploys";
f764c07Claude28import { Layout } from "../views/layout";
f4a1547ccantynz-alt29import { AdminShell } from "../views/admin-shell";
f764c07Claude30import { softAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import { isSiteAdmin } from "../lib/admin";
33
34const page = new Hono<AuthEnv>();
35page.use("*", softAuth);
36
37// ---------------------------------------------------------------------------
38// Helpers — exposed via `__test` so unit tests can hammer the format edges
39// without setting up the full Hono request pipeline.
40// ---------------------------------------------------------------------------
41
42/**
43 * Render a relative time like "just now", "12s ago", "3m ago", "2h ago",
44 * "3d ago". Stable for any past Date; clamps negative deltas to "just now"
45 * so a slight clock skew doesn't print "-2s".
46 */
47export function relativeTime(from: Date, now: Date = new Date()): string {
48 const ms = now.getTime() - from.getTime();
49 if (ms < 5_000) return "just now";
50 const s = Math.floor(ms / 1_000);
51 if (s < 60) return `${s}s ago`;
52 const m = Math.floor(s / 60);
53 if (m < 60) return `${m}m ago`;
54 const h = Math.floor(m / 60);
55 if (h < 24) return `${h}h ago`;
56 const d = Math.floor(h / 24);
57 return `${d}d ago`;
58}
59
60/** Short SHA — first 7 hex chars, lowercased. */
61export function shortSha(sha: string): string {
62 return (sha || "").slice(0, 7).toLowerCase();
63}
64
65/** Format duration_ms into "12s" / "1m 14s" / "—". */
66export function formatDuration(ms: number | null | undefined): string {
67 if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "—";
68 const s = Math.round(ms / 1000);
69 if (s < 60) return `${s}s`;
70 const m = Math.floor(s / 60);
71 const rem = s - m * 60;
72 return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
73}
74
638d24eClaude75/**
76 * Format a step duration in milliseconds for an inline pill.
77 * Short and scannable: `420ms`, `3.4s`, `1m02`. Falls back to `…` while
78 * a step is still in flight (no duration yet).
79 */
80function formatStepDuration(ms: number | null | undefined): string {
81 if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "…";
82 if (ms < 1000) return `${Math.round(ms)}ms`;
83 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
84 const totalS = Math.round(ms / 1000);
85 const m = Math.floor(totalS / 60);
86 const rem = totalS - m * 60;
87 return `${m}m${rem.toString().padStart(2, "0")}`;
88}
89
f764c07Claude90interface DeployRow {
91 id: string;
92 runId: string;
93 sha: string;
94 source: string;
95 status: string;
96 startedAt: Date;
97 finishedAt: Date | null;
98 durationMs: number | null;
99 error: string | null;
100}
101
9dd96b9Test User102/**
103 * R2 — the canonical step order surfaced by `hetzner-deploy.yml`. Drives
104 * the modal skeleton so steps render with a stable order even before any
105 * step events have arrived. Mirror exactly what the workflow + notify
106 * helper emits as `step_name`.
107 */
108export const R2_STEP_ORDER: ReadonlyArray<{ name: string; label: string }> = [
109 { name: "setup", label: "Setup" },
110 { name: "git-pull", label: "Git pull" },
111 { name: "bun-install", label: "Bun install" },
112 { name: "build", label: "Build" },
113 { name: "db-migrate", label: "DB migrate" },
114 { name: "restart-service", label: "Restart service" },
115 { name: "smoke-test", label: "Smoke test" },
116];
117
118interface DeployStepRow {
119 stepName: string;
120 status: string;
121 startedAt: Date;
122 finishedAt: Date | null;
123 durationMs: number | null;
124}
125
126async function fetchStepsForDeploy(deployId: string): Promise<DeployStepRow[]> {
127 try {
128 const rows = await db
129 .select({
130 stepName: platformDeploySteps.stepName,
131 status: platformDeploySteps.status,
132 startedAt: platformDeploySteps.startedAt,
133 finishedAt: platformDeploySteps.finishedAt,
134 durationMs: platformDeploySteps.durationMs,
135 })
136 .from(platformDeploySteps)
137 .where(eq(platformDeploySteps.deployId, deployId))
138 .orderBy(asc(platformDeploySteps.startedAt));
139 return rows as DeployStepRow[];
140 } catch (err) {
141 console.error("[admin-deploys-page] fetchStepsForDeploy failed:", err);
142 return [];
143 }
144}
145
f764c07Claude146async function fetchLatest(limit = 50): Promise<DeployRow[]> {
147 try {
148 const rows = await db
149 .select({
150 id: platformDeploys.id,
151 runId: platformDeploys.runId,
152 sha: platformDeploys.sha,
153 source: platformDeploys.source,
154 status: platformDeploys.status,
155 startedAt: platformDeploys.startedAt,
156 finishedAt: platformDeploys.finishedAt,
157 durationMs: platformDeploys.durationMs,
158 error: platformDeploys.error,
159 })
160 .from(platformDeploys)
161 .orderBy(desc(platformDeploys.startedAt))
162 .limit(limit);
163 return rows as DeployRow[];
164 } catch (err) {
165 console.error("[admin-deploys-page] fetchLatest failed:", err);
166 return [];
167 }
168}
169
170function serialise(row: DeployRow): Record<string, unknown> {
171 return {
172 id: row.id,
173 run_id: row.runId,
174 sha: row.sha,
175 source: row.source,
176 status: row.status,
177 started_at: row.startedAt.toISOString(),
178 finished_at: row.finishedAt ? row.finishedAt.toISOString() : null,
179 duration_ms: row.durationMs,
180 error: row.error,
181 };
182}
183
184// ---------------------------------------------------------------------------
185// Gate — both routes refuse anyone who isn't a site admin. The JSON variant
186// returns 401/403 JSON so the layout pill can `fetch()` it safely on every
187// page and silently disappear for non-admins.
188// ---------------------------------------------------------------------------
189
190async function gate(
191 c: any,
192 asJson: boolean
193): Promise<{ user: any } | Response> {
194 const user = c.get("user");
195 if (!user) {
196 return asJson
197 ? c.json({ ok: false, error: "Unauthorized" }, 401)
198 : c.redirect("/login?next=/admin/deploys");
199 }
200 if (!(await isSiteAdmin(user.id))) {
201 return asJson
202 ? c.json({ ok: false, error: "Forbidden" }, 403)
203 : c.html(
204 <Layout title="Forbidden" user={user}>
205 <div class="empty-state">
206 <h2>403 — Not a site admin</h2>
207 <p>You don't have permission to view this page.</p>
208 </div>
209 </Layout>,
210 403
211 );
212 }
213 return { user };
214}
215
216// ---------------------------------------------------------------------------
217// Routes
218// ---------------------------------------------------------------------------
219
220page.get("/admin/deploys/latest.json", async (c) => {
221 const g = await gate(c, true);
222 if (g instanceof Response) return g;
223 const rows = await fetchLatest(1);
224 return c.json({
225 ok: true,
226 latest: rows[0] ? serialise(rows[0]) : null,
227 asOf: new Date().toISOString(),
228 });
229});
230
231page.get("/admin/deploys", async (c) => {
232 const g = await gate(c, false);
233 if (g instanceof Response) return g;
234 const { user } = g;
235 const rows = await fetchLatest(50);
236 const lastSuccess = rows.find((r) => r.status === "succeeded") || null;
237 const repo = process.env.GITHUB_REPOSITORY || "ccantynz/Gluecron.com";
238
9dd96b9Test User239 // R2 — if a deploy is currently in_progress, fetch its persisted step
240 // history so the modal pre-fills on hard refresh. SSE then keeps it
241 // live going forward. If `?modal=<run_id>` is present we open the
242 // modal regardless of state (the Trigger button uses an inline JS
243 // path; this query-string mode is for deep-linkable debug).
244 const inProgress = rows.find((r) => r.status === "in_progress") || null;
245 const queryModalRun = (() => {
246 const qs = c.req.query("modal");
247 return typeof qs === "string" && qs.length > 0 ? qs : null;
248 })();
249 const modalDeploy =
250 inProgress ||
251 (queryModalRun ? rows.find((r) => r.runId === queryModalRun) || null : null);
252 const modalSteps: DeployStepRow[] = modalDeploy
253 ? await fetchStepsForDeploy(modalDeploy.id)
254 : [];
255
638d24eClaude256 // Fetch step rows for the most-recent deploys so we can render coloured
257 // step pills inline. Limited to 12 to keep the DB hop short — older rows
258 // collapse to a duration-only summary.
259 const STEPS_FETCH_LIMIT = 12;
260 const stepsByDeploy = new Map<string, DeployStepRow[]>();
261 await Promise.all(
262 rows.slice(0, STEPS_FETCH_LIMIT).map(async (r) => {
263 const s = await fetchStepsForDeploy(r.id);
264 stepsByDeploy.set(r.id, s);
265 })
266 );
267
268 // Stats — today / 7-day window / since-last elapsed.
269 const now = new Date();
270 const dayMs = 24 * 60 * 60_000;
271 const startOfDay = new Date(now);
272 startOfDay.setHours(0, 0, 0, 0);
273 const sevenDaysAgo = new Date(now.getTime() - 7 * dayMs);
274 const todayCount = rows.filter((r) => r.startedAt >= startOfDay).length;
275 const weekRows = rows.filter(
276 (r) => r.startedAt >= sevenDaysAgo && r.status !== "in_progress"
277 );
278 const weekSucceeded = weekRows.filter((r) => r.status === "succeeded").length;
279 const successRatePct =
280 weekRows.length === 0
281 ? null
282 : Math.round((weekSucceeded / weekRows.length) * 100);
283
284 const latest = rows[0] || null;
285 const latestState: "green" | "failed" | "rolling" | "idle" = !latest
286 ? "idle"
287 : latest.status === "succeeded"
288 ? "green"
289 : latest.status === "failed"
290 ? "failed"
291 : "rolling";
292
293 const headline =
294 latestState === "idle"
295 ? "No deploys yet."
296 : latestState === "green"
297 ? "Last deploy: green."
298 : latestState === "failed"
299 ? "Last deploy: failed."
300 : "Rolling out…";
301
302 const elapsedSinceLast = latest ? relativeTime(latest.startedAt, now) : "—";
303
304 // In-flight banner — only when a deploy is actually mid-flight, and we
305 // know how many of the canonical steps have at least started.
306 let inflightLine: string | null = null;
307 if (inProgress) {
308 const steps = stepsByDeploy.get(inProgress.id) || [];
309 const completed = steps.filter(
310 (s) => s.status === "succeeded" || s.status === "failed"
311 ).length;
312 const total = R2_STEP_ORDER.length;
313 inflightLine = `Deploy in progress — ${completed} / ${total} steps complete`;
314 }
315
f764c07Claude316 return c.html(
f4a1547ccantynz-alt317 <AdminShell active="deploys" title="Deploys" user={user}>
638d24eClaude318 <div class="deploys-wrap">
319 <section class="deploys-hero">
320 <div class="deploys-hero-orb" aria-hidden="true" />
321 <div class="deploys-hero-inner">
322 <div class="deploys-hero-top">
323 <div class="deploys-hero-text">
324 <div class="deploys-eyebrow">
325 <span class="deploys-eyebrow-dot" aria-hidden="true" />
326 Deploys · live timeline
327 </div>
328 <h1 class="deploys-title">
329 <span
330 class={
331 "deploys-title-grad " +
332 (latestState === "failed"
333 ? "is-failed"
334 : latestState === "rolling"
335 ? "is-rolling"
336 : "is-green")
337 }
338 >
339 {headline}
340 </span>
341 </h1>
342 <p class="deploys-sub">
343 Operator timeline for{" "}
344 <code class="deploys-mono-sub">{repo}</code>. Push to{" "}
345 <code class="deploys-mono-sub">main</code> fires{" "}
346 <code class="deploys-mono-sub">hetzner-deploy.yml</code>{" "}
347 — every step lands here in real time.
348 </p>
349 </div>
350 <div class="deploys-hero-actions">
351 <form
352 method="post"
353 action="/admin/deploys/trigger"
354 class="deploys-trigger-form"
355 >
356 <button
357 type="submit"
358 class="deploys-btn deploys-btn-primary"
359 >
360 Trigger deploy
361 </button>
362 </form>
363 <form
364 method="post"
365 action="/admin/ops/rollback"
366 class="deploys-rollback-form"
367 onsubmit="return confirm('Roll back main to the previous tagged release?')"
368 >
369 <button
370 type="submit"
371 class="deploys-btn deploys-btn-ghost"
372 title={
373 lastSuccess
374 ? `Rollback to ${shortSha(lastSuccess.sha)}`
375 : "No prior successful deploy on file"
376 }
377 >
378 Rollback to previous
379 </button>
380 </form>
381 </div>
382 </div>
f764c07Claude383
638d24eClaude384 <div class="deploys-stats" role="list">
385 <div class="deploys-stat" role="listitem">
386 <div class="deploys-stat-label">Latest SHA</div>
387 <div class="deploys-stat-value deploys-tabular">
388 {latest ? (
389 <a
390 class="deploys-sha-pill"
391 href={`https://github.com/${repo}/commit/${latest.sha}`}
392 target="_blank"
393 rel="noreferrer noopener"
394 >
395 <span
396 class={`deploys-sha-dot is-${latestState}`}
397 aria-hidden="true"
398 />
399 <code>{shortSha(latest.sha)}</code>
400 </a>
401 ) : (
402 <span class="deploys-stat-empty">—</span>
403 )}
404 </div>
405 </div>
406 <div class="deploys-stat" role="listitem">
407 <div class="deploys-stat-label">Since last deploy</div>
408 <div class="deploys-stat-value deploys-tabular">
409 {elapsedSinceLast}
410 </div>
411 </div>
412 <div class="deploys-stat" role="listitem">
413 <div class="deploys-stat-label">Deploys today</div>
414 <div class="deploys-stat-value deploys-tabular">
415 {todayCount}
416 </div>
417 </div>
418 <div class="deploys-stat" role="listitem">
419 <div class="deploys-stat-label">7-day success</div>
420 <div class="deploys-stat-value deploys-tabular">
421 {successRatePct === null ? (
422 <span class="deploys-stat-empty">—</span>
423 ) : (
424 <span
425 class={
426 "deploys-rate " +
427 (successRatePct >= 95
428 ? "is-good"
429 : successRatePct >= 70
430 ? "is-warn"
431 : "is-bad")
432 }
433 >
434 {successRatePct}%
435 </span>
436 )}
437 <span class="deploys-stat-sub">
438 {weekSucceeded}/{weekRows.length}
439 </span>
440 </div>
441 </div>
f764c07Claude442 </div>
638d24eClaude443 </div>
444 </section>
445
446 {inflightLine && (
447 <div
448 class="deploys-inflight"
449 role="status"
450 aria-live="polite"
451 >
452 <span class="deploys-inflight-dot" aria-hidden="true" />
453 <span class="deploys-inflight-text">{inflightLine}</span>
454 <a
455 class="deploys-inflight-link"
456 href={`/admin/deploys?modal=${inProgress!.runId}`}
457 >
458 Watch live →
459 </a>
460 </div>
461 )}
462
463 {lastSuccess && (
464 <div class="deploys-lastgood">
465 <div class="deploys-lastgood-label">Last green deploy</div>
466 <div class="deploys-lastgood-body">
467 <code class="deploys-mono">{shortSha(lastSuccess.sha)}</code>
468 <span class="deploys-lastgood-sep" aria-hidden="true">·</span>
f764c07Claude469 <span title={lastSuccess.startedAt.toISOString()}>
470 {relativeTime(lastSuccess.startedAt)}
471 </span>
638d24eClaude472 <span class="deploys-lastgood-sep" aria-hidden="true">·</span>
473 <span class="deploys-tabular">
474 {formatDuration(lastSuccess.durationMs)}
475 </span>
476 <span class="deploys-lastgood-sep" aria-hidden="true">·</span>
477 <span class="deploys-lastgood-source">{lastSuccess.source}</span>
f764c07Claude478 </div>
479 </div>
638d24eClaude480 )}
481
482 {rows.length === 0 ? (
483 <div class="deploys-empty">
484 <div class="deploys-empty-orb" aria-hidden="true" />
485 <div class="deploys-empty-eyebrow">
486 <span class="deploys-eyebrow-dot" aria-hidden="true" />
487 No deploys yet
488 </div>
489 <h2 class="deploys-empty-title">
490 Push to <code>main</code> to fire your first deploy.
491 </h2>
492 <p class="deploys-empty-sub">
493 The platform polls{" "}
494 <code class="deploys-mono">hetzner-deploy.yml</code> for every
495 push. Each step (setup → git-pull → bun-install → build →
496 db-migrate → restart-service → smoke-test) will render as a
497 coloured pill on this page in real time.
498 </p>
499 <div class="deploys-empty-cli">
500 <span class="deploys-empty-cli-label">CLI shortcut</span>
501 <code>gh workflow run hetzner-deploy.yml -R {repo}</code>
502 </div>
f764c07Claude503 </div>
638d24eClaude504 ) : (
505 <section
506 class="deploys-timeline"
507 aria-label="Recent deploys"
508 >
509 <header class="deploys-timeline-head">
510 <h2 class="deploys-timeline-title">Timeline</h2>
511 <p class="deploys-timeline-sub">
512 Last {rows.length} deploy{rows.length === 1 ? "" : "s"} ·
513 most recent first
514 </p>
515 </header>
516 <ol class="deploys-list">
517 {rows.map((row, i) => {
518 const steps = stepsByDeploy.get(row.id) || [];
519 const hasSteps = steps.length > 0;
520 const isFirst = i === 0;
521 const statusClass =
522 row.status === "succeeded"
523 ? "is-succeeded"
524 : row.status === "failed"
525 ? "is-failed"
526 : "is-running";
527 return (
528 <li
529 class={"deploys-row " + statusClass}
530 data-status={row.status}
531 >
532 <div class="deploys-row-head">
533 <a
534 class="deploys-sha-pill is-row"
535 href={`https://github.com/${repo}/commit/${row.sha}`}
536 target="_blank"
537 rel="noreferrer noopener"
538 title={row.sha}
539 >
540 <span
541 class={`deploys-sha-dot is-${
542 row.status === "succeeded"
543 ? "green"
544 : row.status === "failed"
545 ? "failed"
546 : "rolling"
547 }`}
548 aria-hidden="true"
549 />
550 <code>{shortSha(row.sha)}</code>
551 </a>
552 <span class="deploys-row-source">{row.source}</span>
553 <span
554 class="deploys-row-when"
555 title={row.startedAt.toISOString()}
556 >
557 {relativeTime(row.startedAt)}
558 </span>
559 {isFirst && (
560 <span class="deploys-row-tag">latest</span>
561 )}
562 <span class="deploys-row-spacer" />
563 <span class="deploys-row-duration deploys-tabular">
564 {formatDuration(row.durationMs)}
565 </span>
566 </div>
567
568 {hasSteps ? (
569 <ul
570 class="deploys-steps"
571 aria-label="Deploy steps"
572 >
573 {steps.map((s) => (
574 <li
575 class={`deploys-step is-${s.status}`}
576 data-step={s.stepName}
577 title={`${s.stepName} · ${s.status}`}
578 >
579 <span
580 class="deploys-step-dot"
581 aria-hidden="true"
582 />
583 <span class="deploys-step-name">{s.stepName}</span>
584 <span class="deploys-step-dur deploys-tabular">
585 {formatStepDuration(s.durationMs)}
586 </span>
587 </li>
588 ))}
589 </ul>
590 ) : (
591 <div class="deploys-steps-muted">
592 No per-step data — older deploy or workflow predates
593 step streaming.
594 </div>
595 )}
596
597 {row.status === "failed" && row.error && (
598 <div class="deploys-error" title={row.error}>
599 <span
600 class="deploys-error-icon"
601 aria-hidden="true"
602 >
603 !
604 </span>
605 <span class="deploys-error-msg">
606 {row.error.slice(0, 220)}
607 {row.error.length > 220 ? "…" : ""}
608 </span>
609 </div>
610 )}
611 </li>
612 );
613 })}
614 </ol>
615 <footer class="deploys-timeline-foot">
616 Manual trigger (CLI):{" "}
617 <code class="deploys-mono">
618 gh workflow run hetzner-deploy.yml -R {repo}
619 </code>
620 </footer>
621 </section>
f764c07Claude622 )}
623 </div>
624
638d24eClaude625 <style dangerouslySetInnerHTML={{ __html: DEPLOYS_PAGE_CSS }} />
9dd96b9Test User626
627 {/* R2 — Live deploy modal. Hidden by default; shown when an
628 in_progress deploy is detected or when ?modal=<run_id> is set.
629 The Trigger button posts to /admin/deploys/trigger then opens
630 the modal as soon as the SSE stream sends its first event. */}
631 {renderDeployModal(modalDeploy, modalSteps, repo)}
f4a1547ccantynz-alt632 </AdminShell>
f764c07Claude633 );
634});
635
9dd96b9Test User636/**
637 * R2 — server-render the modal skeleton (steps, status pills, live log
638 * pane) plus the inline JS that wires it to EventSource on
639 * `/live-events/platform:deploys:<run_id>`.
640 *
641 * The modal is ALWAYS rendered in the DOM (hidden by default) so the
642 * client-side Trigger flow can open it without a full page reload after
643 * a successful POST /admin/deploys/trigger. When a deploy is already in
644 * progress on initial load we mark it visible and pre-seed steps.
645 */
646function renderDeployModal(
647 active: DeployRow | null,
648 steps: DeployStepRow[],
649 repo: string
650) {
651 const initiallyOpen = active !== null;
652 // Build a status map keyed by step_name → status for fast initial paint.
653 const stepStatus: Record<string, string> = {};
654 const stepDuration: Record<string, number | null> = {};
655 for (const s of steps) {
656 // Last-write-wins is what we want — succeeded > in_progress.
657 stepStatus[s.stepName] = s.status;
658 stepDuration[s.stepName] = s.durationMs ?? null;
659 }
660 return (
661 <>
662 <div
663 id="deploy-modal-backdrop"
664 data-active-run={active ? active.runId : ""}
665 style={`position:fixed;inset:0;background:rgba(0,0,0,0.55);display:${
666 initiallyOpen ? "flex" : "none"
667 };align-items:flex-start;justify-content:center;padding-top:8vh;z-index:1000`}
668 >
669 <div
670 role="dialog"
671 aria-modal="true"
672 aria-labelledby="deploy-modal-title"
dc26881CC LABS App673 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;max-width:600px;width:92vw;padding:var(--space-4) var(--space-5);box-shadow:0 24px 64px rgba(0,0,0,0.5);font-size:14px;color:var(--text);max-height:80vh;overflow:auto"
9dd96b9Test User674 >
dc26881CC LABS App675 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);gap:var(--space-2)">
9dd96b9Test User676 <h3
677 id="deploy-modal-title"
678 style="margin:0;font-size:16px;font-weight:600"
679 >
680 Deploying — run{" "}
681 <code class="meta-mono" id="deploy-modal-run">
682 #{active ? active.runId : ""}
683 </code>
684 </h3>
685 <button
686 type="button"
687 id="deploy-modal-close"
688 aria-label="Close"
dc26881CC LABS App689 style="background:transparent;border:0;color:var(--text-muted);cursor:pointer;font-size:18px;line-height:1;padding:var(--space-1) var(--space-2)"
9dd96b9Test User690 >
691 ×
692 </button>
693 </div>
694 <ol
695 id="deploy-modal-steps"
dc26881CC LABS App696 style="list-style:none;padding:0;margin:0 0 var(--space-3);display:flex;flex-direction:column;gap:6px"
9dd96b9Test User697 >
698 {R2_STEP_ORDER.map((step) => {
699 const s = stepStatus[step.name];
700 const dur = stepDuration[step.name];
701 const icon =
702 s === "succeeded"
703 ? "✓"
704 : s === "failed"
705 ? "✗"
706 : s === "in_progress"
707 ? "⏳"
708 : "·";
709 const colour =
710 s === "succeeded"
e589f77ccantynz-alt711 ? "var(--green)"
9dd96b9Test User712 : s === "failed"
e589f77ccantynz-alt713 ? "var(--red)"
9dd96b9Test User714 : s === "in_progress"
715 ? "#fbbf24"
716 : "var(--text-muted)";
717 const detail =
718 s === "succeeded" && typeof dur === "number"
719 ? `completed in ${formatDuration(dur)}`
720 : s === "in_progress"
721 ? "in progress"
722 : s === "failed"
723 ? "failed"
724 : "";
725 return (
726 <li
727 data-step={step.name}
728 data-status={s || "pending"}
dc26881CC LABS App729 style="display:flex;align-items:center;gap:var(--space-2);padding:6px var(--space-2);border-radius:6px;background:rgba(255,255,255,0.02)"
9dd96b9Test User730 >
731 <span
732 class="step-icon"
733 aria-hidden="true"
734 style={`display:inline-block;width:18px;text-align:center;color:${colour};font-weight:600`}
735 >
736 {icon}
737 </span>
738 <span class="step-label" style="flex:1">
739 {step.label}
740 </span>
741 <span
742 class="step-detail"
743 style="color:var(--text-muted);font-size:12px"
744 >
745 {detail}
746 </span>
747 </li>
748 );
749 })}
750 </ol>
751 <div
752 style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:10px"
753 >
754 <span id="deploy-modal-elapsed">
755 {active
756 ? `Started ${relativeTime(active.startedAt)}`
757 : "Idle"}
758 </span>
759 <a
760 id="deploy-modal-runlink"
761 href={
762 active
763 ? `https://github.com/${repo}/actions/runs/${active.runId}`
764 : "#"
765 }
766 target="_blank"
767 rel="noreferrer noopener"
768 style="color:var(--text-muted)"
769 >
770 Run on GitHub →
771 </a>
772 </div>
773 </div>
774 </div>
775 {raw(`<script>${DEPLOY_MODAL_JS}</script>`)}
776 </>
777 );
778}
779
780/**
781 * R2 — client-side glue. Plain-JS only (no deps).
782 *
783 * Responsibilities:
784 * - Wire Esc + backdrop click + close-button to hide the modal.
785 * - Hook the "Trigger deploy" form so submission opens the modal and
786 * starts an EventSource for the new run id. The N4 POST returns
787 * {ok:true, run_id?:string} but the run id isn't guaranteed yet —
788 * we fall back to polling /admin/deploys/latest.json once.
789 * - On every SSE 'step' event, update the matching `<li data-step=…>`
790 * row's icon + status pill.
791 */
792const DEPLOY_MODAL_JS = `
793(function(){
794 var modal = document.getElementById('deploy-modal-backdrop');
795 if (!modal) return;
796 var stepsList = document.getElementById('deploy-modal-steps');
797 var runEl = document.getElementById('deploy-modal-run');
798 var runLink = document.getElementById('deploy-modal-runlink');
799 var closeBtn = document.getElementById('deploy-modal-close');
800 var trigger = document.querySelector('form[action="/admin/deploys/trigger"]');
801 var es = null;
802
803 function hide(){ modal.style.display = 'none'; if (es) { try { es.close(); } catch(_){} es = null; } }
804 function show(){ modal.style.display = 'flex'; }
805
806 closeBtn && closeBtn.addEventListener('click', hide);
807 modal.addEventListener('click', function(e){ if (e.target === modal) hide(); });
808 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') hide(); });
809
810 function applyStep(stepName, status, durationMs){
811 var li = stepsList && stepsList.querySelector('li[data-step="' + stepName + '"]');
812 if (!li) return;
813 li.setAttribute('data-status', status);
814 var icon = li.querySelector('.step-icon');
815 var detail = li.querySelector('.step-detail');
816 if (status === 'succeeded') {
e589f77ccantynz-alt817 if (icon) { icon.textContent = '✓'; icon.style.color = 'var(--green)'; }
9dd96b9Test User818 if (detail) detail.textContent = typeof durationMs === 'number'
819 ? ('completed in ' + Math.round(durationMs/1000) + 's')
820 : 'completed';
821 } else if (status === 'failed') {
e589f77ccantynz-alt822 if (icon) { icon.textContent = '✗'; icon.style.color = 'var(--red)'; }
9dd96b9Test User823 if (detail) detail.textContent = 'failed';
824 } else if (status === 'in_progress') {
825 if (icon) { icon.textContent = '⏳'; icon.style.color = '#fbbf24'; }
826 if (detail) detail.textContent = 'in progress';
827 }
828 }
829
830 function attach(runId){
831 if (!runId) return;
832 runEl && (runEl.textContent = '#' + runId);
833 if (runLink) {
834 var href = runLink.getAttribute('href') || '';
835 runLink.setAttribute('href', href.replace(/runs\\/[^/]*$/, 'runs/' + runId));
836 }
837 show();
838 if (es) { try { es.close(); } catch(_){} es = null; }
839 var topic = 'platform:deploys:' + runId;
840 try {
841 es = new EventSource('/live-events/' + topic);
842 } catch (e) { return; }
843 es.addEventListener('step', function(ev){
844 try {
845 var data = JSON.parse(ev.data);
846 applyStep(data.step_name, data.status, data.duration_ms);
847 } catch(_){ /* swallow */ }
848 });
849 }
850
851 // If the server pre-rendered the modal as open, attach to that run id.
852 var preActive = modal.getAttribute('data-active-run') || '';
853 if (preActive) attach(preActive);
854
855 // Hook the trigger form so a click flips the modal open and we discover
856 // the run_id via latest.json.
857 if (trigger) {
858 trigger.addEventListener('submit', function(e){
859 e.preventDefault();
860 show();
861 fetch('/admin/deploys/trigger', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
862 .then(function(){ return fetch('/admin/deploys/latest.json'); })
863 .then(function(r){ return r.json(); })
864 .then(function(j){
865 if (j && j.latest && j.latest.run_id) attach(j.latest.run_id);
866 })
867 .catch(function(){ /* swallow — the page is still usable */ });
868 });
869 }
870})();
871`;
872
638d24eClaude873/* ─────────────────────────────────────────────────────────────────────────
874 * Scoped CSS — every class prefixed `.deploys-` so the operator's deploy
875 * timeline can't bleed into the rest of the admin surface. Mirrors the
876 * gradient-hairline hero + accent orb motif used by admin-integrations
877 * and the shared error-page surface.
878 * ───────────────────────────────────────────────────────────────────── */
879const DEPLOYS_PAGE_CSS = `
880 .deploys-wrap {
881 max-width: 1100px;
882 margin: 0 auto;
883 padding: var(--space-6) var(--space-4) var(--space-12);
884 }
885
886 /* ─── Hero ─── */
887 .deploys-hero {
888 position: relative;
889 margin-bottom: var(--space-5);
890 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
891 background: var(--bg-elevated);
892 border: 1px solid var(--border);
893 border-radius: 18px;
894 overflow: hidden;
895 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
896 }
897 .deploys-hero::before {
898 content: '';
899 position: absolute;
900 top: 0; left: 0; right: 0;
901 height: 2px;
6fd5915Claude902 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
638d24eClaude903 opacity: 0.75;
904 pointer-events: none;
905 }
906 .deploys-hero-orb {
907 position: absolute;
908 inset: -30% -10% auto auto;
909 width: 460px; height: 460px;
6fd5915Claude910 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
638d24eClaude911 filter: blur(80px);
912 opacity: 0.7;
913 pointer-events: none;
914 z-index: 0;
915 }
916 .deploys-hero-inner { position: relative; z-index: 1; }
917
918 .deploys-hero-top {
919 display: flex;
920 align-items: flex-start;
921 justify-content: space-between;
922 gap: var(--space-4);
923 flex-wrap: wrap;
924 margin-bottom: var(--space-5);
925 }
926 .deploys-hero-text { flex: 1; min-width: 280px; }
927 .deploys-eyebrow {
928 display: inline-flex;
929 align-items: center;
930 gap: 8px;
931 text-transform: uppercase;
932 font-family: var(--font-mono);
933 font-size: 11px;
934 letter-spacing: 0.16em;
935 color: var(--text-muted);
936 font-weight: 600;
937 margin-bottom: 14px;
938 }
939 .deploys-eyebrow-dot {
940 width: 8px; height: 8px;
941 border-radius: 9999px;
6fd5915Claude942 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
943 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
638d24eClaude944 }
945 .deploys-title {
946 font-family: var(--font-display);
947 font-size: clamp(28px, 4vw, 40px);
948 font-weight: 800;
949 letter-spacing: -0.028em;
950 line-height: 1.05;
951 margin: 0 0 var(--space-2);
952 color: var(--text-strong);
953 }
954 .deploys-title-grad {
6fd5915Claude955 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
638d24eClaude956 -webkit-background-clip: text;
957 background-clip: text;
958 -webkit-text-fill-color: transparent;
959 color: transparent;
960 }
961 .deploys-title-grad.is-failed {
962 background-image: linear-gradient(135deg, #fca5a5 0%, #f87171 50%, #ef4444 100%);
963 }
964 .deploys-title-grad.is-rolling {
965 background-image: linear-gradient(135deg, #fde68a 0%, #fbbf24 50%, #f59e0b 100%);
966 }
967 .deploys-sub {
968 font-size: 14.5px;
969 color: var(--text-muted);
970 margin: 0;
971 line-height: 1.55;
972 max-width: 640px;
973 }
974 .deploys-mono-sub {
975 font-family: var(--font-mono);
976 font-size: 12.5px;
977 background: rgba(255,255,255,0.04);
978 border: 1px solid var(--border);
979 padding: 1px 6px;
980 border-radius: 5px;
981 color: var(--text);
982 }
983
984 .deploys-hero-actions {
985 display: flex;
986 gap: 10px;
987 flex-shrink: 0;
988 align-items: center;
989 flex-wrap: wrap;
990 }
991 .deploys-trigger-form,
992 .deploys-rollback-form { margin: 0; }
993
994 .deploys-btn {
995 display: inline-flex;
996 align-items: center;
997 justify-content: center;
998 padding: 10px 18px;
999 border-radius: 10px;
1000 font-size: 13.5px;
1001 font-weight: 600;
1002 text-decoration: none;
1003 border: 1px solid transparent;
1004 cursor: pointer;
1005 font-family: inherit;
1006 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
1007 line-height: 1;
1008 }
1009 .deploys-btn-primary {
6fd5915Claude1010 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
638d24eClaude1011 color: #ffffff;
6fd5915Claude1012 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
638d24eClaude1013 }
1014 .deploys-btn-primary:hover {
1015 transform: translateY(-1px);
6fd5915Claude1016 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
638d24eClaude1017 }
1018 .deploys-btn-ghost {
1019 background: transparent;
1020 color: var(--text);
1021 border-color: var(--border-strong, var(--border));
1022 }
1023 .deploys-btn-ghost:hover {
1024 background: rgba(248,113,113,0.06);
1025 border-color: rgba(248,113,113,0.40);
1026 color: #fecaca;
1027 }
1028
1029 /* ─── Stats row ─── */
1030 .deploys-stats {
1031 display: grid;
1032 grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
1033 gap: var(--space-3);
1034 padding-top: var(--space-4);
1035 border-top: 1px solid var(--border);
1036 }
1037 .deploys-stat {
1038 display: flex;
1039 flex-direction: column;
1040 gap: 6px;
1041 }
1042 .deploys-stat-label {
1043 font-size: 11px;
1044 text-transform: uppercase;
1045 letter-spacing: 0.12em;
1046 color: var(--text-muted);
1047 font-weight: 600;
1048 font-family: var(--font-mono);
1049 }
1050 .deploys-stat-value {
1051 font-size: 18px;
1052 color: var(--text-strong);
1053 font-family: var(--font-display);
1054 font-weight: 700;
1055 letter-spacing: -0.012em;
1056 display: inline-flex;
1057 align-items: baseline;
1058 gap: 8px;
1059 }
1060 .deploys-stat-sub {
1061 font-size: 12px;
1062 color: var(--text-muted);
1063 font-family: var(--font-mono);
1064 font-weight: 500;
1065 letter-spacing: 0;
1066 }
1067 .deploys-stat-empty { color: var(--text-muted); font-weight: 600; }
1068 .deploys-tabular { font-variant-numeric: tabular-nums; }
1069
e589f77ccantynz-alt1070 .deploys-rate.is-good { color: var(--green); }
638d24eClaude1071 .deploys-rate.is-warn { color: #fde68a; }
e589f77ccantynz-alt1072 .deploys-rate.is-bad { color: var(--red); }
638d24eClaude1073
1074 /* ─── SHA pill ─── */
1075 .deploys-sha-pill {
1076 display: inline-flex;
1077 align-items: center;
1078 gap: 6px;
1079 padding: 3px 9px;
1080 background: rgba(255,255,255,0.04);
1081 border: 1px solid var(--border);
1082 border-radius: 9999px;
1083 text-decoration: none;
1084 color: var(--text-strong);
1085 font-size: 12.5px;
1086 transition: background 120ms ease, border-color 120ms ease;
1087 }
1088 .deploys-sha-pill:hover {
6fd5915Claude1089 background: rgba(91,110,232,0.10);
1090 border-color: rgba(91,110,232,0.40);
638d24eClaude1091 text-decoration: none;
1092 color: var(--text-strong);
1093 }
1094 .deploys-sha-pill code {
1095 font-family: var(--font-mono);
1096 font-size: 12px;
1097 color: inherit;
1098 background: transparent;
1099 padding: 0;
1100 }
1101 .deploys-sha-pill.is-row {
1102 background: transparent;
1103 border-color: rgba(255,255,255,0.10);
1104 }
1105 .deploys-sha-dot {
1106 width: 7px; height: 7px;
1107 border-radius: 9999px;
1108 background: currentColor;
1109 flex-shrink: 0;
1110 }
e589f77ccantynz-alt1111 .deploys-sha-dot.is-green { background: var(--green); box-shadow: 0 0 0 2px rgba(52,211,153,0.18); }
1112 .deploys-sha-dot.is-failed { background: var(--red); box-shadow: 0 0 0 2px rgba(248,113,113,0.20); }
638d24eClaude1113 .deploys-sha-dot.is-rolling {
1114 background: #fbbf24;
1115 box-shadow: 0 0 0 2px rgba(251,191,36,0.22);
1116 animation: deploys-pulse 1.6s ease-in-out infinite;
1117 }
1118 .deploys-sha-dot.is-idle { background: var(--text-muted); }
1119 @keyframes deploys-pulse {
1120 0%, 100% { opacity: 1; }
1121 50% { opacity: 0.45; }
1122 }
1123 @media (prefers-reduced-motion: reduce) {
1124 .deploys-sha-dot.is-rolling { animation: none; }
1125 }
1126
1127 /* ─── Inflight banner ─── */
1128 .deploys-inflight {
1129 display: flex;
1130 align-items: center;
1131 gap: 12px;
1132 padding: 12px 16px;
1133 margin-bottom: var(--space-4);
1134 background: linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,191,36,0.04));
1135 border: 1px solid rgba(251,191,36,0.32);
1136 border-radius: 12px;
1137 color: #fde68a;
1138 font-size: 13.5px;
1139 }
1140 .deploys-inflight-dot {
1141 width: 8px; height: 8px;
1142 border-radius: 9999px;
1143 background: #fbbf24;
1144 box-shadow: 0 0 0 3px rgba(251,191,36,0.20);
1145 animation: deploys-pulse 1.4s ease-in-out infinite;
1146 flex-shrink: 0;
1147 }
1148 .deploys-inflight-text { flex: 1; font-weight: 600; }
1149 .deploys-inflight-link {
1150 color: #fde68a;
1151 font-weight: 700;
1152 text-decoration: none;
1153 border-bottom: 1px dashed rgba(253,230,138,0.55);
1154 padding-bottom: 1px;
1155 }
1156 .deploys-inflight-link:hover { color: #fef3c7; text-decoration: none; }
1157
1158 /* ─── Last-good strip ─── */
1159 .deploys-lastgood {
1160 display: flex;
1161 align-items: center;
1162 gap: 14px;
1163 padding: 10px 14px;
1164 margin-bottom: var(--space-4);
1165 background: rgba(52,211,153,0.06);
1166 border: 1px solid rgba(52,211,153,0.25);
1167 border-radius: 10px;
1168 font-size: 13px;
1169 color: #bbf7d0;
1170 flex-wrap: wrap;
1171 }
1172 .deploys-lastgood-label {
1173 font-family: var(--font-mono);
1174 font-size: 10.5px;
1175 text-transform: uppercase;
1176 letter-spacing: 0.14em;
1177 font-weight: 700;
e589f77ccantynz-alt1178 color: var(--green);
638d24eClaude1179 }
1180 .deploys-lastgood-body {
1181 display: inline-flex;
1182 align-items: center;
1183 gap: 10px;
1184 flex-wrap: wrap;
1185 color: var(--text);
1186 }
1187 .deploys-lastgood-sep { color: var(--text-muted); opacity: 0.6; }
1188 .deploys-lastgood-source { color: var(--text-muted); font-family: var(--font-mono); font-size: 12px; }
1189 .deploys-mono {
1190 font-family: var(--font-mono);
1191 font-size: 12.5px;
1192 color: var(--text);
1193 background: rgba(255,255,255,0.04);
1194 padding: 2px 7px;
1195 border-radius: 5px;
1196 border: 1px solid var(--border);
1197 }
1198
1199 /* ─── Empty state ─── */
1200 .deploys-empty {
1201 position: relative;
1202 padding: clamp(36px, 6vw, 56px) clamp(24px, 4vw, 44px);
1203 border: 1px dashed var(--border-strong, var(--border));
1204 border-radius: 18px;
1205 background: rgba(255,255,255,0.015);
1206 text-align: left;
1207 overflow: hidden;
1208 }
1209 .deploys-empty-orb {
1210 position: absolute;
1211 inset: -40% -20% auto auto;
1212 width: 360px; height: 360px;
6fd5915Claude1213 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
638d24eClaude1214 filter: blur(70px);
1215 opacity: 0.6;
1216 pointer-events: none;
1217 }
1218 .deploys-empty-eyebrow {
1219 position: relative;
1220 display: inline-flex;
1221 align-items: center;
1222 gap: 8px;
1223 text-transform: uppercase;
1224 font-family: var(--font-mono);
1225 font-size: 11px;
1226 letter-spacing: 0.16em;
1227 color: var(--text-muted);
1228 font-weight: 600;
1229 margin-bottom: 12px;
1230 }
1231 .deploys-empty-title {
1232 position: relative;
1233 font-family: var(--font-display);
1234 font-size: clamp(22px, 3vw, 28px);
1235 font-weight: 700;
1236 letter-spacing: -0.02em;
1237 margin: 0 0 12px;
1238 color: var(--text-strong);
1239 }
1240 .deploys-empty-title code {
1241 font-family: var(--font-mono);
1242 font-size: 0.85em;
6fd5915Claude1243 background: rgba(91,110,232,0.10);
638d24eClaude1244 color: var(--text-link);
1245 padding: 1px 7px;
1246 border-radius: 6px;
1247 }
1248 .deploys-empty-sub {
1249 position: relative;
1250 color: var(--text-muted);
1251 font-size: 14px;
1252 line-height: 1.55;
1253 margin: 0 0 20px;
1254 max-width: 620px;
1255 }
1256 .deploys-empty-sub code {
1257 font-family: var(--font-mono);
1258 font-size: 12.5px;
1259 background: rgba(255,255,255,0.04);
1260 padding: 1px 6px;
1261 border-radius: 4px;
1262 }
1263 .deploys-empty-cli {
1264 position: relative;
1265 display: inline-flex;
1266 align-items: center;
1267 gap: 10px;
1268 padding: 8px 12px;
1269 background: rgba(0,0,0,0.28);
1270 border: 1px solid var(--border);
1271 border-radius: 8px;
1272 }
1273 .deploys-empty-cli-label {
1274 font-family: var(--font-mono);
1275 font-size: 10.5px;
1276 text-transform: uppercase;
1277 letter-spacing: 0.14em;
1278 color: var(--text-muted);
1279 font-weight: 700;
1280 }
1281 .deploys-empty-cli code {
1282 font-family: var(--font-mono);
1283 font-size: 12.5px;
1284 color: var(--text);
1285 background: transparent;
1286 padding: 0;
1287 }
1288
1289 /* ─── Timeline ─── */
1290 .deploys-timeline {
1291 background: var(--bg-elevated);
1292 border: 1px solid var(--border);
1293 border-radius: 16px;
1294 overflow: hidden;
1295 }
1296 .deploys-timeline-head {
1297 padding: var(--space-4) var(--space-5);
1298 border-bottom: 1px solid var(--border);
1299 background: rgba(255,255,255,0.012);
1300 }
1301 .deploys-timeline-title {
1302 margin: 0;
1303 font-family: var(--font-display);
1304 font-size: 16px;
1305 font-weight: 700;
1306 color: var(--text-strong);
1307 letter-spacing: -0.018em;
1308 }
1309 .deploys-timeline-sub {
1310 margin: 4px 0 0;
1311 font-size: 12.5px;
1312 color: var(--text-muted);
1313 }
1314 .deploys-timeline-foot {
1315 padding: var(--space-3) var(--space-5);
1316 border-top: 1px solid var(--border);
1317 background: rgba(255,255,255,0.012);
1318 font-size: 12.5px;
1319 color: var(--text-muted);
1320 }
1321 .deploys-timeline-foot code {
1322 font-family: var(--font-mono);
1323 background: var(--bg-tertiary);
1324 padding: 1px 6px;
1325 border-radius: 4px;
1326 font-size: 12px;
1327 }
1328
1329 .deploys-list {
1330 list-style: none;
1331 margin: 0;
1332 padding: 0;
1333 }
1334 .deploys-row {
1335 position: relative;
1336 padding: 14px var(--space-5);
1337 border-bottom: 1px solid var(--border);
1338 transition: background 120ms ease, transform 120ms ease, box-shadow 120ms ease;
1339 }
1340 .deploys-row:last-child { border-bottom: 0; }
1341 .deploys-row:hover {
1342 background: rgba(255,255,255,0.022);
1343 transform: translateY(-1px);
1344 box-shadow: 0 8px 22px -16px rgba(0,0,0,0.5);
1345 z-index: 1;
1346 }
1347 .deploys-row.is-failed::before {
1348 content: '';
1349 position: absolute;
1350 left: 0; right: 0; top: 0;
1351 height: 2px;
1352 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #ef4444 70%, transparent 100%);
1353 opacity: 0.7;
1354 pointer-events: none;
1355 }
1356 .deploys-row.is-running::after {
1357 content: '';
1358 position: absolute;
1359 left: 0; top: 14px; bottom: 14px;
1360 width: 2px;
1361 background: linear-gradient(180deg, #fbbf24, rgba(251,191,36,0.0));
1362 border-radius: 0 2px 2px 0;
1363 pointer-events: none;
1364 }
1365
1366 .deploys-row-head {
1367 display: flex;
1368 align-items: center;
1369 gap: 12px;
1370 flex-wrap: wrap;
1371 }
1372 .deploys-row-source {
1373 font-family: var(--font-mono);
1374 font-size: 12px;
1375 color: var(--text-muted);
1376 background: rgba(255,255,255,0.04);
1377 padding: 1px 7px;
1378 border-radius: 5px;
1379 border: 1px solid var(--border);
1380 }
1381 .deploys-row-when {
1382 font-size: 13px;
1383 color: var(--text);
1384 }
1385 .deploys-row-tag {
1386 font-family: var(--font-mono);
1387 font-size: 10px;
1388 text-transform: uppercase;
1389 letter-spacing: 0.14em;
1390 padding: 2px 8px;
1391 border-radius: 9999px;
6fd5915Claude1392 background: rgba(91,110,232,0.14);
e589f77ccantynz-alt1393 color: var(--accent);
6fd5915Claude1394 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
638d24eClaude1395 font-weight: 700;
1396 }
1397 .deploys-row-spacer { flex: 1; }
1398 .deploys-row-duration {
1399 font-family: var(--font-mono);
1400 font-size: 12.5px;
1401 color: var(--text-strong);
1402 background: rgba(255,255,255,0.04);
1403 padding: 3px 10px;
1404 border-radius: 9999px;
1405 border: 1px solid var(--border);
1406 }
1407
1408 /* ─── Step pills ─── */
1409 .deploys-steps {
1410 list-style: none;
1411 margin: 10px 0 0;
1412 padding: 0;
1413 display: flex;
1414 flex-wrap: wrap;
1415 gap: 6px;
1416 }
1417 .deploys-step {
1418 display: inline-flex;
1419 align-items: center;
1420 gap: 6px;
1421 padding: 3px 9px;
1422 border-radius: 9999px;
1423 font-size: 11.5px;
1424 background: rgba(255,255,255,0.03);
1425 border: 1px solid var(--border);
1426 color: var(--text);
1427 line-height: 1.4;
1428 }
1429 .deploys-step-dot {
1430 width: 6px; height: 6px;
1431 border-radius: 9999px;
1432 background: var(--text-muted);
1433 flex-shrink: 0;
1434 }
1435 .deploys-step-name {
1436 font-family: var(--font-mono);
1437 font-size: 11.5px;
1438 color: var(--text);
1439 }
1440 .deploys-step-dur {
1441 font-family: var(--font-mono);
1442 font-size: 11px;
1443 color: var(--text-muted);
1444 }
1445 .deploys-step.is-succeeded {
1446 background: rgba(52,211,153,0.10);
1447 border-color: rgba(52,211,153,0.32);
1448 color: #bbf7d0;
1449 }
e589f77ccantynz-alt1450 .deploys-step.is-succeeded .deploys-step-dot { background: var(--green); }
1451 .deploys-step.is-succeeded .deploys-step-dur { color: var(--green); }
638d24eClaude1452 .deploys-step.is-failed {
1453 background: rgba(248,113,113,0.10);
1454 border-color: rgba(248,113,113,0.36);
1455 color: #fecaca;
1456 }
e589f77ccantynz-alt1457 .deploys-step.is-failed .deploys-step-dot { background: var(--red); }
1458 .deploys-step.is-failed .deploys-step-dur { color: var(--red); }
638d24eClaude1459 .deploys-step.is-in_progress {
1460 background: rgba(251,191,36,0.10);
1461 border-color: rgba(251,191,36,0.36);
1462 color: #fde68a;
1463 }
1464 .deploys-step.is-in_progress .deploys-step-dot {
1465 background: #fbbf24;
1466 animation: deploys-pulse 1.4s ease-in-out infinite;
1467 }
1468 .deploys-step.is-in_progress .deploys-step-dur { color: #fcd34d; }
1469 .deploys-step.is-skipped {
1470 color: var(--text-muted);
1471 opacity: 0.6;
1472 }
1473
1474 .deploys-steps-muted {
1475 margin-top: 10px;
1476 font-size: 12px;
1477 color: var(--text-muted);
1478 font-style: italic;
1479 }
1480
1481 /* ─── Failure error inline ─── */
1482 .deploys-error {
1483 display: flex;
1484 align-items: flex-start;
1485 gap: 10px;
1486 margin-top: 10px;
1487 padding: 9px 12px;
1488 background: rgba(248,113,113,0.08);
1489 border: 1px solid rgba(248,113,113,0.30);
1490 border-radius: 8px;
1491 color: #fecaca;
1492 font-size: 12.5px;
1493 line-height: 1.45;
1494 }
1495 .deploys-error-icon {
1496 flex-shrink: 0;
1497 display: inline-flex;
1498 align-items: center;
1499 justify-content: center;
1500 width: 18px; height: 18px;
1501 border-radius: 9999px;
1502 background: rgba(248,113,113,0.20);
1503 color: #fecaca;
1504 font-weight: 800;
1505 font-size: 12px;
1506 line-height: 1;
1507 }
1508 .deploys-error-msg {
1509 font-family: var(--font-mono);
1510 font-size: 12px;
1511 white-space: pre-wrap;
1512 word-break: break-word;
1513 }
1514
1515 /* ─── Light-theme tweaks ─── */
1516 :root[data-theme='light'] .deploys-hero {
1517 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 32px -10px rgba(15,16,28,0.10);
1518 }
1519 :root[data-theme='light'] .deploys-hero-orb,
1520 :root[data-theme='light'] .deploys-empty-orb {
1521 background: radial-gradient(circle, rgba(109,77,255,0.16), rgba(8,145,178,0.08) 45%, transparent 70%);
1522 }
1523
1524 @media (max-width: 640px) {
1525 .deploys-wrap { padding: var(--space-4) var(--space-3) var(--space-8); }
1526 .deploys-hero-actions { width: 100%; }
1527 .deploys-btn { flex: 1 1 auto; }
1528 .deploys-row-head { gap: 8px; }
1529 .deploys-row-spacer { display: none; }
1530 }
1531`;
1532
f764c07Claude1533export const __test = {
1534 relativeTime,
1535 shortSha,
1536 formatDuration,
1537 fetchLatest,
1538 serialise,
9dd96b9Test User1539 fetchStepsForDeploy,
1540 R2_STEP_ORDER,
1541 DEPLOY_MODAL_JS,
638d24eClaude1542 DEPLOYS_PAGE_CSS,
1543 formatStepDuration,
f764c07Claude1544};
1545
1546export default page;