Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

push-watch.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.

push-watch.tsxBlame946 lines · 1 contributor
05ab9b1Claude1/**
2 * Push Watch — per-push live status page.
3 *
4 * Routes:
5 * GET /:owner/:repo/push/:sha — HTML page for this push
6 * GET /api/repos/:owner/:repo/push-status/:sha — JSON polling endpoint
7 *
8 * Shows the developer everything that happened after their push:
9 * commit info, gate results, deployment status, and push-to-live latency.
10 * A plain JS 5-second poller refreshes the gate/deploy cards while any
11 * item is still in a non-terminal state.
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq, or } from "drizzle-orm";
16import { db } from "../db";
17import {
18 activityFeed,
19 gateRuns,
20 deployments,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import { getUnreadCount } from "../lib/unread";
30
31const pushWatchRoutes = new Hono<AuthEnv>();
32
33// Apply soft auth on every push-watch route so the repo-access middleware can
34// read the resolved user.
35pushWatchRoutes.use("/:owner/:repo/push/*", softAuth);
36pushWatchRoutes.use("/api/repos/:owner/:repo/push-status/*", softAuth);
37
38// ---------------------------------------------------------------------------
39// Helpers
40// ---------------------------------------------------------------------------
41
42function shortSha(sha: string): string {
43 return sha.slice(0, 7);
44}
45
46function relTime(d: Date | string | null): string {
47 if (!d) return "—";
48 const t = typeof d === "string" ? new Date(d) : d;
49 const diffMs = Date.now() - t.getTime();
50 const secs = Math.floor(diffMs / 1000);
51 if (secs < 10) return "just now";
52 if (secs < 60) return `${secs}s ago`;
53 const mins = Math.floor(secs / 60);
54 if (mins < 60) return `${mins}m ago`;
55 const hrs = Math.floor(mins / 60);
56 if (hrs < 24) return `${hrs}h ago`;
57 return t.toLocaleDateString();
58}
59
60function formatLatency(ms: number): string {
61 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
62 const totalSecs = Math.round(ms / 1000);
63 const m = Math.floor(totalSecs / 60);
64 const s = totalSecs % 60;
65 return s > 0 ? `${m}m ${s}s` : `${m}m`;
66}
67
68function avatarInitials(name: string): string {
69 const parts = name.trim().split(/\s+/);
70 if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
71 return name.slice(0, 2).toUpperCase();
72}
73
74type GateStatus = "running" | "passed" | "failed" | "repaired" | "pending" | "skipped";
75
76function gateStatusClass(status: string): string {
77 const map: Record<string, string> = {
78 passed: "pw-pill-green",
79 repaired: "pw-pill-teal",
80 failed: "pw-pill-red",
81 running: "pw-pill-yellow",
82 pending: "pw-pill-gray",
83 skipped: "pw-pill-gray",
84 };
85 return map[status] ?? "pw-pill-gray";
86}
87
88function deployStatusClass(status: string): string {
89 const map: Record<string, string> = {
90 success: "pw-pill-green",
91 pending: "pw-pill-yellow",
92 running: "pw-pill-yellow",
93 failure: "pw-pill-red",
94 failed: "pw-pill-red",
95 blocked: "pw-pill-gray",
96 waiting_timer: "pw-pill-gray",
97 };
98 return map[status] ?? "pw-pill-gray";
99}
100
101/** True when the gate/deploy set still has non-terminal items. */
102function isInProgress(
103 gates: { status: string }[],
104 deploy: { status: string } | null
105): boolean {
106 const inFlight = new Set(["running", "pending"]);
107 if (gates.some((g) => inFlight.has(g.status))) return true;
108 if (deploy && inFlight.has(deploy.status)) return true;
109 return false;
110}
111
112function overallBanner(
113 gates: { status: string }[],
114 deploy: { status: string } | null,
115 latencyMs: number | null
116): { icon: string; label: string; mod: string } {
117 const hasFailed = gates.some((g) => g.status === "failed");
118 if (hasFailed) return { icon: "✗", label: "Gate failed", mod: "pw-banner-fail" };
119
120 const inProgress =
121 gates.some((g) => ["running", "pending"].includes(g.status)) ||
122 (deploy !== null && ["running", "pending"].includes(deploy.status));
123 if (inProgress) return { icon: "↻", label: "In progress…", mod: "pw-banner-progress" };
124
125 if (deploy?.status === "failure" || deploy?.status === "failed")
126 return { icon: "✗", label: "Deploy failed", mod: "pw-banner-fail" };
127
128 if (deploy?.status === "success" && latencyMs !== null)
129 return { icon: "✓", label: `Live in ${formatLatency(latencyMs)}`, mod: "pw-banner-live" };
130
131 if (gates.length > 0 && gates.every((g) => ["passed", "repaired", "skipped"].includes(g.status)))
132 return { icon: "✓", label: "All gates passed", mod: "pw-banner-live" };
133
134 return { icon: "◌", label: "No data yet", mod: "pw-banner-empty" };
135}
136
137// ---------------------------------------------------------------------------
138// Data loader shared by both the page and the JSON endpoint
139// ---------------------------------------------------------------------------
140
141async function loadPushData(repoId: string, sha: string) {
142 const [pushEvent] = await db
143 .select()
144 .from(activityFeed)
145 .where(
146 and(
147 eq(activityFeed.repositoryId, repoId),
148 or(
149 // standard push action stored with targetId = sha
150 and(eq(activityFeed.action, "push"), eq(activityFeed.targetId, sha)),
151 // SSH push variant
152 and(eq(activityFeed.action, "git.push.ssh"), eq(activityFeed.targetId, sha))
153 )
154 )
155 )
156 .orderBy(desc(activityFeed.createdAt))
157 .limit(1);
158
159 // Attempt to also find via metadata JSON if no targetId match
160 let pushActivity = pushEvent ?? null;
161 if (!pushActivity) {
162 // Fallback: scan recent push events and check metadata for the sha
163 const candidates = await db
164 .select()
165 .from(activityFeed)
166 .where(
167 and(
168 eq(activityFeed.repositoryId, repoId),
169 or(eq(activityFeed.action, "push"), eq(activityFeed.action, "git.push.ssh"))
170 )
171 )
172 .orderBy(desc(activityFeed.createdAt))
173 .limit(50);
174
175 for (const row of candidates) {
176 if (!row.metadata) continue;
177 try {
178 const m = JSON.parse(row.metadata) as Record<string, unknown>;
179 if (
180 m.sha === sha ||
181 m.headSha === sha ||
182 m.after === sha ||
183 m.commitSha === sha
184 ) {
185 pushActivity = row;
186 break;
187 }
188 } catch {
189 // skip
190 }
191 }
192 }
193
194 const gates = await db
195 .select()
196 .from(gateRuns)
197 .where(and(eq(gateRuns.repositoryId, repoId), eq(gateRuns.commitSha, sha)))
198 .orderBy(desc(gateRuns.createdAt));
199
200 const [deploy] = await db
201 .select()
202 .from(deployments)
203 .where(
204 and(eq(deployments.repositoryId, repoId), eq(deployments.commitSha, sha))
205 )
206 .orderBy(desc(deployments.createdAt))
207 .limit(1);
208
209 const deployment = deploy ?? null;
210
211 let latencyMs: number | null = null;
212 if (pushActivity && deployment?.completedAt && deployment.status === "success") {
213 latencyMs =
214 new Date(deployment.completedAt).getTime() -
215 new Date(pushActivity.createdAt).getTime();
216 if (latencyMs < 0) latencyMs = null;
217 }
218
219 return { pushActivity, gates, deployment, latencyMs };
220}
221
222// ---------------------------------------------------------------------------
223// CSS
224// ---------------------------------------------------------------------------
225
226const pwStyles = `
227 /* ── wrapper ── */
eed4684Claude228 .pw-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
05ab9b1Claude229
230 /* ── banner ── */
231 .pw-banner {
232 position: relative;
233 display: flex;
234 align-items: center;
235 gap: 18px;
236 padding: 28px 32px;
237 border-radius: 16px;
238 border: 1px solid var(--border);
239 background: var(--bg-elevated);
240 margin-bottom: var(--space-5);
241 overflow: hidden;
242 }
243 .pw-banner::before {
244 content: '';
245 position: absolute;
246 top: 0; left: 0; right: 0;
247 height: 2px;
248 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
249 opacity: 0.7;
250 pointer-events: none;
251 }
252 .pw-banner-orb {
253 position: absolute;
254 inset: -40% -10% auto auto;
255 width: 320px; height: 320px;
256 background: radial-gradient(circle, rgba(140,109,255,0.15), rgba(54,197,214,0.07) 50%, transparent 70%);
257 filter: blur(70px);
258 opacity: 0.6;
259 pointer-events: none;
260 }
261 .pw-banner-live { border-color: rgba(52,211,153,0.34); background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, var(--bg-elevated) 55%); }
262 .pw-banner-fail { border-color: rgba(248,113,113,0.34); background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, var(--bg-elevated) 55%); }
263 .pw-banner-progress { border-color: rgba(251,191,36,0.32); background: linear-gradient(135deg, rgba(251,191,36,0.07) 0%, var(--bg-elevated) 55%); }
264 .pw-banner-empty { border-color: var(--border); }
265
266 .pw-banner-icon {
267 flex-shrink: 0;
268 width: 52px; height: 52px;
269 border-radius: 14px;
270 display: flex; align-items: center; justify-content: center;
271 font-size: 22px;
272 font-weight: 700;
273 color: #fff;
274 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
275 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.15);
276 position: relative; z-index: 1;
277 }
278 .pw-banner-live .pw-banner-icon { background: linear-gradient(135deg, #34d399 0%, #10b981 100%); box-shadow: 0 8px 20px -8px rgba(16,185,129,0.5), inset 0 1px 0 rgba(255,255,255,0.18); }
279 .pw-banner-fail .pw-banner-icon { background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); box-shadow: 0 8px 20px -8px rgba(239,68,68,0.5), inset 0 1px 0 rgba(255,255,255,0.15); }
280 .pw-banner-progress .pw-banner-icon { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); color: #1a1206; box-shadow: 0 8px 20px -8px rgba(251,191,36,0.5), inset 0 1px 0 rgba(255,255,255,0.18); }
281
282 .pw-banner-text { position: relative; z-index: 1; }
283 .pw-banner-headline {
284 font-family: var(--font-display);
285 font-size: clamp(20px, 2.8vw, 26px);
286 font-weight: 800;
287 letter-spacing: -0.022em;
288 line-height: 1.1;
289 color: var(--text-strong);
290 margin: 0 0 4px;
291 }
292 .pw-banner-sub {
293 font-size: 13px;
294 color: var(--text-muted);
295 margin: 0;
296 }
297 .pw-banner-sub a { color: var(--accent); text-decoration: none; }
298 .pw-banner-sub a:hover { text-decoration: underline; }
299
300 /* ── section cards ── */
301 .pw-card {
302 background: var(--bg-elevated);
303 border: 1px solid var(--border);
304 border-radius: 12px;
305 margin-bottom: var(--space-4);
306 overflow: hidden;
307 }
308 .pw-card-head {
309 display: flex;
310 align-items: center;
311 gap: 10px;
312 padding: 14px 20px;
313 border-bottom: 1px solid var(--border);
314 font-size: 13px;
315 font-weight: 600;
316 color: var(--text-strong);
317 text-transform: uppercase;
318 letter-spacing: 0.05em;
319 }
320 .pw-card-head-icon {
321 width: 22px; height: 22px;
322 border-radius: 6px;
323 background: rgba(140,109,255,0.14);
324 color: #b69dff;
325 display: flex; align-items: center; justify-content: center;
326 font-size: 12px;
327 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.3);
328 }
329 .pw-card-body { padding: 20px; }
330
331 /* ── commit card ── */
332 .pw-commit {
333 display: flex;
334 align-items: flex-start;
335 gap: 14px;
336 }
337 .pw-avatar {
338 flex-shrink: 0;
339 width: 40px; height: 40px;
340 border-radius: 50%;
341 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
342 display: flex; align-items: center; justify-content: center;
343 font-size: 14px;
344 font-weight: 700;
345 color: #fff;
346 letter-spacing: -0.02em;
347 }
348 .pw-commit-info { flex: 1; min-width: 0; }
349 .pw-commit-msg {
350 font-size: 15px;
351 font-weight: 600;
352 color: var(--text-strong);
353 margin: 0 0 8px;
354 white-space: nowrap;
355 overflow: hidden;
356 text-overflow: ellipsis;
357 }
358 .pw-commit-meta {
359 display: flex;
360 flex-wrap: wrap;
361 gap: 8px;
362 align-items: center;
363 font-size: 12.5px;
364 color: var(--text-muted);
365 }
366 .pw-sha {
367 font-family: var(--font-mono, monospace);
368 font-size: 12px;
369 background: rgba(140,109,255,0.10);
370 color: #b69dff;
371 border-radius: 4px;
372 padding: 2px 7px;
373 border: 1px solid rgba(140,109,255,0.22);
374 }
375 .pw-branch-badge {
376 font-size: 12px;
377 background: rgba(54,197,214,0.10);
378 color: #36c5d6;
379 border-radius: 4px;
380 padding: 2px 7px;
381 border: 1px solid rgba(54,197,214,0.22);
382 }
383
384 /* ── gate table ── */
385 .pw-gate-table {
386 width: 100%;
387 border-collapse: collapse;
388 font-size: 13.5px;
389 }
390 .pw-gate-table th {
391 text-align: left;
392 color: var(--text-muted);
393 font-size: 11px;
394 font-weight: 600;
395 text-transform: uppercase;
396 letter-spacing: 0.05em;
397 padding: 0 0 10px;
398 border-bottom: 1px solid var(--border);
399 }
400 .pw-gate-table td {
401 padding: 10px 0;
402 border-bottom: 1px solid rgba(255,255,255,0.05);
403 color: var(--text);
404 vertical-align: middle;
405 }
406 .pw-gate-table tr:last-child td { border-bottom: none; }
407 .pw-gate-name { font-weight: 500; color: var(--text-strong); }
408 .pw-gate-dur { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono, monospace); }
409
410 /* ── status pills ── */
411 .pw-pill {
412 display: inline-flex;
413 align-items: center;
414 gap: 5px;
415 font-size: 11.5px;
416 font-weight: 600;
417 border-radius: 9999px;
418 padding: 3px 9px;
419 text-transform: capitalize;
420 letter-spacing: 0.02em;
421 }
422 .pw-pill::before {
423 content: '';
424 width: 6px; height: 6px;
425 border-radius: 50%;
426 background: currentColor;
427 flex-shrink: 0;
428 }
429 .pw-pill-green { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.25); }
430 .pw-pill-teal { background: rgba(54,197,214,0.12); color: #36c5d6; border: 1px solid rgba(54,197,214,0.25); }
431 .pw-pill-red { background: rgba(248,113,113,0.12); color: #f87171; border: 1px solid rgba(248,113,113,0.25); }
432 .pw-pill-yellow { background: rgba(251,191,36,0.12); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
433 .pw-pill-gray { background: rgba(139,148,158,0.12); color: #8b949e; border: 1px solid rgba(139,148,158,0.22); }
434 .pw-pill-spin::after {
435 content: '';
436 display: inline-block;
437 width: 8px; height: 8px;
438 border: 1.5px solid #fbbf24;
439 border-top-color: transparent;
440 border-radius: 50%;
441 animation: pw-spin 0.8s linear infinite;
442 margin-left: 2px;
443 }
444 @keyframes pw-spin { to { transform: rotate(360deg); } }
445 @media (prefers-reduced-motion: reduce) { .pw-pill-spin::after { animation: none; } }
446
447 /* ── deploy card row ── */
448 .pw-deploy-row {
449 display: flex;
450 flex-wrap: wrap;
451 align-items: center;
452 gap: 12px 20px;
453 }
454 .pw-deploy-env {
455 font-size: 14px;
456 font-weight: 600;
457 color: var(--text-strong);
458 flex: 1;
459 min-width: 120px;
460 }
461 .pw-deploy-meta {
462 font-size: 12.5px;
463 color: var(--text-muted);
464 }
465
466 /* ── empty state ── */
467 .pw-empty {
468 padding: 40px 20px;
469 text-align: center;
470 color: var(--text-muted);
471 font-size: 14px;
472 }
473 .pw-empty-title {
474 font-size: 16px;
475 font-weight: 600;
476 color: var(--text-strong);
477 margin: 0 0 8px;
478 }
479
480 /* ── poller status ── */
481 .pw-poll-bar {
482 display: none;
483 align-items: center;
484 gap: 8px;
485 font-size: 12px;
486 color: var(--text-muted);
487 margin-bottom: var(--space-3);
488 }
489 .pw-poll-bar.pw-poll-visible { display: flex; }
490 .pw-poll-dot {
491 width: 6px; height: 6px;
492 border-radius: 50%;
493 background: #fbbf24;
494 animation: pw-pulse 1.6s ease-in-out infinite;
495 }
496 @keyframes pw-pulse { 0%,100%{opacity:1} 50%{opacity:0.35} }
497 @media (prefers-reduced-motion: reduce) { .pw-poll-dot { animation: none; } }
498`;
499
500// ---------------------------------------------------------------------------
501// JSON API endpoint
502// ---------------------------------------------------------------------------
503
504pushWatchRoutes.get(
505 "/api/repos/:owner/:repo/push-status/:sha",
506 requireRepoAccess("read"),
507 async (c) => {
508 const sha = c.req.param("sha");
509 const repo = c.get("repository") as { id: string };
510
511 try {
512 const { gates, deployment, latencyMs } = await loadPushData(repo.id, sha);
513 return c.json({
514 sha,
515 gates: gates.map((g) => ({
516 id: g.id,
517 gateName: g.gateName,
518 status: g.status,
519 durationMs: g.durationMs ?? null,
520 summary: g.summary ?? null,
521 createdAt: g.createdAt,
522 completedAt: g.completedAt ?? null,
523 })),
524 deployment: deployment
525 ? {
526 id: deployment.id,
527 environment: deployment.environment,
528 status: deployment.status,
529 target: deployment.target ?? null,
530 createdAt: deployment.createdAt,
531 completedAt: deployment.completedAt ?? null,
532 }
533 : null,
534 latencyMs,
535 });
536 } catch (err) {
537 console.error("[push-watch] JSON endpoint error:", err);
538 return c.json({ error: "Internal error" }, 500);
539 }
540 }
541);
542
543// ---------------------------------------------------------------------------
544// Page route
545// ---------------------------------------------------------------------------
546
547pushWatchRoutes.get(
548 "/:owner/:repo/push/:sha",
549 requireRepoAccess("read"),
550 async (c) => {
551 const owner = c.req.param("owner");
552 const repoName = c.req.param("repo");
553 const sha = c.req.param("sha");
554 const user = c.get("user");
555 const repo = c.get("repository") as {
556 id: string;
557 name: string;
558 ownerId: string;
559 starCount: number;
560 forkCount: number;
561 isPrivate: boolean;
562 };
563
564 const unread = user ? await getUnreadCount(user.id) : 0;
565
566 // Load all push data
567 const { pushActivity, gates, deployment, latencyMs } = await loadPushData(
568 repo.id,
569 sha
570 );
571
572 // Parse commit message + branch from activity metadata
573 let commitMessage = "";
574 let branch = "";
575 let pusherName = "";
576
577 if (pushActivity) {
578 if (pushActivity.metadata) {
579 try {
580 const m = JSON.parse(pushActivity.metadata) as Record<string, unknown>;
581 commitMessage =
582 (m.commitMessage as string) ||
583 (m.message as string) ||
584 (m.subject as string) ||
585 "";
586 branch =
587 (m.branch as string) ||
588 (m.ref as string
589 ? String(m.ref).replace("refs/heads/", "")
590 : "") ||
591 "";
592 } catch {
593 // ignore
594 }
595 }
596
597 // Resolve pusher name from userId
598 if (pushActivity.userId) {
599 try {
600 const [pusherRow] = await db
601 .select({ username: users.username, displayName: users.displayName })
602 .from(users)
603 .where(eq(users.id, pushActivity.userId))
604 .limit(1);
605 if (pusherRow) {
606 pusherName = pusherRow.displayName || pusherRow.username;
607 }
608 } catch {
609 // ignore
610 }
611 }
612 }
613
614 const banner = overallBanner(gates, deployment, latencyMs);
615 const polling = isInProgress(gates, deployment);
616
617 // Build branch from gate ref if not found in activity
618 if (!branch && gates.length > 0 && gates[0].ref) {
619 branch = gates[0].ref.replace("refs/heads/", "");
620 }
621
622 const title = `Push ${shortSha(sha)} — ${owner}/${repoName}`;
623
624 return c.html(
625 <Layout
626 title={title}
627 user={user}
628 notificationCount={unread}
629 >
630 <style dangerouslySetInnerHTML={{ __html: pwStyles }} />
631
632 <div class="pw-wrap">
633 <RepoHeader
634 owner={owner}
635 repo={repoName}
636 starCount={repo.starCount}
637 forkCount={repo.forkCount}
638 currentUser={user?.username ?? null}
639 />
640 <RepoNav owner={owner} repo={repoName} active="commits" />
641
642 {/* ── Live-update poller bar ── */}
643 <div
644 id="pw-poll-bar"
645 class={`pw-poll-bar${polling ? " pw-poll-visible" : ""}`}
646 >
647 <span class="pw-poll-dot" />
648 <span id="pw-poll-msg">Watching for updates…</span>
649 </div>
650
651 {/* ── Hero banner ── */}
652 <div class={`pw-banner ${banner.mod}`} id="pw-banner">
653 <div class="pw-banner-orb" />
654 <div class="pw-banner-icon">{banner.icon}</div>
655 <div class="pw-banner-text">
656 <p class="pw-banner-headline" id="pw-banner-headline">
657 {banner.label}
658 </p>
659 <p class="pw-banner-sub">
660 Commit{" "}
661 <a
662 href={`/${owner}/${repoName}/commit/${sha}`}
663 class="pw-sha"
664 >
665 {shortSha(sha)}
666 </a>
667 {branch && (
668 <>
669 {" "}on <span class="pw-branch-badge">{branch}</span>
670 </>
671 )}
672 {pushActivity && (
673 <> · pushed {relTime(pushActivity.createdAt)}</>
674 )}
675 </p>
676 </div>
677 </div>
678
679 {/* ── Commit card ── */}
680 <div class="pw-card">
681 <div class="pw-card-head">
682 <span class="pw-card-head-icon">⎇</span>
683 Commit
684 </div>
685 <div class="pw-card-body">
686 {pushActivity ? (
687 <div class="pw-commit">
688 <div class="pw-avatar">
689 {pusherName ? avatarInitials(pusherName) : shortSha(sha).slice(0, 2).toUpperCase()}
690 </div>
691 <div class="pw-commit-info">
692 <p class="pw-commit-msg">
693 {commitMessage || "(no commit message)"}
694 </p>
695 <div class="pw-commit-meta">
696 <span>
697 {pusherName ? (
698 <a href={`/${pusherName.toLowerCase().replace(/\s+/g, "-")}`}>
699 {pusherName}
700 </a>
701 ) : (
702 "Unknown"
703 )}
704 </span>
705 <span class="pw-sha">{shortSha(sha)}</span>
706 {branch && <span class="pw-branch-badge">{branch}</span>}
707 <span>{relTime(pushActivity.createdAt)}</span>
708 </div>
709 </div>
710 </div>
711 ) : (
712 <div class="pw-empty">
713 <p class="pw-empty-title">No push event found</p>
714 <p>No activity was recorded for commit {shortSha(sha)}.</p>
715 </div>
716 )}
717 </div>
718 </div>
719
720 {/* ── Gate results card ── */}
721 <div class="pw-card" id="pw-gates-card">
722 <div class="pw-card-head">
723 <span class="pw-card-head-icon">✓</span>
724 Gate results
725 </div>
726 <div class="pw-card-body" id="pw-gates-body">
727 {gates.length > 0 ? (
728 <table class="pw-gate-table">
729 <thead>
730 <tr>
731 <th>Gate</th>
732 <th>Status</th>
733 <th>Duration</th>
734 </tr>
735 </thead>
736 <tbody>
737 {gates.map((g) => (
738 <tr key={g.id}>
739 <td class="pw-gate-name">{g.gateName}</td>
740 <td>
741 <span
742 class={`pw-pill ${gateStatusClass(g.status)}${
743 ["running", "pending"].includes(g.status)
744 ? " pw-pill-spin"
745 : ""
746 }`}
747 >
748 {g.status}
749 </span>
750 </td>
751 <td class="pw-gate-dur">
752 {g.durationMs != null
753 ? `${(g.durationMs / 1000).toFixed(1)}s`
754 : "—"}
755 </td>
756 </tr>
757 ))}
758 </tbody>
759 </table>
760 ) : (
761 <div class="pw-empty">
762 <p class="pw-empty-title">No gate runs yet</p>
763 <p>Gates have not been triggered for this commit.</p>
764 </div>
765 )}
766 </div>
767 </div>
768
769 {/* ── Deployment card ── */}
770 <div class="pw-card" id="pw-deploy-card">
771 <div class="pw-card-head">
772 <span class="pw-card-head-icon">⬆</span>
773 Deployment
774 </div>
775 <div class="pw-card-body" id="pw-deploy-body">
776 {deployment ? (
777 <div class="pw-deploy-row">
778 <span class="pw-deploy-env">{deployment.environment}</span>
779 <span
780 class={`pw-pill ${deployStatusClass(deployment.status)}${
781 ["running", "pending"].includes(deployment.status)
782 ? " pw-pill-spin"
783 : ""
784 }`}
785 >
786 {deployment.status}
787 </span>
788 {deployment.target && (
789 <span class="pw-deploy-meta">→ {deployment.target}</span>
790 )}
791 <span class="pw-deploy-meta">
792 {deployment.completedAt
793 ? relTime(deployment.completedAt)
794 : relTime(deployment.createdAt)}
795 </span>
796 {latencyMs !== null && (
797 <span class="pw-pill pw-pill-green" style="margin-left: auto;">
798 Live in {formatLatency(latencyMs)}
799 </span>
800 )}
801 </div>
802 ) : (
803 <div class="pw-empty">
804 <p class="pw-empty-title">No deployment yet</p>
805 <p>
806 A deployment record will appear once a deploy is triggered
807 for this commit.
808 </p>
809 </div>
810 )}
811 </div>
812 </div>
813 </div>
814
815 {/* ── 5-second poller script ── */}
816 <script
817 dangerouslySetInnerHTML={{
818 __html: `
819(function() {
820 var POLL_INTERVAL = 5000;
821 var owner = ${JSON.stringify(owner)};
822 var repo = ${JSON.stringify(repoName)};
823 var sha = ${JSON.stringify(sha)};
824 var url = '/api/repos/' + owner + '/' + repo + '/push-status/' + sha;
825
826 var isTerminal = ${JSON.stringify(!polling)};
827 if (isTerminal) return;
828
829 var pollBar = document.getElementById('pw-poll-bar');
830 var pollMsg = document.getElementById('pw-poll-msg');
831 var banner = document.getElementById('pw-banner');
832 var headline = document.getElementById('pw-banner-headline');
833
834 function shortSha(s) { return s.slice(0, 7); }
835
836 function gateStatusClass(s) {
837 var m = { passed:'pw-pill-green', repaired:'pw-pill-teal', failed:'pw-pill-red',
838 running:'pw-pill-yellow', pending:'pw-pill-yellow', skipped:'pw-pill-gray' };
839 return m[s] || 'pw-pill-gray';
840 }
841 function deployStatusClass(s) {
842 var m = { success:'pw-pill-green', pending:'pw-pill-yellow', running:'pw-pill-yellow',
843 failure:'pw-pill-red', failed:'pw-pill-red', blocked:'pw-pill-gray', waiting_timer:'pw-pill-gray' };
844 return m[s] || 'pw-pill-gray';
845 }
846 function formatLatency(ms) {
847 if (ms < 60000) return Math.round(ms/1000) + 's';
848 var s = Math.round(ms/1000); var m = Math.floor(s/60); var r = s%60;
849 return r > 0 ? m + 'm ' + r + 's' : m + 'm';
850 }
851 function inProgress(gates, deploy) {
852 var inf = ['running','pending'];
853 if (gates.some(function(g){ return inf.indexOf(g.status)>=0; })) return true;
854 if (deploy && inf.indexOf(deploy.status)>=0) return true;
855 return false;
856 }
857 function overallBanner(gates, deploy, latencyMs) {
858 if (gates.some(function(g){ return g.status==='failed'; }))
859 return { icon:'✗', label:'Gate failed', mod:'pw-banner-fail' };
860 var inf = ['running','pending'];
861 if (gates.some(function(g){ return inf.indexOf(g.status)>=0; }) ||
862 (deploy && inf.indexOf(deploy.status)>=0))
863 return { icon:'↻', label:'In progress…', mod:'pw-banner-progress' };
864 if (deploy && (deploy.status==='failure'||deploy.status==='failed'))
865 return { icon:'✗', label:'Deploy failed', mod:'pw-banner-fail' };
866 if (deploy && deploy.status==='success' && latencyMs!=null)
867 return { icon:'✓', label:'Live in '+formatLatency(latencyMs), mod:'pw-banner-live' };
868 var terminalGate = ['passed','repaired','skipped'];
869 if (gates.length>0 && gates.every(function(g){ return terminalGate.indexOf(g.status)>=0; }))
870 return { icon:'✓', label:'All gates passed', mod:'pw-banner-live' };
871 return { icon:'◌', label:'No data yet', mod:'pw-banner-empty' };
872 }
873
874 function renderGates(gates) {
875 var body = document.getElementById('pw-gates-body');
876 if (!body) return;
877 if (!gates.length) {
878 body.innerHTML = '<div class="pw-empty"><p class="pw-empty-title">No gate runs yet</p><p>Gates have not been triggered for this commit.</p></div>';
879 return;
880 }
881 var rows = gates.map(function(g) {
882 var spin = (g.status==='running'||g.status==='pending') ? ' pw-pill-spin' : '';
883 var dur = g.durationMs != null ? (g.durationMs/1000).toFixed(1)+'s' : '—';
884 return '<tr><td class="pw-gate-name">'+g.gateName+'</td><td><span class="pw-pill '+gateStatusClass(g.status)+spin+'">'+g.status+'</span></td><td class="pw-gate-dur">'+dur+'</td></tr>';
885 }).join('');
886 body.innerHTML = '<table class="pw-gate-table"><thead><tr><th>Gate</th><th>Status</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table>';
887 }
888
889 function renderDeploy(deploy, latencyMs) {
890 var body = document.getElementById('pw-deploy-body');
891 if (!body) return;
892 if (!deploy) {
893 body.innerHTML = '<div class="pw-empty"><p class="pw-empty-title">No deployment yet</p><p>A deployment record will appear once a deploy is triggered for this commit.</p></div>';
894 return;
895 }
896 var spin = (deploy.status==='running'||deploy.status==='pending') ? ' pw-pill-spin' : '';
897 var target = deploy.target ? '<span class="pw-deploy-meta">→ '+deploy.target+'</span>' : '';
898 var when = deploy.completedAt || deploy.createdAt;
899 var whenStr = when ? new Date(when).toLocaleTimeString() : '—';
900 var latStr = latencyMs!=null ? '<span class="pw-pill pw-pill-green" style="margin-left:auto;">Live in '+formatLatency(latencyMs)+'</span>' : '';
901 body.innerHTML = '<div class="pw-deploy-row"><span class="pw-deploy-env">'+deploy.environment+'</span><span class="pw-pill '+deployStatusClass(deploy.status)+spin+'">'+deploy.status+'</span>'+target+'<span class="pw-deploy-meta">'+whenStr+'</span>'+latStr+'</div>';
902 }
903
904 function updateBanner(gates, deploy, latencyMs) {
905 if (!banner || !headline) return;
906 var b = overallBanner(gates, deploy, latencyMs);
907 banner.className = 'pw-banner ' + b.mod;
908 var icon = banner.querySelector('.pw-banner-icon');
909 if (icon) icon.textContent = b.icon;
910 headline.textContent = b.label;
911 }
912
913 function poll() {
914 fetch(url)
915 .then(function(r){ return r.json(); })
916 .then(function(data) {
917 renderGates(data.gates || []);
918 renderDeploy(data.deployment, data.latencyMs);
919 updateBanner(data.gates || [], data.deployment, data.latencyMs);
920
921 if (!inProgress(data.gates || [], data.deployment)) {
922 isTerminal = true;
923 if (pollBar) pollBar.classList.remove('pw-poll-visible');
924 if (pollMsg) pollMsg.textContent = 'Up to date';
925 return;
926 }
927 if (pollMsg) pollMsg.textContent = 'Watching for updates…';
928 setTimeout(poll, POLL_INTERVAL);
929 })
930 .catch(function() {
931 // silent — try again
932 setTimeout(poll, POLL_INTERVAL * 2);
933 });
934 }
935
936 setTimeout(poll, POLL_INTERVAL);
937})();
938`,
939 }}
940 />
941 </Layout>
942 );
943 }
944);
945
946export default pushWatchRoutes;