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

admin-deploys-page.tsx

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

admin-deploys-page.tsxBlame608 lines · 3 contributors
f764c07Claude1/**
2 * Block N3 — Platform deploy timeline page + JSON feed.
3 *
4 * GET /admin/deploys — site-admin HTML timeline (last 50 deploys)
5 * GET /admin/deploys/latest.json — `{ latest, asOf }` JSON. Polled by the
6 * layout status pill on every page; SSE
7 * pushes follow via `platform:deploys`.
8 *
9 * The companion POST /admin/deploys/trigger lives in `src/routes/admin-deploys.tsx`
10 * (Block N4 — pre-existing locked file) — we MUST NOT extend that file, so
11 * these GET routes ship as a sibling. Both mount on the same Hono `/` so the
12 * URLs land where the spec expects.
13 *
14 * Backed by `platform_deploys` (drizzle/0046_platform_deploys.sql,
15 * src/db/schema-deploys.ts). Populated by
16 * `POST /api/events/deploy/{started,finished}` in `src/routes/events.ts`,
17 * which the `.github/workflows/hetzner-deploy.yml` workflow calls as it runs.
18 */
19
20import { Hono } from "hono";
9dd96b9Test User21import { raw } from "hono/html";
22import { asc, desc, eq } from "drizzle-orm";
f764c07Claude23import { db } from "../db";
9dd96b9Test User24import {
25 platformDeploys,
26 platformDeploySteps,
27} from "../db/schema-deploys";
f764c07Claude28import { Layout } from "../views/layout";
29import { softAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import { isSiteAdmin } from "../lib/admin";
32
33const page = new Hono<AuthEnv>();
34page.use("*", softAuth);
35
36// ---------------------------------------------------------------------------
37// Helpers — exposed via `__test` so unit tests can hammer the format edges
38// without setting up the full Hono request pipeline.
39// ---------------------------------------------------------------------------
40
41/**
42 * Render a relative time like "just now", "12s ago", "3m ago", "2h ago",
43 * "3d ago". Stable for any past Date; clamps negative deltas to "just now"
44 * so a slight clock skew doesn't print "-2s".
45 */
46export function relativeTime(from: Date, now: Date = new Date()): string {
47 const ms = now.getTime() - from.getTime();
48 if (ms < 5_000) return "just now";
49 const s = Math.floor(ms / 1_000);
50 if (s < 60) return `${s}s ago`;
51 const m = Math.floor(s / 60);
52 if (m < 60) return `${m}m ago`;
53 const h = Math.floor(m / 60);
54 if (h < 24) return `${h}h ago`;
55 const d = Math.floor(h / 24);
56 return `${d}d ago`;
57}
58
59/** Short SHA — first 7 hex chars, lowercased. */
60export function shortSha(sha: string): string {
61 return (sha || "").slice(0, 7).toLowerCase();
62}
63
64/** Format duration_ms into "12s" / "1m 14s" / "—". */
65export function formatDuration(ms: number | null | undefined): string {
66 if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "—";
67 const s = Math.round(ms / 1000);
68 if (s < 60) return `${s}s`;
69 const m = Math.floor(s / 60);
70 const rem = s - m * 60;
71 return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
72}
73
74interface DeployRow {
75 id: string;
76 runId: string;
77 sha: string;
78 source: string;
79 status: string;
80 startedAt: Date;
81 finishedAt: Date | null;
82 durationMs: number | null;
83 error: string | null;
84}
85
9dd96b9Test User86/**
87 * R2 — the canonical step order surfaced by `hetzner-deploy.yml`. Drives
88 * the modal skeleton so steps render with a stable order even before any
89 * step events have arrived. Mirror exactly what the workflow + notify
90 * helper emits as `step_name`.
91 */
92export const R2_STEP_ORDER: ReadonlyArray<{ name: string; label: string }> = [
93 { name: "setup", label: "Setup" },
94 { name: "git-pull", label: "Git pull" },
95 { name: "bun-install", label: "Bun install" },
96 { name: "build", label: "Build" },
97 { name: "db-migrate", label: "DB migrate" },
98 { name: "restart-service", label: "Restart service" },
99 { name: "smoke-test", label: "Smoke test" },
100];
101
102interface DeployStepRow {
103 stepName: string;
104 status: string;
105 startedAt: Date;
106 finishedAt: Date | null;
107 durationMs: number | null;
108}
109
110async function fetchStepsForDeploy(deployId: string): Promise<DeployStepRow[]> {
111 try {
112 const rows = await db
113 .select({
114 stepName: platformDeploySteps.stepName,
115 status: platformDeploySteps.status,
116 startedAt: platformDeploySteps.startedAt,
117 finishedAt: platformDeploySteps.finishedAt,
118 durationMs: platformDeploySteps.durationMs,
119 })
120 .from(platformDeploySteps)
121 .where(eq(platformDeploySteps.deployId, deployId))
122 .orderBy(asc(platformDeploySteps.startedAt));
123 return rows as DeployStepRow[];
124 } catch (err) {
125 console.error("[admin-deploys-page] fetchStepsForDeploy failed:", err);
126 return [];
127 }
128}
129
f764c07Claude130async function fetchLatest(limit = 50): Promise<DeployRow[]> {
131 try {
132 const rows = await db
133 .select({
134 id: platformDeploys.id,
135 runId: platformDeploys.runId,
136 sha: platformDeploys.sha,
137 source: platformDeploys.source,
138 status: platformDeploys.status,
139 startedAt: platformDeploys.startedAt,
140 finishedAt: platformDeploys.finishedAt,
141 durationMs: platformDeploys.durationMs,
142 error: platformDeploys.error,
143 })
144 .from(platformDeploys)
145 .orderBy(desc(platformDeploys.startedAt))
146 .limit(limit);
147 return rows as DeployRow[];
148 } catch (err) {
149 console.error("[admin-deploys-page] fetchLatest failed:", err);
150 return [];
151 }
152}
153
154function serialise(row: DeployRow): Record<string, unknown> {
155 return {
156 id: row.id,
157 run_id: row.runId,
158 sha: row.sha,
159 source: row.source,
160 status: row.status,
161 started_at: row.startedAt.toISOString(),
162 finished_at: row.finishedAt ? row.finishedAt.toISOString() : null,
163 duration_ms: row.durationMs,
164 error: row.error,
165 };
166}
167
168// ---------------------------------------------------------------------------
169// Gate — both routes refuse anyone who isn't a site admin. The JSON variant
170// returns 401/403 JSON so the layout pill can `fetch()` it safely on every
171// page and silently disappear for non-admins.
172// ---------------------------------------------------------------------------
173
174async function gate(
175 c: any,
176 asJson: boolean
177): Promise<{ user: any } | Response> {
178 const user = c.get("user");
179 if (!user) {
180 return asJson
181 ? c.json({ ok: false, error: "Unauthorized" }, 401)
182 : c.redirect("/login?next=/admin/deploys");
183 }
184 if (!(await isSiteAdmin(user.id))) {
185 return asJson
186 ? c.json({ ok: false, error: "Forbidden" }, 403)
187 : c.html(
188 <Layout title="Forbidden" user={user}>
189 <div class="empty-state">
190 <h2>403 — Not a site admin</h2>
191 <p>You don't have permission to view this page.</p>
192 </div>
193 </Layout>,
194 403
195 );
196 }
197 return { user };
198}
199
200// ---------------------------------------------------------------------------
201// Routes
202// ---------------------------------------------------------------------------
203
204page.get("/admin/deploys/latest.json", async (c) => {
205 const g = await gate(c, true);
206 if (g instanceof Response) return g;
207 const rows = await fetchLatest(1);
208 return c.json({
209 ok: true,
210 latest: rows[0] ? serialise(rows[0]) : null,
211 asOf: new Date().toISOString(),
212 });
213});
214
215page.get("/admin/deploys", async (c) => {
216 const g = await gate(c, false);
217 if (g instanceof Response) return g;
218 const { user } = g;
219 const rows = await fetchLatest(50);
220 const lastSuccess = rows.find((r) => r.status === "succeeded") || null;
221 const repo = process.env.GITHUB_REPOSITORY || "ccantynz/Gluecron.com";
222
9dd96b9Test User223 // R2 — if a deploy is currently in_progress, fetch its persisted step
224 // history so the modal pre-fills on hard refresh. SSE then keeps it
225 // live going forward. If `?modal=<run_id>` is present we open the
226 // modal regardless of state (the Trigger button uses an inline JS
227 // path; this query-string mode is for deep-linkable debug).
228 const inProgress = rows.find((r) => r.status === "in_progress") || null;
229 const queryModalRun = (() => {
230 const qs = c.req.query("modal");
231 return typeof qs === "string" && qs.length > 0 ? qs : null;
232 })();
233 const modalDeploy =
234 inProgress ||
235 (queryModalRun ? rows.find((r) => r.runId === queryModalRun) || null : null);
236 const modalSteps: DeployStepRow[] = modalDeploy
237 ? await fetchStepsForDeploy(modalDeploy.id)
238 : [];
239
f764c07Claude240 return c.html(
241 <Layout title="Deploys — admin" user={user}>
242 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
243 <h2 style="margin:0">Platform deploys</h2>
244 <form
245 method="post"
246 action="/admin/deploys/trigger"
247 style="margin:0"
248 >
249 <button type="submit" class="btn btn-sm btn-primary">
250 Trigger deploy
251 </button>
252 </form>
253 </div>
254
dc26881CC LABS App255 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)">
f764c07Claude256 {lastSuccess ? (
257 <div>
258 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em">
259 Last successful deploy
260 </div>
dc26881CC LABS App261 <div style="margin-top:var(--space-1);font-size:14px">
f764c07Claude262 <code class="meta-mono">{shortSha(lastSuccess.sha)}</code>
263 {" · "}
264 <span title={lastSuccess.startedAt.toISOString()}>
265 {relativeTime(lastSuccess.startedAt)}
266 </span>
267 {" · "}
268 <span>{formatDuration(lastSuccess.durationMs)}</span>
269 {" · "}
270 <span>{lastSuccess.source}</span>
271 </div>
272 </div>
273 ) : (
274 <div style="color:var(--text-muted);font-size:14px">
275 No successful deploys recorded yet.
276 </div>
277 )}
278 </div>
279
280 <table style="width:100%;border-collapse:collapse;font-size:13px">
281 <thead>
282 <tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">
283 <th style="padding:8px 6px;width:90px">Status</th>
284 <th style="padding:8px 6px">SHA</th>
285 <th style="padding:8px 6px">Source</th>
286 <th style="padding:8px 6px">Started</th>
287 <th style="padding:8px 6px">Duration</th>
288 <th style="padding:8px 6px">Error</th>
289 </tr>
290 </thead>
291 <tbody>
292 {rows.length === 0 && (
293 <tr>
294 <td
295 colspan={6}
dc26881CC LABS App296 style="padding:var(--space-4) 6px;color:var(--text-muted);text-align:center"
f764c07Claude297 >
298 No deploys recorded yet — they'll appear here when the next
299 push to <code>main</code> runs hetzner-deploy.yml.
300 </td>
301 </tr>
302 )}
303 {rows.map((row) => (
304 <tr style="border-bottom:1px solid var(--border)">
305 <td style="padding:8px 6px">
306 <span
307 title={row.status}
308 aria-label={row.status}
309 style={`display:inline-block;width:10px;height:10px;border-radius:50%;background:${
310 row.status === "succeeded"
311 ? "#34d399"
312 : row.status === "failed"
313 ? "#f87171"
314 : "#fbbf24"
315 }`}
316 />
dc26881CC LABS App317 <span style="margin-left:var(--space-2)">{row.status}</span>
f764c07Claude318 </td>
319 <td style="padding:8px 6px">
320 <code class="meta-mono">{shortSha(row.sha)}</code>
321 </td>
322 <td style="padding:8px 6px">{row.source}</td>
323 <td
324 style="padding:8px 6px"
325 title={row.startedAt.toISOString()}
326 >
327 {relativeTime(row.startedAt)}
328 </td>
329 <td style="padding:8px 6px">
330 {formatDuration(row.durationMs)}
331 </td>
332 <td
333 style="padding:8px 6px;color:var(--text-muted);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
334 title={row.error || ""}
335 >
336 {row.error ? row.error.slice(0, 160) : "—"}
337 </td>
338 </tr>
339 ))}
340 </tbody>
341 </table>
342
dc26881CC LABS App343 <p style="margin-top:var(--space-4);font-size:12px;color:var(--text-muted)">
f764c07Claude344 Manual trigger (CLI shortcut — the button above is wired to the N4
345 POST /admin/deploys/trigger handler):{" "}
346 <code class="meta-mono">
347 gh workflow run hetzner-deploy.yml -R {repo}
348 </code>
349 </p>
9dd96b9Test User350
351 {/* R2 — Live deploy modal. Hidden by default; shown when an
352 in_progress deploy is detected or when ?modal=<run_id> is set.
353 The Trigger button posts to /admin/deploys/trigger then opens
354 the modal as soon as the SSE stream sends its first event. */}
355 {renderDeployModal(modalDeploy, modalSteps, repo)}
f764c07Claude356 </Layout>
357 );
358});
359
9dd96b9Test User360/**
361 * R2 — server-render the modal skeleton (steps, status pills, live log
362 * pane) plus the inline JS that wires it to EventSource on
363 * `/live-events/platform:deploys:<run_id>`.
364 *
365 * The modal is ALWAYS rendered in the DOM (hidden by default) so the
366 * client-side Trigger flow can open it without a full page reload after
367 * a successful POST /admin/deploys/trigger. When a deploy is already in
368 * progress on initial load we mark it visible and pre-seed steps.
369 */
370function renderDeployModal(
371 active: DeployRow | null,
372 steps: DeployStepRow[],
373 repo: string
374) {
375 const initiallyOpen = active !== null;
376 // Build a status map keyed by step_name → status for fast initial paint.
377 const stepStatus: Record<string, string> = {};
378 const stepDuration: Record<string, number | null> = {};
379 for (const s of steps) {
380 // Last-write-wins is what we want — succeeded > in_progress.
381 stepStatus[s.stepName] = s.status;
382 stepDuration[s.stepName] = s.durationMs ?? null;
383 }
384 return (
385 <>
386 <div
387 id="deploy-modal-backdrop"
388 data-active-run={active ? active.runId : ""}
389 style={`position:fixed;inset:0;background:rgba(0,0,0,0.55);display:${
390 initiallyOpen ? "flex" : "none"
391 };align-items:flex-start;justify-content:center;padding-top:8vh;z-index:1000`}
392 >
393 <div
394 role="dialog"
395 aria-modal="true"
396 aria-labelledby="deploy-modal-title"
dc26881CC LABS App397 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;max-width:600px;width:92vw;padding:var(--space-4) var(--space-5);box-shadow:0 24px 64px rgba(0,0,0,0.5);font-size:14px;color:var(--text);max-height:80vh;overflow:auto"
9dd96b9Test User398 >
dc26881CC LABS App399 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);gap:var(--space-2)">
9dd96b9Test User400 <h3
401 id="deploy-modal-title"
402 style="margin:0;font-size:16px;font-weight:600"
403 >
404 Deploying — run{" "}
405 <code class="meta-mono" id="deploy-modal-run">
406 #{active ? active.runId : ""}
407 </code>
408 </h3>
409 <button
410 type="button"
411 id="deploy-modal-close"
412 aria-label="Close"
dc26881CC LABS App413 style="background:transparent;border:0;color:var(--text-muted);cursor:pointer;font-size:18px;line-height:1;padding:var(--space-1) var(--space-2)"
9dd96b9Test User414 >
415 ×
416 </button>
417 </div>
418 <ol
419 id="deploy-modal-steps"
dc26881CC LABS App420 style="list-style:none;padding:0;margin:0 0 var(--space-3);display:flex;flex-direction:column;gap:6px"
9dd96b9Test User421 >
422 {R2_STEP_ORDER.map((step) => {
423 const s = stepStatus[step.name];
424 const dur = stepDuration[step.name];
425 const icon =
426 s === "succeeded"
427 ? "✓"
428 : s === "failed"
429 ? "✗"
430 : s === "in_progress"
431 ? "⏳"
432 : "·";
433 const colour =
434 s === "succeeded"
435 ? "#34d399"
436 : s === "failed"
437 ? "#f87171"
438 : s === "in_progress"
439 ? "#fbbf24"
440 : "var(--text-muted)";
441 const detail =
442 s === "succeeded" && typeof dur === "number"
443 ? `completed in ${formatDuration(dur)}`
444 : s === "in_progress"
445 ? "in progress"
446 : s === "failed"
447 ? "failed"
448 : "";
449 return (
450 <li
451 data-step={step.name}
452 data-status={s || "pending"}
dc26881CC LABS App453 style="display:flex;align-items:center;gap:var(--space-2);padding:6px var(--space-2);border-radius:6px;background:rgba(255,255,255,0.02)"
9dd96b9Test User454 >
455 <span
456 class="step-icon"
457 aria-hidden="true"
458 style={`display:inline-block;width:18px;text-align:center;color:${colour};font-weight:600`}
459 >
460 {icon}
461 </span>
462 <span class="step-label" style="flex:1">
463 {step.label}
464 </span>
465 <span
466 class="step-detail"
467 style="color:var(--text-muted);font-size:12px"
468 >
469 {detail}
470 </span>
471 </li>
472 );
473 })}
474 </ol>
475 <div
476 style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:10px"
477 >
478 <span id="deploy-modal-elapsed">
479 {active
480 ? `Started ${relativeTime(active.startedAt)}`
481 : "Idle"}
482 </span>
483 <a
484 id="deploy-modal-runlink"
485 href={
486 active
487 ? `https://github.com/${repo}/actions/runs/${active.runId}`
488 : "#"
489 }
490 target="_blank"
491 rel="noreferrer noopener"
492 style="color:var(--text-muted)"
493 >
494 Run on GitHub →
495 </a>
496 </div>
497 </div>
498 </div>
499 {raw(`<script>${DEPLOY_MODAL_JS}</script>`)}
500 </>
501 );
502}
503
504/**
505 * R2 — client-side glue. Plain-JS only (no deps).
506 *
507 * Responsibilities:
508 * - Wire Esc + backdrop click + close-button to hide the modal.
509 * - Hook the "Trigger deploy" form so submission opens the modal and
510 * starts an EventSource for the new run id. The N4 POST returns
511 * {ok:true, run_id?:string} but the run id isn't guaranteed yet —
512 * we fall back to polling /admin/deploys/latest.json once.
513 * - On every SSE 'step' event, update the matching `<li data-step=…>`
514 * row's icon + status pill.
515 */
516const DEPLOY_MODAL_JS = `
517(function(){
518 var modal = document.getElementById('deploy-modal-backdrop');
519 if (!modal) return;
520 var stepsList = document.getElementById('deploy-modal-steps');
521 var runEl = document.getElementById('deploy-modal-run');
522 var runLink = document.getElementById('deploy-modal-runlink');
523 var closeBtn = document.getElementById('deploy-modal-close');
524 var trigger = document.querySelector('form[action="/admin/deploys/trigger"]');
525 var es = null;
526
527 function hide(){ modal.style.display = 'none'; if (es) { try { es.close(); } catch(_){} es = null; } }
528 function show(){ modal.style.display = 'flex'; }
529
530 closeBtn && closeBtn.addEventListener('click', hide);
531 modal.addEventListener('click', function(e){ if (e.target === modal) hide(); });
532 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') hide(); });
533
534 function applyStep(stepName, status, durationMs){
535 var li = stepsList && stepsList.querySelector('li[data-step="' + stepName + '"]');
536 if (!li) return;
537 li.setAttribute('data-status', status);
538 var icon = li.querySelector('.step-icon');
539 var detail = li.querySelector('.step-detail');
540 if (status === 'succeeded') {
541 if (icon) { icon.textContent = '✓'; icon.style.color = '#34d399'; }
542 if (detail) detail.textContent = typeof durationMs === 'number'
543 ? ('completed in ' + Math.round(durationMs/1000) + 's')
544 : 'completed';
545 } else if (status === 'failed') {
546 if (icon) { icon.textContent = '✗'; icon.style.color = '#f87171'; }
547 if (detail) detail.textContent = 'failed';
548 } else if (status === 'in_progress') {
549 if (icon) { icon.textContent = '⏳'; icon.style.color = '#fbbf24'; }
550 if (detail) detail.textContent = 'in progress';
551 }
552 }
553
554 function attach(runId){
555 if (!runId) return;
556 runEl && (runEl.textContent = '#' + runId);
557 if (runLink) {
558 var href = runLink.getAttribute('href') || '';
559 runLink.setAttribute('href', href.replace(/runs\\/[^/]*$/, 'runs/' + runId));
560 }
561 show();
562 if (es) { try { es.close(); } catch(_){} es = null; }
563 var topic = 'platform:deploys:' + runId;
564 try {
565 es = new EventSource('/live-events/' + topic);
566 } catch (e) { return; }
567 es.addEventListener('step', function(ev){
568 try {
569 var data = JSON.parse(ev.data);
570 applyStep(data.step_name, data.status, data.duration_ms);
571 } catch(_){ /* swallow */ }
572 });
573 }
574
575 // If the server pre-rendered the modal as open, attach to that run id.
576 var preActive = modal.getAttribute('data-active-run') || '';
577 if (preActive) attach(preActive);
578
579 // Hook the trigger form so a click flips the modal open and we discover
580 // the run_id via latest.json.
581 if (trigger) {
582 trigger.addEventListener('submit', function(e){
583 e.preventDefault();
584 show();
585 fetch('/admin/deploys/trigger', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
586 .then(function(){ return fetch('/admin/deploys/latest.json'); })
587 .then(function(r){ return r.json(); })
588 .then(function(j){
589 if (j && j.latest && j.latest.run_id) attach(j.latest.run_id);
590 })
591 .catch(function(){ /* swallow — the page is still usable */ });
592 });
593 }
594})();
595`;
596
f764c07Claude597export const __test = {
598 relativeTime,
599 shortSha,
600 formatDuration,
601 fetchLatest,
602 serialise,
9dd96b9Test User603 fetchStepsForDeploy,
604 R2_STEP_ORDER,
605 DEPLOY_MODAL_JS,
f764c07Claude606};
607
608export default page;