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