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.
| 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"; |
| 29 | import { softAuth } from "../middleware/auth"; | |
| 30 | import type { AuthEnv } from "../middleware/auth"; | |
| 31 | import { isSiteAdmin } from "../lib/admin"; | |
| 32 | ||
| 33 | const page = new Hono<AuthEnv>(); | |
| 34 | page.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 | */ | |
| 46 | export 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. */ | |
| 60 | export function shortSha(sha: string): string { | |
| 61 | return (sha || "").slice(0, 7).toLowerCase(); | |
| 62 | } | |
| 63 | ||
| 64 | /** Format duration_ms into "12s" / "1m 14s" / "—". */ | |
| 65 | export 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 | ||
| 638d24e | 74 | /** |
| 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 | */ | |
| 79 | function 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 | ||
| f764c07 | 89 | interface 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 | ||
| 9dd96b9 | 101 | /** |
| 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 | */ | |
| 107 | export 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 | ||
| 117 | interface DeployStepRow { | |
| 118 | stepName: string; | |
| 119 | status: string; | |
| 120 | startedAt: Date; | |
| 121 | finishedAt: Date | null; | |
| 122 | durationMs: number | null; | |
| 123 | } | |
| 124 | ||
| 125 | async 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 | ||
| f764c07 | 145 | async 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 | ||
| 169 | function 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 | ||
| 189 | async 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 | ||
| 219 | page.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 | ||
| 230 | page.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 | ||
| 9dd96b9 | 238 | // 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 | ||
| 638d24e | 255 | // 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 | ||
| f764c07 | 315 | return c.html( |
| 316 | <Layout title="Deploys — admin" user={user}> | |
| 638d24e | 317 | <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> | |
| f764c07 | 382 | |
| 638d24e | 383 | <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> | |
| f764c07 | 441 | </div> |
| 638d24e | 442 | </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> | |
| f764c07 | 468 | <span title={lastSuccess.startedAt.toISOString()}> |
| 469 | {relativeTime(lastSuccess.startedAt)} | |
| 470 | </span> | |
| 638d24e | 471 | <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> | |
| f764c07 | 477 | </div> |
| 478 | </div> | |
| 638d24e | 479 | )} |
| 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> | |
| f764c07 | 502 | </div> |
| 638d24e | 503 | ) : ( |
| 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> | |
| f764c07 | 621 | )} |
| 622 | </div> | |
| 623 | ||
| 638d24e | 624 | <style dangerouslySetInnerHTML={{ __html: DEPLOYS_PAGE_CSS }} /> |
| 9dd96b9 | 625 | |
| 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)} | |
| f764c07 | 631 | </Layout> |
| 632 | ); | |
| 633 | }); | |
| 634 | ||
| 9dd96b9 | 635 | /** |
| 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 | */ | |
| 645 | function 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" | |
| dc26881 | 672 | 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 | 673 | > |
| dc26881 | 674 | <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);gap:var(--space-2)"> |
| 9dd96b9 | 675 | <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" | |
| dc26881 | 688 | 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 | 689 | > |
| 690 | × | |
| 691 | </button> | |
| 692 | </div> | |
| 693 | <ol | |
| 694 | id="deploy-modal-steps" | |
| dc26881 | 695 | style="list-style:none;padding:0;margin:0 0 var(--space-3);display:flex;flex-direction:column;gap:6px" |
| 9dd96b9 | 696 | > |
| 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"} | |
| dc26881 | 728 | 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 | 729 | > |
| 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 | */ | |
| 791 | const 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 | ||
| 638d24e | 872 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 878 | const 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; | |
| 6fd5915 | 901 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| 638d24e | 902 | 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; | |
| 6fd5915 | 909 | background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 638d24e | 910 | 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; | |
| 6fd5915 | 941 | background: linear-gradient(135deg, #5b6ee8, #5f8fa0); |
| 942 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); | |
| 638d24e | 943 | } |
| 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 { | |
| 6fd5915 | 954 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 638d24e | 955 | -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 { | |
| 6fd5915 | 1009 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 638d24e | 1010 | color: #ffffff; |
| 6fd5915 | 1011 | box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16); |
| 638d24e | 1012 | } |
| 1013 | .deploys-btn-primary:hover { | |
| 1014 | transform: translateY(-1px); | |
| 6fd5915 | 1015 | box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20); |
| 638d24e | 1016 | } |
| 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 { | |
| 6fd5915 | 1088 | background: rgba(91,110,232,0.10); |
| 1089 | border-color: rgba(91,110,232,0.40); | |
| 638d24e | 1090 | 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; | |
| 6fd5915 | 1212 | background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%); |
| 638d24e | 1213 | 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; | |
| 6fd5915 | 1242 | background: rgba(91,110,232,0.10); |
| 638d24e | 1243 | 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; | |
| 6fd5915 | 1391 | background: rgba(91,110,232,0.14); |
| 1392 | color: #5b6ee8; | |
| 1393 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32); | |
| 638d24e | 1394 | 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 | ||
| f764c07 | 1532 | export const __test = { |
| 1533 | relativeTime, | |
| 1534 | shortSha, | |
| 1535 | formatDuration, | |
| 1536 | fetchLatest, | |
| 1537 | serialise, | |
| 9dd96b9 | 1538 | fetchStepsForDeploy, |
| 1539 | R2_STEP_ORDER, | |
| 1540 | DEPLOY_MODAL_JS, | |
| 638d24e | 1541 | DEPLOYS_PAGE_CSS, |
| 1542 | formatStepDuration, | |
| f764c07 | 1543 | }; |
| 1544 | ||
| 1545 | export default page; |