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.tsxBlame1395 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";
c9ed210Claude27import { deployments, repositories, users, cloudDeployConfigs, cloudDeployments } from "../db/schema";
24cf2caClaude28import 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";
c9ed210Claude33import { encryptValue } from "../lib/server-targets-crypto";
24cf2caClaude34
35const dep = new Hono<AuthEnv>();
36
37dep.use("/:owner/:repo/deployments", softAuth);
38dep.use("/:owner/:repo/deployments/*", softAuth);
39
40type Row = typeof deployments.$inferSelect & { triggeredByName: string | null };
41
304ce2aClaude42/* ─────────────────────────────────────────────────────────────────────────
43 * Scoped CSS — every class prefixed `.dk-` so this page can't bleed into
44 * other surfaces. Mirrors the section-card + traffic-light patterns from
45 * admin-integrations.tsx and admin-ops.tsx.
46 * ───────────────────────────────────────────────────────────────────── */
47const deployStyles = `
eed4684Claude48 .dk-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
304ce2aClaude49
50 /* ─── Page heading (no hero block — RepoHeader supplies framing) ─── */
51 .dk-head { margin-bottom: var(--space-5); }
52 .dk-eyebrow {
53 font-size: 12px;
54 color: var(--text-muted);
55 margin-bottom: var(--space-2);
56 letter-spacing: 0.02em;
57 display: inline-flex;
58 align-items: center;
59 gap: 8px;
60 text-transform: uppercase;
61 }
62 .dk-eyebrow-pill {
63 display: inline-flex;
64 align-items: center;
65 justify-content: center;
66 width: 18px; height: 18px;
67 border-radius: 6px;
68 background: rgba(140,109,255,0.14);
69 color: #b69dff;
70 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
71 }
72 .dk-title {
73 font-size: clamp(24px, 3.2vw, 32px);
74 font-family: var(--font-display);
75 font-weight: 800;
76 letter-spacing: -0.024em;
77 line-height: 1.08;
78 margin: 0 0 var(--space-2);
79 color: var(--text-strong);
80 }
81 .dk-title-grad {
82 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
83 -webkit-background-clip: text;
84 background-clip: text;
85 -webkit-text-fill-color: transparent;
86 color: transparent;
87 }
88 .dk-sub {
89 font-size: 14.5px;
90 color: var(--text-muted);
91 margin: 0;
92 line-height: 1.55;
93 max-width: 620px;
94 }
95 .dk-sub code {
96 font-family: var(--font-mono);
97 font-size: 12.5px;
98 background: var(--bg-tertiary);
99 padding: 1px 5px;
100 border-radius: 4px;
101 }
102
103 /* ─── Env section cards ─── */
104 .dk-envs { display: flex; flex-direction: column; gap: var(--space-4); }
105 .dk-env {
106 background: var(--bg-elevated);
107 border: 1px solid var(--border);
108 border-radius: 14px;
109 overflow: hidden;
110 transition: border-color 140ms ease;
111 }
112 .dk-env:hover { border-color: var(--border-strong); }
113 .dk-env-head {
114 padding: var(--space-3) var(--space-5);
115 border-bottom: 1px solid var(--border);
116 display: flex;
117 align-items: center;
118 justify-content: space-between;
119 gap: var(--space-3);
120 flex-wrap: wrap;
121 background: rgba(255,255,255,0.012);
122 }
123 .dk-env-name {
124 margin: 0;
125 font-family: var(--font-display);
126 font-size: 15px;
127 font-weight: 700;
128 color: var(--text-strong);
129 letter-spacing: -0.012em;
130 display: flex;
131 align-items: center;
132 gap: 10px;
133 }
134 .dk-env-meta {
135 display: flex;
136 align-items: center;
137 gap: 10px;
138 font-size: 12px;
139 color: var(--text-muted);
140 flex-wrap: wrap;
141 }
142 .dk-env-rate {
143 font-variant-numeric: tabular-nums;
144 font-size: 12px;
145 padding: 2px 8px;
146 border-radius: 6px;
147 background: rgba(255,255,255,0.04);
148 border: 1px solid var(--border);
149 color: var(--text);
150 }
151 .dk-env-body { padding: var(--space-2) 0; }
152
153 /* ─── Run rows ─── */
154 .dk-run {
155 display: grid;
156 grid-template-columns: 80px 90px 1fr auto auto auto auto;
157 align-items: center;
158 gap: 12px;
159 padding: 10px var(--space-5);
160 border-top: 1px solid rgba(255,255,255,0.04);
161 font-size: 12.5px;
162 }
163 .dk-run:first-child { border-top: none; }
164 .dk-run:hover { background: rgba(255,255,255,0.02); }
165 .dk-run .dk-sha {
166 font-family: var(--font-mono);
167 font-size: 12px;
168 color: var(--text-strong);
169 background: rgba(255,255,255,0.04);
170 border: 1px solid var(--border);
171 padding: 2px 7px;
172 border-radius: 6px;
173 width: max-content;
174 }
175 .dk-run .dk-ref {
176 font-family: var(--font-mono);
177 font-size: 11.5px;
178 color: var(--text-muted);
179 overflow: hidden;
180 text-overflow: ellipsis;
181 white-space: nowrap;
182 }
183 .dk-run .dk-target {
184 font-family: var(--font-mono);
185 font-size: 11.5px;
186 color: var(--text-muted);
187 }
188 .dk-run .dk-by { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
189 .dk-run .dk-time {
190 font-size: 11.5px;
191 color: var(--text-muted);
192 font-variant-numeric: tabular-nums;
193 white-space: nowrap;
194 }
195 .dk-run .dk-link {
196 font-size: 11.5px;
197 color: var(--accent);
198 text-decoration: none;
199 font-weight: 600;
200 }
201 .dk-run .dk-link:hover { text-decoration: underline; }
202 .dk-run-more {
203 padding: 10px var(--space-5);
204 font-size: 12px;
205 color: var(--text-muted);
206 border-top: 1px solid rgba(255,255,255,0.04);
207 text-align: center;
208 }
209 @media (max-width: 760px) {
210 .dk-run {
211 grid-template-columns: 80px 1fr auto;
212 grid-template-areas:
213 "status sha link"
214 "ref ref ref"
215 "meta meta meta";
216 row-gap: 6px;
217 }
218 .dk-run .dk-status { grid-area: status; }
219 .dk-run .dk-sha { grid-area: sha; }
220 .dk-run .dk-link { grid-area: link; justify-self: end; }
221 .dk-run .dk-ref { grid-area: ref; }
222 .dk-run .dk-target,
223 .dk-run .dk-by,
224 .dk-run .dk-time { grid-area: meta; display: inline; margin-right: 10px; }
225 }
226
227 /* ─── Status pill (gate-status replacement) ─── */
228 .dk-status {
229 display: inline-flex;
230 align-items: center;
231 gap: 6px;
232 padding: 2px 9px;
233 border-radius: 9999px;
234 font-size: 10.5px;
235 font-weight: 700;
236 letter-spacing: 0.05em;
237 text-transform: uppercase;
238 width: max-content;
239 }
240 .dk-status .dot {
241 width: 6px; height: 6px;
242 border-radius: 9999px;
243 background: currentColor;
244 }
245 .dk-status.is-success {
246 background: rgba(52,211,153,0.14);
247 color: #6ee7b7;
248 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
249 }
250 .dk-status.is-failed {
251 background: rgba(248,113,113,0.14);
252 color: #fca5a5;
253 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
254 }
255 .dk-status.is-blocked {
256 background: rgba(251,191,36,0.12);
257 color: #fde68a;
258 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
259 }
260 .dk-status.is-running {
261 background: rgba(96,165,250,0.14);
262 color: #bfdbfe;
263 box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32);
264 }
265 .dk-status.is-other {
266 background: rgba(107,114,128,0.16);
267 color: #d1d5db;
268 box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32);
269 }
270
271 /* ─── Empty state ─── */
272 .dk-empty {
273 position: relative;
274 padding: var(--space-6) var(--space-5);
275 border: 1px dashed var(--border-strong);
276 border-radius: 16px;
277 background: rgba(255,255,255,0.02);
278 text-align: center;
279 overflow: hidden;
280 }
281 .dk-empty-orb {
282 position: absolute;
283 inset: -40% -10% auto auto;
284 width: 320px; height: 320px;
285 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
286 filter: blur(70px);
287 opacity: 0.6;
288 pointer-events: none;
289 z-index: 0;
290 }
291 .dk-empty-inner { position: relative; z-index: 1; }
292 .dk-empty-title {
293 font-family: var(--font-display);
294 font-size: 17px;
295 font-weight: 700;
296 color: var(--text-strong);
297 margin: 0 0 6px;
298 letter-spacing: -0.018em;
299 }
300 .dk-empty-sub {
301 font-size: 13.5px;
302 color: var(--text-muted);
303 margin: 0 auto;
304 max-width: 480px;
305 line-height: 1.5;
306 }
307
308 /* ─── Detail page ─── */
eed4684Claude309 .dk-detail-wrap { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
304ce2aClaude310 .dk-bread {
311 display: flex;
312 align-items: center;
313 gap: 6px;
314 font-size: 12.5px;
315 color: var(--text-muted);
316 margin-bottom: var(--space-3);
317 }
318 .dk-bread a { color: var(--accent); text-decoration: none; }
319 .dk-bread a:hover { text-decoration: underline; }
320 .dk-detail-title {
321 font-family: var(--font-display);
322 font-size: clamp(20px, 2.6vw, 26px);
323 font-weight: 800;
324 letter-spacing: -0.02em;
325 color: var(--text-strong);
326 margin: 0 0 var(--space-4);
327 display: flex;
328 align-items: center;
329 gap: 10px;
330 flex-wrap: wrap;
331 }
332 .dk-detail-sha {
333 font-family: var(--font-mono);
334 font-size: 16px;
335 background: rgba(255,255,255,0.04);
336 border: 1px solid var(--border);
337 padding: 3px 10px;
338 border-radius: 8px;
339 }
340 .dk-detail-arrow { color: var(--text-muted); font-weight: 500; }
341 .dk-detail-env { color: var(--text); }
342 .dk-detail-card {
343 background: var(--bg-elevated);
344 border: 1px solid var(--border);
345 border-radius: 14px;
346 overflow: hidden;
347 }
348 .dk-kv { width: 100%; border-collapse: collapse; font-size: 13px; }
349 .dk-kv th, .dk-kv td {
350 padding: 10px 16px;
351 text-align: left;
352 border-bottom: 1px solid var(--border);
353 vertical-align: top;
354 }
355 .dk-kv tr:last-child th, .dk-kv tr:last-child td { border-bottom: none; }
356 .dk-kv th {
357 width: 160px;
358 font-weight: 600;
359 color: var(--text-muted);
360 background: rgba(255,255,255,0.012);
361 font-family: var(--font-mono);
362 font-size: 12px;
363 letter-spacing: -0.005em;
364 }
365 .dk-kv td { color: var(--text); }
366 .dk-kv td code,
367 .dk-kv td a code {
368 font-family: var(--font-mono);
369 font-size: 12px;
370 background: rgba(255,255,255,0.04);
371 border: 1px solid var(--border);
372 padding: 2px 7px;
373 border-radius: 6px;
374 word-break: break-all;
375 }
376 .dk-kv td a { color: var(--accent); text-decoration: none; }
377 .dk-kv td a:hover { text-decoration: underline; }
378 .dk-kv .dk-blocked { color: #fca5a5; }
379 .dk-retry {
380 margin-top: var(--space-4);
381 display: flex;
382 justify-content: flex-end;
383 }
384 .dk-btn {
385 display: inline-flex;
386 align-items: center;
387 gap: 6px;
388 padding: 8px 16px;
389 font-size: 13px;
390 font-weight: 600;
391 border-radius: 8px;
392 cursor: pointer;
393 font-family: inherit;
394 border: 1px solid transparent;
395 transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
396 }
397 .dk-btn-ghost {
398 background: rgba(255,255,255,0.03);
399 border-color: var(--border);
400 color: var(--text);
401 }
402 .dk-btn-ghost:hover {
403 background: rgba(255,255,255,0.06);
404 border-color: var(--border-strong);
405 color: var(--text-strong);
406 }
407`;
408
24cf2caClaude409async function resolveRepo(owner: string, name: string) {
410 try {
411 const [row] = await db
412 .select({
413 id: repositories.id,
414 name: repositories.name,
1e162a8Claude415 ownerId: repositories.ownerId,
24cf2caClaude416 })
417 .from(repositories)
418 .innerJoin(users, eq(users.id, repositories.ownerId))
419 .where(and(eq(users.username, owner), eq(repositories.name, name)))
420 .limit(1);
421 return row || null;
422 } catch {
423 return null;
424 }
425}
426
1e162a8Claude427/** Parse "auto-issue #42" from a blockedReason string. Returns null if absent. */
428function parseAutoIssueNumber(blockedReason: string | null): number | null {
429 if (!blockedReason) return null;
430 const m = blockedReason.match(/auto-issue #(\d+)/);
431 return m ? parseInt(m[1], 10) : null;
432}
433
304ce2aClaude434function statusClass(status: string): string {
24cf2caClaude435 switch (status) {
436 case "success":
304ce2aClaude437 return "dk-status is-success";
24cf2caClaude438 case "failed":
304ce2aClaude439 return "dk-status is-failed";
24cf2caClaude440 case "blocked":
304ce2aClaude441 return "dk-status is-blocked";
24cf2caClaude442 case "running":
443 case "pending":
304ce2aClaude444 return "dk-status is-running";
24cf2caClaude445 default:
304ce2aClaude446 return "dk-status is-other";
24cf2caClaude447 }
448}
449
450function fmtTs(t: Date | null | undefined): string {
451 if (!t) return "—";
452 return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z";
453}
454
304ce2aClaude455/** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */
456function dkRelativeTime(from: Date | null, now: Date = new Date()): string {
457 if (!from) return "—";
458 const ms = now.getTime() - new Date(from).getTime();
459 if (ms < 5_000) return "just now";
460 const s = Math.floor(ms / 1_000);
461 if (s < 60) return `${s}s ago`;
462 const m = Math.floor(s / 60);
463 if (m < 60) return `${m}m ago`;
464 const h = Math.floor(m / 60);
465 if (h < 24) return `${h}h ago`;
466 const d = Math.floor(h / 24);
467 return `${d}d ago`;
468}
469
24cf2caClaude470function groupByEnv(rows: Row[]): Record<string, Row[]> {
471 const out: Record<string, Row[]> = {};
472 for (const r of rows) {
473 (out[r.environment] ||= []).push(r);
474 }
475 return out;
476}
477
478function envSummary(rows: Row[]): { last: Row | undefined; successRate: number } {
479 const last = rows[0];
480 const recent = rows.slice(0, 20);
481 const successes = recent.filter((r) => r.status === "success").length;
482 const rate = recent.length ? successes / recent.length : 1;
483 return { last, successRate: rate };
484}
485
486dep.get("/:owner/:repo/deployments", async (c) => {
487 const { owner, repo } = c.req.param();
488 const user = c.get("user");
489 const repoRow = await resolveRepo(owner, repo);
490 if (!repoRow) return c.notFound();
491
492 let rows: Row[] = [];
493 try {
494 rows = (await db
495 .select({
496 id: deployments.id,
497 repositoryId: deployments.repositoryId,
498 environment: deployments.environment,
499 commitSha: deployments.commitSha,
500 ref: deployments.ref,
501 status: deployments.status,
502 blockedReason: deployments.blockedReason,
503 target: deployments.target,
504 triggeredBy: deployments.triggeredBy,
505 createdAt: deployments.createdAt,
506 completedAt: deployments.completedAt,
507 triggeredByName: users.username,
508 })
509 .from(deployments)
510 .leftJoin(users, eq(users.id, deployments.triggeredBy))
511 .where(eq(deployments.repositoryId, repoRow.id))
512 .orderBy(desc(deployments.createdAt))
513 .limit(500)) as Row[];
514 } catch (err) {
515 console.error("[deployments] list:", err);
516 }
517
518 const envs = groupByEnv(rows);
519 const envNames = Object.keys(envs).sort();
520
521 return c.html(
522 <Layout title={`${owner}/${repo} — deployments`} user={user}>
523 <RepoHeader owner={owner} repo={repo} />
304ce2aClaude524 <div class="dk-wrap">
525 <header class="dk-head">
526 <div class="dk-eyebrow">
527 <span class="dk-eyebrow-pill" aria-hidden="true">
528 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
529 <path d="M3 12h4l3 8 4-16 3 8h4" />
530 </svg>
531 </span>
532 Deployments · {owner}/{repo}
24cf2caClaude533 </div>
304ce2aClaude534 <h2 class="dk-title">
535 <span class="dk-title-grad">Shipped</span>, env by env.
536 </h2>
537 <p class="dk-sub">
538 Every deploy to every environment, newest first. Rolled up by
539 environment with the latest status and success rate across the
540 last 20 runs.
541 </p>
542 </header>
24cf2caClaude543
304ce2aClaude544 {envNames.length === 0 ? (
545 <div class="dk-empty">
546 <div class="dk-empty-orb" aria-hidden="true" />
547 <div class="dk-empty-inner">
548 <p class="dk-empty-title">No deployments yet</p>
549 <p class="dk-empty-sub">
550 When a green push reaches the default branch and a deploy
551 target is configured, deploys land here — with status, SHA,
552 and the operator who shipped it.
553 </p>
24cf2caClaude554 </div>
304ce2aClaude555 </div>
556 ) : (
557 <div class="dk-envs">
558 {envNames.map((env) => {
559 const envRows = envs[env];
560 const { last, successRate } = envSummary(envRows);
561 const rate = Math.round(successRate * 100);
562 return (
563 <section class="dk-env" aria-label={`Environment ${env}`}>
564 <header class="dk-env-head">
565 <h3 class="dk-env-name">{env}</h3>
566 <div class="dk-env-meta">
567 {last && (
568 <span class={statusClass(last.status)}>
569 <span class="dot" aria-hidden="true" />
570 {last.status}
571 </span>
572 )}
573 <span class="dk-env-rate">
574 {rate}% green · {envRows.length} total
575 </span>
576 </div>
577 </header>
578 <div class="dk-env-body">
579 {envRows.slice(0, 10).map((r) => (
580 <div class="dk-run">
581 <span class={statusClass(r.status)}>
582 <span class="dot" aria-hidden="true" />
583 {r.status}
584 </span>
585 <code class="dk-sha">{r.commitSha.slice(0, 7)}</code>
586 <span class="dk-ref">
587 {r.ref.replace(/^refs\/heads\//, "")}
588 </span>
589 <span class="dk-target">{r.target || "—"}</span>
590 <span class="dk-by">
591 by {r.triggeredByName || "system"}
592 </span>
593 <span
594 class="dk-time"
595 title={fmtTs(r.createdAt)}
596 >
597 {dkRelativeTime(r.createdAt)}
598 </span>
599 <a
600 href={`/${owner}/${repo}/deployments/${r.id}`}
601 class="dk-link"
602 >
603 details
604 </a>
605 </div>
606 ))}
607 {envRows.length > 10 && (
608 <div class="dk-run-more">
609 + {envRows.length - 10} more{"…"}
610 </div>
611 )}
612 </div>
613 </section>
614 );
615 })}
616 </div>
617 )}
24cf2caClaude618 </div>
304ce2aClaude619 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
24cf2caClaude620 </Layout>
621 );
622});
623
624dep.get("/:owner/:repo/deployments/:id", async (c) => {
625 const { owner, repo, id } = c.req.param();
626 const user = c.get("user");
627 const repoRow = await resolveRepo(owner, repo);
628 if (!repoRow) return c.notFound();
629
630 let row: Row | null = null;
631 try {
632 const [r] = await db
633 .select({
634 id: deployments.id,
635 repositoryId: deployments.repositoryId,
636 environment: deployments.environment,
637 commitSha: deployments.commitSha,
638 ref: deployments.ref,
639 status: deployments.status,
640 blockedReason: deployments.blockedReason,
641 target: deployments.target,
642 triggeredBy: deployments.triggeredBy,
643 createdAt: deployments.createdAt,
644 completedAt: deployments.completedAt,
645 triggeredByName: users.username,
646 })
647 .from(deployments)
648 .leftJoin(users, eq(users.id, deployments.triggeredBy))
649 .where(
650 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
651 )
652 .limit(1);
653 row = (r as Row) || null;
654 } catch (err) {
655 console.error("[deployments] detail:", err);
656 }
657
658 if (!row) return c.notFound();
659
304ce2aClaude660 const autoIssue = parseAutoIssueNumber(row.blockedReason);
661
24cf2caClaude662 return c.html(
663 <Layout
664 title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`}
665 user={user}
666 >
667 <RepoHeader owner={owner} repo={repo} />
304ce2aClaude668 <div class="dk-detail-wrap">
669 <div class="dk-bread">
24cf2caClaude670 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
304ce2aClaude671 <span aria-hidden="true">/</span>
24cf2caClaude672 <span>{row.id.slice(0, 8)}</span>
673 </div>
304ce2aClaude674 <h2 class="dk-detail-title">
675 <span class={statusClass(row.status)}>
676 <span class="dot" aria-hidden="true" />
677 {row.status}
24cf2caClaude678 </span>
304ce2aClaude679 <span class="dk-detail-sha">{row.commitSha.slice(0, 7)}</span>
680 <span class="dk-detail-arrow" aria-hidden="true">&rarr;</span>
681 <span class="dk-detail-env">{row.environment}</span>
24cf2caClaude682 </h2>
304ce2aClaude683 <div class="dk-detail-card">
684 <table class="dk-kv">
685 <tbody>
686 <tr>
687 <th>Target</th>
688 <td>{row.target || "—"}</td>
689 </tr>
24cf2caClaude690 <tr>
304ce2aClaude691 <th>Ref</th>
692 <td>
693 <code>{row.ref}</code>
694 </td>
24cf2caClaude695 </tr>
304ce2aClaude696 <tr>
697 <th>Commit</th>
698 <td>
699 <a href={`/${owner}/${repo}/commit/${row.commitSha}`}>
700 <code>{row.commitSha}</code>
701 </a>
702 </td>
703 </tr>
704 <tr>
705 <th>Triggered by</th>
706 <td>{row.triggeredByName || "system"}</td>
707 </tr>
708 <tr>
709 <th>Created</th>
710 <td>
711 {fmtTs(row.createdAt)}{" "}
712 <span style="color:var(--text-muted);font-variant-numeric:tabular-nums">
713 ({dkRelativeTime(row.createdAt)})
714 </span>
715 </td>
716 </tr>
717 <tr>
718 <th>Completed</th>
719 <td>
720 {fmtTs(row.completedAt)}
721 {row.completedAt && (
722 <span style="color:var(--text-muted);font-variant-numeric:tabular-nums">
723 {" "}({dkRelativeTime(row.completedAt)})
724 </span>
725 )}
726 </td>
727 </tr>
728 {row.blockedReason && (
729 <tr>
730 <th>Blocked reason</th>
731 <td class="dk-blocked">{row.blockedReason}</td>
732 </tr>
733 )}
734 {autoIssue !== null && (
1e162a8Claude735 <tr>
736 <th>Incident issue</th>
737 <td>
304ce2aClaude738 <a href={`/${owner}/${repo}/issues/${autoIssue}`}>
739 #{autoIssue}
740 </a>
1e162a8Claude741 </td>
742 </tr>
304ce2aClaude743 )}
744 </tbody>
745 </table>
746 </div>
1e162a8Claude747 {row.status === "failed" && (
748 <form
749 method="post"
750 action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`}
304ce2aClaude751 class="dk-retry"
1e162a8Claude752 >
304ce2aClaude753 <button type="submit" class="dk-btn dk-btn-ghost">
1e162a8Claude754 Re-run incident analysis
755 </button>
756 </form>
757 )}
24cf2caClaude758 </div>
304ce2aClaude759 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
24cf2caClaude760 </Layout>
761 );
762});
763
1e162a8Claude764// D4: re-trigger the AI incident responder for a failed deployment. Owner-only.
765// Redirects back to the deployment detail page in all cases.
766dep.post(
767 "/:owner/:repo/deployments/:id/retry-incident",
768 requireAuth,
769 async (c) => {
770 const { owner, repo, id } = c.req.param();
771 const user = c.get("user")!;
772 const repoRow = await resolveRepo(owner, repo);
773 const back = `/${owner}/${repo}/deployments/${id}`;
774 if (!repoRow) return c.notFound();
775 if (repoRow.ownerId !== user.id) {
776 return c.redirect(back);
777 }
778 try {
779 const [depRow] = await db
780 .select()
781 .from(deployments)
782 .where(
783 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
784 )
785 .limit(1);
786 if (!depRow || depRow.status !== "failed") return c.redirect(back);
787 await onDeployFailure({
788 repositoryId: repoRow.id,
789 deploymentId: depRow.id,
790 ref: depRow.ref,
791 commitSha: depRow.commitSha,
792 target: depRow.target,
793 errorMessage: depRow.blockedReason,
794 });
795 } catch (err) {
796 console.error("[deployments] retry-incident:", err);
797 }
798 return c.redirect(back);
799 }
800);
801
c9ed210Claude802/* ─────────────────────────────────────────────────────────────────────────────
803 * Cloud deploy integration routes (migration 0077)
804 * ───────────────────────────────────────────────────────────────────────── */
805
806const PROVIDER_LABELS: Record<string, string> = {
807 fly: "Fly.io",
808 railway: "Railway",
809 render: "Render",
810 vercel: "Vercel",
811 netlify: "Netlify",
812 webhook: "Generic Webhook",
813};
814
815const cloudDeploySettingsStyles = `
816 .cds-wrap { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
817 .cds-head { margin-bottom: var(--space-5); }
818 .cds-title {
819 font-size: clamp(20px,2.8vw,28px); font-family: var(--font-display); font-weight: 800;
820 letter-spacing: -0.022em; margin: 0 0 var(--space-2); color: var(--text-strong);
821 }
822 .cds-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.55; }
823 .cds-card {
824 background: var(--bg-elevated); border: 1px solid var(--border);
825 border-radius: 14px; overflow: hidden; margin-bottom: var(--space-4);
826 }
827 .cds-card-head {
828 padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border);
829 display: flex; align-items: center; justify-content: space-between;
830 background: rgba(255,255,255,0.012);
831 }
832 .cds-card-title { margin: 0; font-size: 14px; font-weight: 700; color: var(--text-strong); }
833 .cds-config-row {
834 display: grid; grid-template-columns: 120px 1fr 120px 80px auto;
835 align-items: center; gap: 12px; padding: 10px var(--space-4);
836 border-top: 1px solid rgba(255,255,255,0.04); font-size: 13px;
837 }
838 .cds-config-row:first-child { border-top: none; }
839 .cds-badge {
840 display: inline-flex; align-items: center; padding: 2px 8px;
841 border-radius: 6px; font-size: 11px; font-weight: 700;
842 background: rgba(140,109,255,0.14); color: #b69dff;
843 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.3);
844 }
845 .cds-mono { font-family: var(--font-mono); font-size: 12px; }
846 .cds-form label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
847 .cds-form input, .cds-form select {
848 width: 100%; padding: 8px 10px; background: var(--bg-tertiary);
849 border: 1px solid var(--border); border-radius: 8px; color: var(--text);
850 font-family: inherit; font-size: 13px;
851 }
852 .cds-form input:focus, .cds-form select:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
853 .cds-grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-3); }
854 .cds-btn {
855 display: inline-flex; align-items: center; gap: 6px;
856 padding: 8px 16px; font-size: 13px; font-weight: 600;
857 border-radius: 8px; cursor: pointer; font-family: inherit;
858 border: 1px solid transparent;
859 transition: background 120ms ease, border-color 120ms ease;
860 }
861 .cds-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
862 .cds-btn-primary:hover { filter: brightness(1.1); }
863 .cds-btn-ghost { background: rgba(255,255,255,0.03); border-color: var(--border); color: var(--text); }
864 .cds-btn-ghost:hover { background: rgba(255,255,255,0.06); border-color: var(--border-strong); }
865 .cds-btn-danger { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.35); color: #fca5a5; }
866 .cds-btn-danger:hover { background: rgba(239,68,68,0.2); }
867 .cds-footer { margin-top: var(--space-4); display: flex; justify-content: flex-end; }
868 .cds-empty { padding: var(--space-5); text-align: center; color: var(--text-muted); font-size: 13px; }
869 .cds-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 700;
870 padding: 2px 8px; border-radius: 9999px; }
871 .cds-status.is-success { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
872 .cds-status.is-failed { background: rgba(248,113,113,0.14); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
873 .cds-status.is-running, .cds-status.is-pending { background: rgba(96,165,250,0.14); color: #bfdbfe; box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); }
874 .cds-status.is-cancelled { background: rgba(107,114,128,0.16); color: #d1d5db; box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32); }
875 .cds-runs { display: flex; flex-direction: column; gap: 0; }
876 .cds-run-row {
877 display: grid; grid-template-columns: 80px 90px 1fr auto auto;
878 align-items: center; gap: 12px; padding: 10px var(--space-4);
879 border-top: 1px solid rgba(255,255,255,0.04); font-size: 13px;
880 }
881 .cds-run-row:first-child { border-top: none; }
882 .cds-run-row:hover { background: rgba(255,255,255,0.02); }
883 .cds-sha { font-family: var(--font-mono); font-size: 12px; background: rgba(255,255,255,0.04);
884 border: 1px solid var(--border); padding: 2px 7px; border-radius: 6px; }
885 .cds-link { color: var(--accent); text-decoration: none; font-size: 12px; }
886 .cds-link:hover { text-decoration: underline; }
887 @keyframes cds-spin { to { transform: rotate(360deg); } }
888 .cds-spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid rgba(96,165,250,0.3);
889 border-top-color: #93c5fd; border-radius: 50%; animation: cds-spin 0.8s linear infinite; }
890`;
891
892function cdStatusClass(status: string): string {
893 switch (status) {
894 case "success":
895 return "cds-status is-success";
896 case "failed":
897 return "cds-status is-failed";
898 case "running":
899 return "cds-status is-running";
900 case "pending":
901 return "cds-status is-pending";
902 case "cancelled":
903 return "cds-status is-cancelled";
904 default:
905 return "cds-status is-cancelled";
906 }
907}
908
909/** GET /:owner/:repo/settings/deployments — cloud deploy config page */
910dep.get("/:owner/:repo/settings/deployments", requireAuth, async (c) => {
911 const { owner, repo } = c.req.param();
912 const user = c.get("user")!;
913 const repoRow = await resolveRepo(owner, repo);
914 if (!repoRow) return c.notFound();
915 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
916
917 let configs: Array<typeof cloudDeployConfigs.$inferSelect> = [];
918 try {
919 configs = await db
920 .select()
921 .from(cloudDeployConfigs)
922 .where(eq(cloudDeployConfigs.repoId, repoRow.id))
923 .orderBy(desc(cloudDeployConfigs.createdAt));
924 } catch {
925 /* ignore */
926 }
927
928 const flash = c.req.query("flash");
929
930 return c.html(
931 <Layout title={`${owner}/${repo} — cloud deploy settings`} user={user}>
932 <RepoHeader owner={owner} repo={repo} />
933 <div class="cds-wrap">
934 <header class="cds-head">
935 <h2 class="cds-title">Cloud Deploy Integrations</h2>
936 <p class="cds-sub">
937 Push to a branch and Gluecron automatically deploys to your cloud
938 provider. Supports Fly.io, Railway, Render, Vercel, Netlify, and any
939 webhook-based platform.
940 </p>
941 </header>
942
943 {flash === "added" && (
944 <div style="background:rgba(52,211,153,0.1);border:1px solid rgba(52,211,153,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#6ee7b7;margin-bottom:var(--space-4);">
945 Integration added successfully.
946 </div>
947 )}
948 {flash === "deleted" && (
949 <div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;margin-bottom:var(--space-4);">
950 Integration removed.
951 </div>
952 )}
953 {flash === "error" && (
954 <div style="background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;margin-bottom:var(--space-4);">
955 Error: SERVER_TARGETS_KEY env var is not set. Cloud deploy tokens cannot
956 be encrypted.
957 </div>
958 )}
959
960 <div class="cds-card">
961 <div class="cds-card-head">
962 <h3 class="cds-card-title">Active integrations</h3>
963 </div>
964 {configs.length === 0 ? (
965 <div class="cds-empty">
966 No integrations configured yet. Add one below.
967 </div>
968 ) : (
969 <div style="padding:0;">
970 {configs.map((cfg) => (
971 <div class="cds-config-row">
972 <span class="cds-badge">
973 {PROVIDER_LABELS[cfg.provider] ?? cfg.provider}
974 </span>
975 <span class="cds-mono">{cfg.providerAppId}</span>
976 <span style="font-size:12px;color:var(--text-muted);">
977 branch:{" "}
978 <code class="cds-mono">{cfg.triggerBranch}</code>
979 </span>
980 <span style="font-size:12px;color:var(--text-muted);">
981 {cfg.enabled ? "enabled" : "disabled"}
982 </span>
983 <form
984 method="post"
985 action={`/${owner}/${repo}/settings/deployments/${cfg.id}/delete`}
986 >
987 <button
988 type="submit"
989 class="cds-btn cds-btn-danger"
990 style="padding:4px 10px;font-size:12px;"
991 >
992 Remove
993 </button>
994 </form>
995 </div>
996 ))}
997 </div>
998 )}
999 </div>
1000
1001 <div class="cds-card">
1002 <div class="cds-card-head">
1003 <h3 class="cds-card-title">Add integration</h3>
1004 </div>
1005 <div style="padding:var(--space-4);">
1006 <form
1007 method="post"
1008 action={`/${owner}/${repo}/settings/deployments`}
1009 class="cds-form"
1010 >
1011 <div
1012 class="cds-grid-3"
1013 style="margin-bottom:var(--space-3);"
1014 >
1015 <div>
1016 <label for="provider">Provider</label>
1017 <select id="provider" name="provider" required>
1018 <option value="fly">Fly.io</option>
1019 <option value="railway">Railway</option>
1020 <option value="render">Render</option>
1021 <option value="vercel">Vercel</option>
1022 <option value="netlify">Netlify</option>
1023 <option value="webhook">Generic Webhook</option>
1024 </select>
1025 </div>
1026 <div>
1027 <label for="provider_app_id">
1028 App / Service ID (or webhook URL)
1029 </label>
1030 <input
1031 type="text"
1032 id="provider_app_id"
1033 name="provider_app_id"
1034 placeholder="e.g. my-app, srv-xxx, webhook URL"
1035 required
1036 />
1037 </div>
1038 <div>
1039 <label for="trigger_branch">Trigger branch</label>
1040 <input
1041 type="text"
1042 id="trigger_branch"
1043 name="trigger_branch"
1044 placeholder="main"
1045 value="main"
1046 required
1047 />
1048 </div>
1049 </div>
1050 <div style="margin-bottom:var(--space-4);">
1051 <label for="api_token">API token (stored encrypted)</label>
1052 <input
1053 type="password"
1054 id="api_token"
1055 name="api_token"
1056 placeholder="Paste your provider API token"
1057 required
1058 />
1059 <p style="font-size:11.5px;color:var(--text-muted);margin:4px 0 0;">
1060 Token is encrypted with AES-256-GCM before storing. Never
1061 logged.
1062 </p>
1063 </div>
1064 <div class="cds-footer">
1065 <button type="submit" class="cds-btn cds-btn-primary">
1066 Add integration
1067 </button>
1068 </div>
1069 </form>
1070 </div>
1071 </div>
1072
1073 <div style="text-align:center;margin-top:var(--space-3);">
1074 <a
1075 href={`/${owner}/${repo}/cloud-deployments`}
1076 class="cds-link"
1077 >
1078 View deployment history &rarr;
1079 </a>
1080 </div>
1081 </div>
1082 <style dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }} />
1083 </Layout>
1084 );
1085});
1086
1087/** POST /:owner/:repo/settings/deployments — add new cloud deploy config */
1088dep.post(
1089 "/:owner/:repo/settings/deployments",
1090 requireAuth,
1091 async (c) => {
1092 const { owner, repo } = c.req.param();
1093 const user = c.get("user")!;
1094 const repoRow = await resolveRepo(owner, repo);
1095 const back = `/${owner}/${repo}/settings/deployments`;
1096 if (!repoRow) return c.notFound();
1097 if (repoRow.ownerId !== user.id) return c.redirect(back);
1098
1099 const form = await c.req.formData();
1100 const provider = String(form.get("provider") || "").trim();
1101 const providerAppId = String(form.get("provider_app_id") || "").trim();
1102 const triggerBranch = String(
1103 form.get("trigger_branch") || "main"
1104 ).trim();
1105 const apiToken = String(form.get("api_token") || "").trim();
1106
1107 const validProviders = [
1108 "fly",
1109 "railway",
1110 "render",
1111 "vercel",
1112 "netlify",
1113 "webhook",
1114 ];
1115 if (
1116 !validProviders.includes(provider) ||
1117 !providerAppId ||
1118 !triggerBranch ||
1119 !apiToken
1120 ) {
1121 return c.redirect(`${back}?flash=error`);
1122 }
1123
1124 const encResult = encryptValue(apiToken);
1125 if (!encResult.ok) {
1126 return c.redirect(`${back}?flash=error`);
1127 }
1128
1129 try {
1130 await db.insert(cloudDeployConfigs).values({
1131 repoId: repoRow.id,
1132 provider,
1133 providerAppId,
1134 apiTokenEncrypted: encResult.ciphertext,
1135 triggerBranch,
1136 enabled: true,
1137 });
1138 } catch (err) {
1139 console.error("[cloud-deploy] insert config:", err);
1140 return c.redirect(`${back}?flash=error`);
1141 }
1142
1143 return c.redirect(`${back}?flash=added`);
1144 }
1145);
1146
1147/** POST /:owner/:repo/settings/deployments/:configId/delete */
1148dep.post(
1149 "/:owner/:repo/settings/deployments/:configId/delete",
1150 requireAuth,
1151 async (c) => {
1152 const { owner, repo, configId } = c.req.param();
1153 const user = c.get("user")!;
1154 const repoRow = await resolveRepo(owner, repo);
1155 const back = `/${owner}/${repo}/settings/deployments`;
1156 if (!repoRow) return c.notFound();
1157 if (repoRow.ownerId !== user.id) return c.redirect(back);
1158
1159 try {
1160 await db
1161 .delete(cloudDeployConfigs)
1162 .where(
1163 and(
1164 eq(cloudDeployConfigs.id, configId),
1165 eq(cloudDeployConfigs.repoId, repoRow.id)
1166 )
1167 );
1168 } catch (err) {
1169 console.error("[cloud-deploy] delete config:", err);
1170 }
1171
1172 return c.redirect(`${back}?flash=deleted`);
1173 }
1174);
1175
1176/** GET /:owner/:repo/cloud-deployments — list recent cloud deploy runs */
1177dep.get("/:owner/:repo/cloud-deployments", softAuth, async (c) => {
1178 const { owner, repo } = c.req.param();
1179 const user = c.get("user");
1180 const repoRow = await resolveRepo(owner, repo);
1181 if (!repoRow) return c.notFound();
1182
1183 let runs: Array<{
1184 id: string;
1185 configId: string;
1186 commitSha: string;
1187 status: string;
1188 providerDeployId: string | null;
1189 logUrl: string | null;
1190 deployUrl: string | null;
1191 errorMessage: string | null;
1192 startedAt: Date | null;
1193 completedAt: Date | null;
1194 durationMs: number | null;
1195 provider: string;
1196 providerAppId: string;
1197 triggerBranch: string;
1198 }> = [];
1199 try {
1200 runs = await db
1201 .select({
1202 id: cloudDeployments.id,
1203 configId: cloudDeployments.configId,
1204 commitSha: cloudDeployments.commitSha,
1205 status: cloudDeployments.status,
1206 providerDeployId: cloudDeployments.providerDeployId,
1207 logUrl: cloudDeployments.logUrl,
1208 deployUrl: cloudDeployments.deployUrl,
1209 errorMessage: cloudDeployments.errorMessage,
1210 startedAt: cloudDeployments.startedAt,
1211 completedAt: cloudDeployments.completedAt,
1212 durationMs: cloudDeployments.durationMs,
1213 provider: cloudDeployConfigs.provider,
1214 providerAppId: cloudDeployConfigs.providerAppId,
1215 triggerBranch: cloudDeployConfigs.triggerBranch,
1216 })
1217 .from(cloudDeployments)
1218 .innerJoin(
1219 cloudDeployConfigs,
1220 eq(cloudDeployments.configId, cloudDeployConfigs.id)
1221 )
1222 .where(eq(cloudDeployments.repoId, repoRow.id))
1223 .orderBy(desc(cloudDeployments.startedAt))
1224 .limit(100);
1225 } catch (err) {
1226 console.error("[cloud-deployments] list:", err);
1227 }
1228
1229 return c.html(
1230 <Layout title={`${owner}/${repo} — cloud deployments`} user={user}>
1231 <RepoHeader owner={owner} repo={repo} />
1232 <div class="cds-wrap">
1233 <header
1234 class="cds-head"
1235 style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;"
1236 >
1237 <div>
1238 <h2 class="cds-title">Cloud Deployments</h2>
1239 <p class="cds-sub">
1240 Recent push-triggered deploys across all configured integrations.
1241 </p>
1242 </div>
1243 {user?.id === repoRow.ownerId && (
1244 <a
1245 href={`/${owner}/${repo}/settings/deployments`}
1246 class="cds-btn cds-btn-ghost"
1247 style="text-decoration:none;"
1248 >
1249 Configure integrations
1250 </a>
1251 )}
1252 </header>
1253
1254 <div class="cds-card">
1255 {runs.length === 0 ? (
1256 <div class="cds-empty">
1257 No cloud deployments yet.{" "}
1258 {user?.id === repoRow.ownerId ? (
1259 <a
1260 href={`/${owner}/${repo}/settings/deployments`}
1261 class="cds-link"
1262 >
1263 Add an integration
1264 </a>
1265 ) : (
1266 "Ask the repo owner to configure a cloud deploy integration."
1267 )}
1268 </div>
1269 ) : (
1270 <div class="cds-runs">
1271 {runs.map((run) => {
1272 const durMs = run.durationMs;
1273 const dur =
1274 durMs != null
1275 ? durMs >= 60_000
1276 ? `${Math.round(durMs / 60_000)}m ${Math.round((durMs % 60_000) / 1000)}s`
1277 : `${Math.round(durMs / 1000)}s`
1278 : null;
1279 const isRunning =
1280 run.status === "running" || run.status === "pending";
1281 return (
1282 <div class="cds-run-row">
1283 <span class={cdStatusClass(run.status)}>
1284 {isRunning ? (
1285 <span
1286 class="cds-spinner"
1287 aria-label="deploying"
1288 />
1289 ) : null}
1290 {run.status}
1291 </span>
1292 <code class="cds-sha">
1293 {run.commitSha.slice(0, 7)}
1294 </code>
1295 <div
1296 style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;"
1297 >
1298 <span class="cds-badge">
1299 {PROVIDER_LABELS[run.provider] ?? run.provider}
1300 </span>
1301 <span
1302 class="cds-mono"
1303 style="font-size:12px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1304 >
1305 {run.providerAppId}
1306 </span>
1307 {run.deployUrl && run.status === "success" && (
1308 <a
1309 href={run.deployUrl}
1310 target="_blank"
1311 rel="noopener noreferrer"
1312 class="cds-link"
1313 >
1314 {run.deployUrl
1315 .replace(/^https?:\/\//, "")
1316 .slice(0, 40)}
1317 </a>
1318 )}
1319 {run.status === "failed" && run.errorMessage && (
1320 <span
1321 style="font-size:11.5px;color:#fca5a5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1322 title={run.errorMessage}
1323 >
1324 {run.errorMessage.slice(0, 60)}
1325 </span>
1326 )}
1327 </div>
1328 <span
1329 style="font-size:12px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums;"
1330 >
1331 {dur
1332 ? run.status === "success"
1333 ? `Deployed in ${dur}`
1334 : run.status === "failed"
1335 ? `Failed after ${dur}`
1336 : dur
1337 : dkRelativeTime(run.startedAt)}
1338 </span>
1339 <div style="display:flex;gap:6px;align-items:center;">
1340 {run.logUrl && (
1341 <a
1342 href={run.logUrl}
1343 target="_blank"
1344 rel="noopener noreferrer"
1345 class="cds-link"
1346 >
1347 Logs
1348 </a>
1349 )}
1350 </div>
1351 </div>
1352 );
1353 })}
1354 </div>
1355 )}
1356 </div>
1357 </div>
1358 <style
1359 dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }}
1360 />
1361 </Layout>
1362 );
1363});
1364
1365/** POST /:owner/:repo/deployments/:deployId/cancel — cancel a running cloud deployment */
1366dep.post(
1367 "/:owner/:repo/deployments/:deployId/cancel",
1368 requireAuth,
1369 async (c) => {
1370 const { owner, repo, deployId } = c.req.param();
1371 const user = c.get("user")!;
1372 const repoRow = await resolveRepo(owner, repo);
1373 const back = `/${owner}/${repo}/cloud-deployments`;
1374 if (!repoRow) return c.notFound();
1375 if (repoRow.ownerId !== user.id) return c.redirect(back);
1376
1377 try {
1378 await db
1379 .update(cloudDeployments)
1380 .set({ status: "cancelled", completedAt: new Date() })
1381 .where(
1382 and(
1383 eq(cloudDeployments.id, deployId),
1384 eq(cloudDeployments.repoId, repoRow.id)
1385 )
1386 );
1387 } catch (err) {
1388 console.error("[cloud-deploy] cancel:", err);
1389 }
1390
1391 return c.redirect(back);
1392 }
1393);
1394
24cf2caClaude1395export default dep;