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.
| f764c07 | 1 | /** |
| 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 | ||
| 20 | import { Hono } from "hono"; | |
| 9dd96b9 | 21 | import { raw } from "hono/html"; |
| 22 | import { asc, desc, eq } from "drizzle-orm"; | |
| f764c07 | 23 | import { db } from "../db"; |
| 9dd96b9 | 24 | import { |
| 25 | platformDeploys, | |
| 26 | platformDeploySteps, | |
| 27 | } from "../db/schema-deploys"; | |
| f764c07 | 28 | import { Layout } from "../views/layout"; |
| f4a1547 | 29 | import { AdminShell } from "../views/admin-shell"; |
| f764c07 | 30 | import { softAuth } from "../middleware/auth"; |
| 31 | import type { AuthEnv } from "../middleware/auth"; | |
| 32 | import { isSiteAdmin } from "../lib/admin"; | |
| 33 | ||
| 34 | const page = new Hono<AuthEnv>(); | |
| 35 | page.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 | */ | |
| 47 | export 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. */ | |
| 61 | export function shortSha(sha: string): string { | |
| 62 | return (sha || "").slice(0, 7).toLowerCase(); | |
| 63 | } | |
| 64 | ||
| 65 | /** Format duration_ms into "12s" / "1m 14s" / "—". */ | |
| 66 | export 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 | ||
| 638d24e | 75 | /** |
| 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 | */ | |
| 80 | function 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 | ||
| f764c07 | 90 | interface 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 | ||
| 9dd96b9 | 102 | /** |
| 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 | */ | |
| 108 | export 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 | ||
| 118 | interface DeployStepRow { | |
| 119 | stepName: string; | |
| 120 | status: string; | |
| 121 | startedAt: Date; | |
| 122 | finishedAt: Date | null; | |
| 123 | durationMs: number | null; | |
| 124 | } | |
| 125 | ||
| 126 | async 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 | ||
| f764c07 | 146 | async 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 | ||
| 170 | function 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 | ||
| 190 | async 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 | ||
| 220 | page.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 | ||
| 231 | page.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 | ||
| 9dd96b9 | 239 | // 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 | ||
| 638d24e | 256 | // 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 | ||
| f764c07 | 316 | return c.html( |
| f4a1547 | 317 | <AdminShell active="deploys" title="Deploys" user={user}> |
| 638d24e | 318 | <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> | |
| f764c07 | 383 | |
| 638d24e | 384 | <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> | |
| f764c07 | 442 | </div> |
| 638d24e | 443 | </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> | |
| f764c07 | 469 | <span title={lastSuccess.startedAt.toISOString()}> |
| 470 | {relativeTime(lastSuccess.startedAt)} | |
| 471 | </span> | |
| 638d24e | 472 | <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> | |
| f764c07 | 478 | </div> |
| 479 | </div> | |
| 638d24e | 480 | )} |
| 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> | |
| f764c07 | 503 | </div> |
| 638d24e | 504 | ) : ( |
| 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> | |
| f764c07 | 622 | )} |
| 623 | </div> | |
| 624 | ||
| 638d24e | 625 | <style dangerouslySetInnerHTML={{ __html: DEPLOYS_PAGE_CSS }} /> |
| 9dd96b9 | 626 | |
| 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)} | |
| f4a1547 | 632 | </AdminShell> |
| f764c07 | 633 | ); |
| 634 | }); | |
| 635 | ||
| 9dd96b9 | 636 | /** |
| 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 | */ | |
| 646 | function 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" | |
| dc26881 | 673 | 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" |
| 9dd96b9 | 674 | > |
| dc26881 | 675 | <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);gap:var(--space-2)"> |
| 9dd96b9 | 676 | <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" | |
| dc26881 | 689 | style="background:transparent;border:0;color:var(--text-muted);cursor:pointer;font-size:18px;line-height:1;padding:var(--space-1) var(--space-2)" |
| 9dd96b9 | 690 | > |
| 691 | × | |
| 692 | </button> | |
| 693 | </div> | |
| 694 | <ol | |
| 695 | id="deploy-modal-steps" | |
| dc26881 | 696 | style="list-style:none;padding:0;margin:0 0 var(--space-3);display:flex;flex-direction:column;gap:6px" |
| 9dd96b9 | 697 | > |
| 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" | |
| e589f77 | 711 | ? "var(--green)" |
| 9dd96b9 | 712 | : s === "failed" |
| e589f77 | 713 | ? "var(--red)" |
| 9dd96b9 | 714 | : 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"} | |
| dc26881 | 729 | 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)" |
| 9dd96b9 | 730 | > |
| 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 | */ | |
| 792 | const 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') { | |
| e589f77 | 817 | if (icon) { icon.textContent = '✓'; icon.style.color = 'var(--green)'; } |
| 9dd96b9 | 818 | if (detail) detail.textContent = typeof durationMs === 'number' |
| 819 | ? ('completed in ' + Math.round(durationMs/1000) + 's') | |
| 820 | : 'completed'; | |
| 821 | } else if (status === 'failed') { | |
| e589f77 | 822 | if (icon) { icon.textContent = '✗'; icon.style.color = 'var(--red)'; } |
| 9dd96b9 | 823 | 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 | ||
| 638d24e | 873 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 879 | const 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; | |
| 6fd5915 | 902 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| 638d24e | 903 | 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; | |
| 6fd5915 | 910 | background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 638d24e | 911 | 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; | |
| 6fd5915 | 942 | background: linear-gradient(135deg, #5b6ee8, #5f8fa0); |
| 943 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); | |
| 638d24e | 944 | } |
| 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 { | |
| 6fd5915 | 955 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 638d24e | 956 | -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 { | |
| 6fd5915 | 1010 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 638d24e | 1011 | color: #ffffff; |
| 6fd5915 | 1012 | box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16); |
| 638d24e | 1013 | } |
| 1014 | .deploys-btn-primary:hover { | |
| 1015 | transform: translateY(-1px); | |
| 6fd5915 | 1016 | box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20); |
| 638d24e | 1017 | } |
| 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 | ||
| e589f77 | 1070 | .deploys-rate.is-good { color: var(--green); } |
| 638d24e | 1071 | .deploys-rate.is-warn { color: #fde68a; } |
| e589f77 | 1072 | .deploys-rate.is-bad { color: var(--red); } |
| 638d24e | 1073 | |
| 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 { | |
| 6fd5915 | 1089 | background: rgba(91,110,232,0.10); |
| 1090 | border-color: rgba(91,110,232,0.40); | |
| 638d24e | 1091 | 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 | } | |
| e589f77 | 1111 | .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); } | |
| 638d24e | 1113 | .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; | |
| e589f77 | 1178 | color: var(--green); |
| 638d24e | 1179 | } |
| 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; | |
| 6fd5915 | 1213 | background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%); |
| 638d24e | 1214 | 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; | |
| 6fd5915 | 1243 | background: rgba(91,110,232,0.10); |
| 638d24e | 1244 | 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; | |
| 6fd5915 | 1392 | background: rgba(91,110,232,0.14); |
| e589f77 | 1393 | color: var(--accent); |
| 6fd5915 | 1394 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32); |
| 638d24e | 1395 | 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 | } | |
| e589f77 | 1450 | .deploys-step.is-succeeded .deploys-step-dot { background: var(--green); } |
| 1451 | .deploys-step.is-succeeded .deploys-step-dur { color: var(--green); } | |
| 638d24e | 1452 | .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 | } | |
| e589f77 | 1457 | .deploys-step.is-failed .deploys-step-dot { background: var(--red); } |
| 1458 | .deploys-step.is-failed .deploys-step-dur { color: var(--red); } | |
| 638d24e | 1459 | .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 | ||
| f764c07 | 1533 | export const __test = { |
| 1534 | relativeTime, | |
| 1535 | shortSha, | |
| 1536 | formatDuration, | |
| 1537 | fetchLatest, | |
| 1538 | serialise, | |
| 9dd96b9 | 1539 | fetchStepsForDeploy, |
| 1540 | R2_STEP_ORDER, | |
| 1541 | DEPLOY_MODAL_JS, | |
| 638d24e | 1542 | DEPLOYS_PAGE_CSS, |
| 1543 | formatStepDuration, | |
| f764c07 | 1544 | }; |
| 1545 | ||
| 1546 | export default page; |