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

deployments.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.

deployments.tsxBlame801 lines · 1 contributor
24cf2caClaude1/**
2 * Environments + deployment history UI.
3 *
4 * Routes:
5 * GET /:owner/:repo/deployments full deploy history per env
6 * GET /:owner/:repo/deployments/:id single deployment detail
7 *
8 * Data comes from the `deployments` table populated by Crontech / gate
9 * logic on successful push to the default branch.
304ce2aClaude10 *
11 * 2026 polish:
12 * - Page-level eyebrow + display headline + subtitle.
13 * - Each environment is its own polished card with a header strip
14 * (status pill + success rate), and a list of recent deploys as
15 * mini cards (mono SHA, status dot, tabular-nums relative time).
16 * - Empty state is a dashed card with an orb + helpful CTA copy.
17 * - All CSS scoped under `.dk-*` to avoid bleed.
18 *
19 * Hard rules preserved:
20 * - Every route, form action, POST handler, and DB query is unchanged.
21 * - Layout / ui.tsx / components.tsx are not modified.
24cf2caClaude22 */
23
24import { Hono } from "hono";
25import { desc, eq, and } from "drizzle-orm";
26import { db } from "../db";
27import { deployments, repositories, users } from "../db/schema";
28import type { AuthEnv } from "../middleware/auth";
1e162a8Claude29import { softAuth, requireAuth } from "../middleware/auth";
24cf2caClaude30import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
1e162a8Claude32import { onDeployFailure } from "../lib/ai-incident";
24cf2caClaude33
34const dep = new Hono<AuthEnv>();
35
36dep.use("/:owner/:repo/deployments", softAuth);
37dep.use("/:owner/:repo/deployments/*", softAuth);
38
39type Row = typeof deployments.$inferSelect & { triggeredByName: string | null };
40
304ce2aClaude41/* ─────────────────────────────────────────────────────────────────────────
42 * Scoped CSS — every class prefixed `.dk-` so this page can't bleed into
43 * other surfaces. Mirrors the section-card + traffic-light patterns from
44 * admin-integrations.tsx and admin-ops.tsx.
45 * ───────────────────────────────────────────────────────────────────── */
46const deployStyles = `
47 .dk-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
48
49 /* ─── Page heading (no hero block — RepoHeader supplies framing) ─── */
50 .dk-head { margin-bottom: var(--space-5); }
51 .dk-eyebrow {
52 font-size: 12px;
53 color: var(--text-muted);
54 margin-bottom: var(--space-2);
55 letter-spacing: 0.02em;
56 display: inline-flex;
57 align-items: center;
58 gap: 8px;
59 text-transform: uppercase;
60 }
61 .dk-eyebrow-pill {
62 display: inline-flex;
63 align-items: center;
64 justify-content: center;
65 width: 18px; height: 18px;
66 border-radius: 6px;
67 background: rgba(140,109,255,0.14);
68 color: #b69dff;
69 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
70 }
71 .dk-title {
72 font-size: clamp(24px, 3.2vw, 32px);
73 font-family: var(--font-display);
74 font-weight: 800;
75 letter-spacing: -0.024em;
76 line-height: 1.08;
77 margin: 0 0 var(--space-2);
78 color: var(--text-strong);
79 }
80 .dk-title-grad {
81 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
82 -webkit-background-clip: text;
83 background-clip: text;
84 -webkit-text-fill-color: transparent;
85 color: transparent;
86 }
87 .dk-sub {
88 font-size: 14.5px;
89 color: var(--text-muted);
90 margin: 0;
91 line-height: 1.55;
92 max-width: 620px;
93 }
94 .dk-sub code {
95 font-family: var(--font-mono);
96 font-size: 12.5px;
97 background: var(--bg-tertiary);
98 padding: 1px 5px;
99 border-radius: 4px;
100 }
101
102 /* ─── Env section cards ─── */
103 .dk-envs { display: flex; flex-direction: column; gap: var(--space-4); }
104 .dk-env {
105 background: var(--bg-elevated);
106 border: 1px solid var(--border);
107 border-radius: 14px;
108 overflow: hidden;
109 transition: border-color 140ms ease;
110 }
111 .dk-env:hover { border-color: var(--border-strong); }
112 .dk-env-head {
113 padding: var(--space-3) var(--space-5);
114 border-bottom: 1px solid var(--border);
115 display: flex;
116 align-items: center;
117 justify-content: space-between;
118 gap: var(--space-3);
119 flex-wrap: wrap;
120 background: rgba(255,255,255,0.012);
121 }
122 .dk-env-name {
123 margin: 0;
124 font-family: var(--font-display);
125 font-size: 15px;
126 font-weight: 700;
127 color: var(--text-strong);
128 letter-spacing: -0.012em;
129 display: flex;
130 align-items: center;
131 gap: 10px;
132 }
133 .dk-env-meta {
134 display: flex;
135 align-items: center;
136 gap: 10px;
137 font-size: 12px;
138 color: var(--text-muted);
139 flex-wrap: wrap;
140 }
141 .dk-env-rate {
142 font-variant-numeric: tabular-nums;
143 font-size: 12px;
144 padding: 2px 8px;
145 border-radius: 6px;
146 background: rgba(255,255,255,0.04);
147 border: 1px solid var(--border);
148 color: var(--text);
149 }
150 .dk-env-body { padding: var(--space-2) 0; }
151
152 /* ─── Run rows ─── */
153 .dk-run {
154 display: grid;
155 grid-template-columns: 80px 90px 1fr auto auto auto auto;
156 align-items: center;
157 gap: 12px;
158 padding: 10px var(--space-5);
159 border-top: 1px solid rgba(255,255,255,0.04);
160 font-size: 12.5px;
161 }
162 .dk-run:first-child { border-top: none; }
163 .dk-run:hover { background: rgba(255,255,255,0.02); }
164 .dk-run .dk-sha {
165 font-family: var(--font-mono);
166 font-size: 12px;
167 color: var(--text-strong);
168 background: rgba(255,255,255,0.04);
169 border: 1px solid var(--border);
170 padding: 2px 7px;
171 border-radius: 6px;
172 width: max-content;
173 }
174 .dk-run .dk-ref {
175 font-family: var(--font-mono);
176 font-size: 11.5px;
177 color: var(--text-muted);
178 overflow: hidden;
179 text-overflow: ellipsis;
180 white-space: nowrap;
181 }
182 .dk-run .dk-target {
183 font-family: var(--font-mono);
184 font-size: 11.5px;
185 color: var(--text-muted);
186 }
187 .dk-run .dk-by { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
188 .dk-run .dk-time {
189 font-size: 11.5px;
190 color: var(--text-muted);
191 font-variant-numeric: tabular-nums;
192 white-space: nowrap;
193 }
194 .dk-run .dk-link {
195 font-size: 11.5px;
196 color: var(--accent);
197 text-decoration: none;
198 font-weight: 600;
199 }
200 .dk-run .dk-link:hover { text-decoration: underline; }
201 .dk-run-more {
202 padding: 10px var(--space-5);
203 font-size: 12px;
204 color: var(--text-muted);
205 border-top: 1px solid rgba(255,255,255,0.04);
206 text-align: center;
207 }
208 @media (max-width: 760px) {
209 .dk-run {
210 grid-template-columns: 80px 1fr auto;
211 grid-template-areas:
212 "status sha link"
213 "ref ref ref"
214 "meta meta meta";
215 row-gap: 6px;
216 }
217 .dk-run .dk-status { grid-area: status; }
218 .dk-run .dk-sha { grid-area: sha; }
219 .dk-run .dk-link { grid-area: link; justify-self: end; }
220 .dk-run .dk-ref { grid-area: ref; }
221 .dk-run .dk-target,
222 .dk-run .dk-by,
223 .dk-run .dk-time { grid-area: meta; display: inline; margin-right: 10px; }
224 }
225
226 /* ─── Status pill (gate-status replacement) ─── */
227 .dk-status {
228 display: inline-flex;
229 align-items: center;
230 gap: 6px;
231 padding: 2px 9px;
232 border-radius: 9999px;
233 font-size: 10.5px;
234 font-weight: 700;
235 letter-spacing: 0.05em;
236 text-transform: uppercase;
237 width: max-content;
238 }
239 .dk-status .dot {
240 width: 6px; height: 6px;
241 border-radius: 9999px;
242 background: currentColor;
243 }
244 .dk-status.is-success {
245 background: rgba(52,211,153,0.14);
246 color: #6ee7b7;
247 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
248 }
249 .dk-status.is-failed {
250 background: rgba(248,113,113,0.14);
251 color: #fca5a5;
252 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
253 }
254 .dk-status.is-blocked {
255 background: rgba(251,191,36,0.12);
256 color: #fde68a;
257 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
258 }
259 .dk-status.is-running {
260 background: rgba(96,165,250,0.14);
261 color: #bfdbfe;
262 box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32);
263 }
264 .dk-status.is-other {
265 background: rgba(107,114,128,0.16);
266 color: #d1d5db;
267 box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32);
268 }
269
270 /* ─── Empty state ─── */
271 .dk-empty {
272 position: relative;
273 padding: var(--space-6) var(--space-5);
274 border: 1px dashed var(--border-strong);
275 border-radius: 16px;
276 background: rgba(255,255,255,0.02);
277 text-align: center;
278 overflow: hidden;
279 }
280 .dk-empty-orb {
281 position: absolute;
282 inset: -40% -10% auto auto;
283 width: 320px; height: 320px;
284 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
285 filter: blur(70px);
286 opacity: 0.6;
287 pointer-events: none;
288 z-index: 0;
289 }
290 .dk-empty-inner { position: relative; z-index: 1; }
291 .dk-empty-title {
292 font-family: var(--font-display);
293 font-size: 17px;
294 font-weight: 700;
295 color: var(--text-strong);
296 margin: 0 0 6px;
297 letter-spacing: -0.018em;
298 }
299 .dk-empty-sub {
300 font-size: 13.5px;
301 color: var(--text-muted);
302 margin: 0 auto;
303 max-width: 480px;
304 line-height: 1.5;
305 }
306
307 /* ─── Detail page ─── */
308 .dk-detail-wrap { max-width: 760px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
309 .dk-bread {
310 display: flex;
311 align-items: center;
312 gap: 6px;
313 font-size: 12.5px;
314 color: var(--text-muted);
315 margin-bottom: var(--space-3);
316 }
317 .dk-bread a { color: var(--accent); text-decoration: none; }
318 .dk-bread a:hover { text-decoration: underline; }
319 .dk-detail-title {
320 font-family: var(--font-display);
321 font-size: clamp(20px, 2.6vw, 26px);
322 font-weight: 800;
323 letter-spacing: -0.02em;
324 color: var(--text-strong);
325 margin: 0 0 var(--space-4);
326 display: flex;
327 align-items: center;
328 gap: 10px;
329 flex-wrap: wrap;
330 }
331 .dk-detail-sha {
332 font-family: var(--font-mono);
333 font-size: 16px;
334 background: rgba(255,255,255,0.04);
335 border: 1px solid var(--border);
336 padding: 3px 10px;
337 border-radius: 8px;
338 }
339 .dk-detail-arrow { color: var(--text-muted); font-weight: 500; }
340 .dk-detail-env { color: var(--text); }
341 .dk-detail-card {
342 background: var(--bg-elevated);
343 border: 1px solid var(--border);
344 border-radius: 14px;
345 overflow: hidden;
346 }
347 .dk-kv { width: 100%; border-collapse: collapse; font-size: 13px; }
348 .dk-kv th, .dk-kv td {
349 padding: 10px 16px;
350 text-align: left;
351 border-bottom: 1px solid var(--border);
352 vertical-align: top;
353 }
354 .dk-kv tr:last-child th, .dk-kv tr:last-child td { border-bottom: none; }
355 .dk-kv th {
356 width: 160px;
357 font-weight: 600;
358 color: var(--text-muted);
359 background: rgba(255,255,255,0.012);
360 font-family: var(--font-mono);
361 font-size: 12px;
362 letter-spacing: -0.005em;
363 }
364 .dk-kv td { color: var(--text); }
365 .dk-kv td code,
366 .dk-kv td a code {
367 font-family: var(--font-mono);
368 font-size: 12px;
369 background: rgba(255,255,255,0.04);
370 border: 1px solid var(--border);
371 padding: 2px 7px;
372 border-radius: 6px;
373 word-break: break-all;
374 }
375 .dk-kv td a { color: var(--accent); text-decoration: none; }
376 .dk-kv td a:hover { text-decoration: underline; }
377 .dk-kv .dk-blocked { color: #fca5a5; }
378 .dk-retry {
379 margin-top: var(--space-4);
380 display: flex;
381 justify-content: flex-end;
382 }
383 .dk-btn {
384 display: inline-flex;
385 align-items: center;
386 gap: 6px;
387 padding: 8px 16px;
388 font-size: 13px;
389 font-weight: 600;
390 border-radius: 8px;
391 cursor: pointer;
392 font-family: inherit;
393 border: 1px solid transparent;
394 transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
395 }
396 .dk-btn-ghost {
397 background: rgba(255,255,255,0.03);
398 border-color: var(--border);
399 color: var(--text);
400 }
401 .dk-btn-ghost:hover {
402 background: rgba(255,255,255,0.06);
403 border-color: var(--border-strong);
404 color: var(--text-strong);
405 }
406`;
407
24cf2caClaude408async function resolveRepo(owner: string, name: string) {
409 try {
410 const [row] = await db
411 .select({
412 id: repositories.id,
413 name: repositories.name,
1e162a8Claude414 ownerId: repositories.ownerId,
24cf2caClaude415 })
416 .from(repositories)
417 .innerJoin(users, eq(users.id, repositories.ownerId))
418 .where(and(eq(users.username, owner), eq(repositories.name, name)))
419 .limit(1);
420 return row || null;
421 } catch {
422 return null;
423 }
424}
425
1e162a8Claude426/** Parse "auto-issue #42" from a blockedReason string. Returns null if absent. */
427function parseAutoIssueNumber(blockedReason: string | null): number | null {
428 if (!blockedReason) return null;
429 const m = blockedReason.match(/auto-issue #(\d+)/);
430 return m ? parseInt(m[1], 10) : null;
431}
432
304ce2aClaude433function statusClass(status: string): string {
24cf2caClaude434 switch (status) {
435 case "success":
304ce2aClaude436 return "dk-status is-success";
24cf2caClaude437 case "failed":
304ce2aClaude438 return "dk-status is-failed";
24cf2caClaude439 case "blocked":
304ce2aClaude440 return "dk-status is-blocked";
24cf2caClaude441 case "running":
442 case "pending":
304ce2aClaude443 return "dk-status is-running";
24cf2caClaude444 default:
304ce2aClaude445 return "dk-status is-other";
24cf2caClaude446 }
447}
448
449function fmtTs(t: Date | null | undefined): string {
450 if (!t) return "—";
451 return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z";
452}
453
304ce2aClaude454/** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */
455function dkRelativeTime(from: Date | null, now: Date = new Date()): string {
456 if (!from) return "—";
457 const ms = now.getTime() - new Date(from).getTime();
458 if (ms < 5_000) return "just now";
459 const s = Math.floor(ms / 1_000);
460 if (s < 60) return `${s}s ago`;
461 const m = Math.floor(s / 60);
462 if (m < 60) return `${m}m ago`;
463 const h = Math.floor(m / 60);
464 if (h < 24) return `${h}h ago`;
465 const d = Math.floor(h / 24);
466 return `${d}d ago`;
467}
468
24cf2caClaude469function groupByEnv(rows: Row[]): Record<string, Row[]> {
470 const out: Record<string, Row[]> = {};
471 for (const r of rows) {
472 (out[r.environment] ||= []).push(r);
473 }
474 return out;
475}
476
477function envSummary(rows: Row[]): { last: Row | undefined; successRate: number } {
478 const last = rows[0];
479 const recent = rows.slice(0, 20);
480 const successes = recent.filter((r) => r.status === "success").length;
481 const rate = recent.length ? successes / recent.length : 1;
482 return { last, successRate: rate };
483}
484
485dep.get("/:owner/:repo/deployments", async (c) => {
486 const { owner, repo } = c.req.param();
487 const user = c.get("user");
488 const repoRow = await resolveRepo(owner, repo);
489 if (!repoRow) return c.notFound();
490
491 let rows: Row[] = [];
492 try {
493 rows = (await db
494 .select({
495 id: deployments.id,
496 repositoryId: deployments.repositoryId,
497 environment: deployments.environment,
498 commitSha: deployments.commitSha,
499 ref: deployments.ref,
500 status: deployments.status,
501 blockedReason: deployments.blockedReason,
502 target: deployments.target,
503 triggeredBy: deployments.triggeredBy,
504 createdAt: deployments.createdAt,
505 completedAt: deployments.completedAt,
506 triggeredByName: users.username,
507 })
508 .from(deployments)
509 .leftJoin(users, eq(users.id, deployments.triggeredBy))
510 .where(eq(deployments.repositoryId, repoRow.id))
511 .orderBy(desc(deployments.createdAt))
512 .limit(500)) as Row[];
513 } catch (err) {
514 console.error("[deployments] list:", err);
515 }
516
517 const envs = groupByEnv(rows);
518 const envNames = Object.keys(envs).sort();
519
520 return c.html(
521 <Layout title={`${owner}/${repo} — deployments`} user={user}>
522 <RepoHeader owner={owner} repo={repo} />
304ce2aClaude523 <div class="dk-wrap">
524 <header class="dk-head">
525 <div class="dk-eyebrow">
526 <span class="dk-eyebrow-pill" aria-hidden="true">
527 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
528 <path d="M3 12h4l3 8 4-16 3 8h4" />
529 </svg>
530 </span>
531 Deployments · {owner}/{repo}
24cf2caClaude532 </div>
304ce2aClaude533 <h2 class="dk-title">
534 <span class="dk-title-grad">Shipped</span>, env by env.
535 </h2>
536 <p class="dk-sub">
537 Every deploy to every environment, newest first. Rolled up by
538 environment with the latest status and success rate across the
539 last 20 runs.
540 </p>
541 </header>
24cf2caClaude542
304ce2aClaude543 {envNames.length === 0 ? (
544 <div class="dk-empty">
545 <div class="dk-empty-orb" aria-hidden="true" />
546 <div class="dk-empty-inner">
547 <p class="dk-empty-title">No deployments yet</p>
548 <p class="dk-empty-sub">
549 When a green push reaches the default branch and a deploy
550 target is configured, deploys land here — with status, SHA,
551 and the operator who shipped it.
552 </p>
24cf2caClaude553 </div>
304ce2aClaude554 </div>
555 ) : (
556 <div class="dk-envs">
557 {envNames.map((env) => {
558 const envRows = envs[env];
559 const { last, successRate } = envSummary(envRows);
560 const rate = Math.round(successRate * 100);
561 return (
562 <section class="dk-env" aria-label={`Environment ${env}`}>
563 <header class="dk-env-head">
564 <h3 class="dk-env-name">{env}</h3>
565 <div class="dk-env-meta">
566 {last && (
567 <span class={statusClass(last.status)}>
568 <span class="dot" aria-hidden="true" />
569 {last.status}
570 </span>
571 )}
572 <span class="dk-env-rate">
573 {rate}% green · {envRows.length} total
574 </span>
575 </div>
576 </header>
577 <div class="dk-env-body">
578 {envRows.slice(0, 10).map((r) => (
579 <div class="dk-run">
580 <span class={statusClass(r.status)}>
581 <span class="dot" aria-hidden="true" />
582 {r.status}
583 </span>
584 <code class="dk-sha">{r.commitSha.slice(0, 7)}</code>
585 <span class="dk-ref">
586 {r.ref.replace(/^refs\/heads\//, "")}
587 </span>
588 <span class="dk-target">{r.target || "—"}</span>
589 <span class="dk-by">
590 by {r.triggeredByName || "system"}
591 </span>
592 <span
593 class="dk-time"
594 title={fmtTs(r.createdAt)}
595 >
596 {dkRelativeTime(r.createdAt)}
597 </span>
598 <a
599 href={`/${owner}/${repo}/deployments/${r.id}`}
600 class="dk-link"
601 >
602 details
603 </a>
604 </div>
605 ))}
606 {envRows.length > 10 && (
607 <div class="dk-run-more">
608 + {envRows.length - 10} more{"…"}
609 </div>
610 )}
611 </div>
612 </section>
613 );
614 })}
615 </div>
616 )}
24cf2caClaude617 </div>
304ce2aClaude618 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
24cf2caClaude619 </Layout>
620 );
621});
622
623dep.get("/:owner/:repo/deployments/:id", async (c) => {
624 const { owner, repo, id } = c.req.param();
625 const user = c.get("user");
626 const repoRow = await resolveRepo(owner, repo);
627 if (!repoRow) return c.notFound();
628
629 let row: Row | null = null;
630 try {
631 const [r] = await db
632 .select({
633 id: deployments.id,
634 repositoryId: deployments.repositoryId,
635 environment: deployments.environment,
636 commitSha: deployments.commitSha,
637 ref: deployments.ref,
638 status: deployments.status,
639 blockedReason: deployments.blockedReason,
640 target: deployments.target,
641 triggeredBy: deployments.triggeredBy,
642 createdAt: deployments.createdAt,
643 completedAt: deployments.completedAt,
644 triggeredByName: users.username,
645 })
646 .from(deployments)
647 .leftJoin(users, eq(users.id, deployments.triggeredBy))
648 .where(
649 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
650 )
651 .limit(1);
652 row = (r as Row) || null;
653 } catch (err) {
654 console.error("[deployments] detail:", err);
655 }
656
657 if (!row) return c.notFound();
658
304ce2aClaude659 const autoIssue = parseAutoIssueNumber(row.blockedReason);
660
24cf2caClaude661 return c.html(
662 <Layout
663 title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`}
664 user={user}
665 >
666 <RepoHeader owner={owner} repo={repo} />
304ce2aClaude667 <div class="dk-detail-wrap">
668 <div class="dk-bread">
24cf2caClaude669 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
304ce2aClaude670 <span aria-hidden="true">/</span>
24cf2caClaude671 <span>{row.id.slice(0, 8)}</span>
672 </div>
304ce2aClaude673 <h2 class="dk-detail-title">
674 <span class={statusClass(row.status)}>
675 <span class="dot" aria-hidden="true" />
676 {row.status}
24cf2caClaude677 </span>
304ce2aClaude678 <span class="dk-detail-sha">{row.commitSha.slice(0, 7)}</span>
679 <span class="dk-detail-arrow" aria-hidden="true">&rarr;</span>
680 <span class="dk-detail-env">{row.environment}</span>
24cf2caClaude681 </h2>
304ce2aClaude682 <div class="dk-detail-card">
683 <table class="dk-kv">
684 <tbody>
685 <tr>
686 <th>Target</th>
687 <td>{row.target || "—"}</td>
688 </tr>
24cf2caClaude689 <tr>
304ce2aClaude690 <th>Ref</th>
691 <td>
692 <code>{row.ref}</code>
693 </td>
24cf2caClaude694 </tr>
304ce2aClaude695 <tr>
696 <th>Commit</th>
697 <td>
698 <a href={`/${owner}/${repo}/commit/${row.commitSha}`}>
699 <code>{row.commitSha}</code>
700 </a>
701 </td>
702 </tr>
703 <tr>
704 <th>Triggered by</th>
705 <td>{row.triggeredByName || "system"}</td>
706 </tr>
707 <tr>
708 <th>Created</th>
709 <td>
710 {fmtTs(row.createdAt)}{" "}
711 <span style="color:var(--text-muted);font-variant-numeric:tabular-nums">
712 ({dkRelativeTime(row.createdAt)})
713 </span>
714 </td>
715 </tr>
716 <tr>
717 <th>Completed</th>
718 <td>
719 {fmtTs(row.completedAt)}
720 {row.completedAt && (
721 <span style="color:var(--text-muted);font-variant-numeric:tabular-nums">
722 {" "}({dkRelativeTime(row.completedAt)})
723 </span>
724 )}
725 </td>
726 </tr>
727 {row.blockedReason && (
728 <tr>
729 <th>Blocked reason</th>
730 <td class="dk-blocked">{row.blockedReason}</td>
731 </tr>
732 )}
733 {autoIssue !== null && (
1e162a8Claude734 <tr>
735 <th>Incident issue</th>
736 <td>
304ce2aClaude737 <a href={`/${owner}/${repo}/issues/${autoIssue}`}>
738 #{autoIssue}
739 </a>
1e162a8Claude740 </td>
741 </tr>
304ce2aClaude742 )}
743 </tbody>
744 </table>
745 </div>
1e162a8Claude746 {row.status === "failed" && (
747 <form
748 method="post"
749 action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`}
304ce2aClaude750 class="dk-retry"
1e162a8Claude751 >
304ce2aClaude752 <button type="submit" class="dk-btn dk-btn-ghost">
1e162a8Claude753 Re-run incident analysis
754 </button>
755 </form>
756 )}
24cf2caClaude757 </div>
304ce2aClaude758 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
24cf2caClaude759 </Layout>
760 );
761});
762
1e162a8Claude763// D4: re-trigger the AI incident responder for a failed deployment. Owner-only.
764// Redirects back to the deployment detail page in all cases.
765dep.post(
766 "/:owner/:repo/deployments/:id/retry-incident",
767 requireAuth,
768 async (c) => {
769 const { owner, repo, id } = c.req.param();
770 const user = c.get("user")!;
771 const repoRow = await resolveRepo(owner, repo);
772 const back = `/${owner}/${repo}/deployments/${id}`;
773 if (!repoRow) return c.notFound();
774 if (repoRow.ownerId !== user.id) {
775 return c.redirect(back);
776 }
777 try {
778 const [depRow] = await db
779 .select()
780 .from(deployments)
781 .where(
782 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
783 )
784 .limit(1);
785 if (!depRow || depRow.status !== "failed") return c.redirect(back);
786 await onDeployFailure({
787 repositoryId: repoRow.id,
788 deploymentId: depRow.id,
789 ref: depRow.ref,
790 commitSha: depRow.commitSha,
791 target: depRow.target,
792 errorMessage: depRow.blockedReason,
793 });
794 } catch (err) {
795 console.error("[deployments] retry-incident:", err);
796 }
797 return c.redirect(back);
798 }
799);
800
24cf2caClaude801export default dep;