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.tsxBlame374 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.
10 */
11
12import { Hono } from "hono";
13import { desc, eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { deployments, repositories, users } from "../db/schema";
16import type { AuthEnv } from "../middleware/auth";
1e162a8Claude17import { softAuth, requireAuth } from "../middleware/auth";
24cf2caClaude18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
1e162a8Claude20import { onDeployFailure } from "../lib/ai-incident";
24cf2caClaude21
22const dep = new Hono<AuthEnv>();
23
24dep.use("/:owner/:repo/deployments", softAuth);
25dep.use("/:owner/:repo/deployments/*", softAuth);
26
27type Row = typeof deployments.$inferSelect & { triggeredByName: string | null };
28
29async function resolveRepo(owner: string, name: string) {
30 try {
31 const [row] = await db
32 .select({
33 id: repositories.id,
34 name: repositories.name,
1e162a8Claude35 ownerId: repositories.ownerId,
24cf2caClaude36 })
37 .from(repositories)
38 .innerJoin(users, eq(users.id, repositories.ownerId))
39 .where(and(eq(users.username, owner), eq(repositories.name, name)))
40 .limit(1);
41 return row || null;
42 } catch {
43 return null;
44 }
45}
46
1e162a8Claude47/** Parse "auto-issue #42" from a blockedReason string. Returns null if absent. */
48function parseAutoIssueNumber(blockedReason: string | null): number | null {
49 if (!blockedReason) return null;
50 const m = blockedReason.match(/auto-issue #(\d+)/);
51 return m ? parseInt(m[1], 10) : null;
52}
53
24cf2caClaude54function statusBadgeClass(status: string): string {
55 switch (status) {
56 case "success":
57 return "gate-status passed";
58 case "failed":
59 return "gate-status failed";
60 case "blocked":
61 return "gate-status skipped";
62 case "running":
63 case "pending":
64 return "gate-status running";
65 default:
66 return "gate-status";
67 }
68}
69
70function fmtTs(t: Date | null | undefined): string {
71 if (!t) return "—";
72 return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z";
73}
74
75function groupByEnv(rows: Row[]): Record<string, Row[]> {
76 const out: Record<string, Row[]> = {};
77 for (const r of rows) {
78 (out[r.environment] ||= []).push(r);
79 }
80 return out;
81}
82
83function envSummary(rows: Row[]): { last: Row | undefined; successRate: number } {
84 const last = rows[0];
85 const recent = rows.slice(0, 20);
86 const successes = recent.filter((r) => r.status === "success").length;
87 const rate = recent.length ? successes / recent.length : 1;
88 return { last, successRate: rate };
89}
90
91dep.get("/:owner/:repo/deployments", async (c) => {
92 const { owner, repo } = c.req.param();
93 const user = c.get("user");
94 const repoRow = await resolveRepo(owner, repo);
95 if (!repoRow) return c.notFound();
96
97 let rows: Row[] = [];
98 try {
99 rows = (await db
100 .select({
101 id: deployments.id,
102 repositoryId: deployments.repositoryId,
103 environment: deployments.environment,
104 commitSha: deployments.commitSha,
105 ref: deployments.ref,
106 status: deployments.status,
107 blockedReason: deployments.blockedReason,
108 target: deployments.target,
109 triggeredBy: deployments.triggeredBy,
110 createdAt: deployments.createdAt,
111 completedAt: deployments.completedAt,
112 triggeredByName: users.username,
113 })
114 .from(deployments)
115 .leftJoin(users, eq(users.id, deployments.triggeredBy))
116 .where(eq(deployments.repositoryId, repoRow.id))
117 .orderBy(desc(deployments.createdAt))
118 .limit(500)) as Row[];
119 } catch (err) {
120 console.error("[deployments] list:", err);
121 }
122
123 const envs = groupByEnv(rows);
124 const envNames = Object.keys(envs).sort();
125
126 return c.html(
127 <Layout title={`${owner}/${repo} — deployments`} user={user}>
128 <RepoHeader owner={owner} repo={repo} />
129 <div style="max-width: 1000px">
130 <h2>Deployments</h2>
131 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
132 Every deploy to every environment, newest first. Rolled up by
133 environment with the latest status and success rate across the last
134 20 runs.
135 </p>
136
137 {envNames.length === 0 && (
138 <div class="empty-state">
139 <h2>No deployments yet</h2>
140 <p>
141 When a green push reaches the default branch and a deploy
142 target is configured, the deploy will show up here.
143 </p>
144 </div>
145 )}
146
147 {envNames.map((env) => {
148 const envRows = envs[env];
149 const { last, successRate } = envSummary(envRows);
150 return (
151 <div
152 style="border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); margin-bottom: 16px; overflow: hidden"
153 >
154 <div
155 style="padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; background: var(--bg-tertiary); border-bottom: 1px solid var(--border)"
156 >
157 <h3 style="margin: 0; font-size: 15px">{env}</h3>
158 <div style="display: flex; gap: 12px; align-items: center; font-size: 12px">
159 {last && (
160 <span class={statusBadgeClass(last.status)}>
161 {last.status}
162 </span>
163 )}
164 <span style="color: var(--text-muted)">
165 {Math.round(successRate * 100)}% green · {envRows.length} total
166 </span>
167 </div>
168 </div>
169 <div class="gate-list" style="border: none; border-radius: 0">
170 {envRows.slice(0, 10).map((r) => (
171 <div class="gate-run-row">
172 <span class={statusBadgeClass(r.status)}>{r.status}</span>
173 <code
174 style="font-family: var(--font-mono); font-size: 12px"
175 >
176 {r.commitSha.slice(0, 7)}
177 </code>
178 <span style="color: var(--text-muted); font-size: 12px">
179 {r.ref.replace(/^refs\/heads\//, "")}
180 </span>
181 <span style="color: var(--text-muted); font-size: 12px; margin-left: auto">
182 {r.target || "—"}
183 </span>
184 <span style="color: var(--text-muted); font-size: 12px">
185 by {r.triggeredByName || "system"}
186 </span>
187 <span style="color: var(--text-muted); font-size: 12px">
188 {fmtTs(r.createdAt)}
189 </span>
190 <a
191 href={`/${owner}/${repo}/deployments/${r.id}`}
192 style="font-size: 12px"
193 >
194 details
195 </a>
196 </div>
197 ))}
198 {envRows.length > 10 && (
199 <div class="gate-run-row" style="color: var(--text-muted); font-size: 12px">
200 + {envRows.length - 10} more{"\u2026"}
201 </div>
202 )}
203 </div>
204 </div>
205 );
206 })}
207 </div>
208 </Layout>
209 );
210});
211
212dep.get("/:owner/:repo/deployments/:id", async (c) => {
213 const { owner, repo, id } = c.req.param();
214 const user = c.get("user");
215 const repoRow = await resolveRepo(owner, repo);
216 if (!repoRow) return c.notFound();
217
218 let row: Row | null = null;
219 try {
220 const [r] = await db
221 .select({
222 id: deployments.id,
223 repositoryId: deployments.repositoryId,
224 environment: deployments.environment,
225 commitSha: deployments.commitSha,
226 ref: deployments.ref,
227 status: deployments.status,
228 blockedReason: deployments.blockedReason,
229 target: deployments.target,
230 triggeredBy: deployments.triggeredBy,
231 createdAt: deployments.createdAt,
232 completedAt: deployments.completedAt,
233 triggeredByName: users.username,
234 })
235 .from(deployments)
236 .leftJoin(users, eq(users.id, deployments.triggeredBy))
237 .where(
238 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
239 )
240 .limit(1);
241 row = (r as Row) || null;
242 } catch (err) {
243 console.error("[deployments] detail:", err);
244 }
245
246 if (!row) return c.notFound();
247
248 return c.html(
249 <Layout
250 title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`}
251 user={user}
252 >
253 <RepoHeader owner={owner} repo={repo} />
254 <div style="max-width: 700px">
255 <div class="breadcrumb">
256 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
257 <span>/</span>
258 <span>{row.id.slice(0, 8)}</span>
259 </div>
260 <h2>
261 <span class={statusBadgeClass(row.status)}>{row.status}</span>{" "}
262 <span style="font-family: var(--font-mono); font-size: 16px">
263 {row.commitSha.slice(0, 7)}
264 </span>{" "}
265 <span style="color: var(--text-muted); font-weight: 400">
266 &rarr; {row.environment}
267 </span>
268 </h2>
269 <table class="audit-table" style="margin-top: 16px">
270 <tbody>
271 <tr>
272 <th style="width: 140px">Target</th>
273 <td>{row.target || "—"}</td>
274 </tr>
275 <tr>
276 <th>Ref</th>
277 <td>
278 <code>{row.ref}</code>
279 </td>
280 </tr>
281 <tr>
282 <th>Commit</th>
283 <td>
284 <a href={`/${owner}/${repo}/commit/${row.commitSha}`}>
285 <code>{row.commitSha}</code>
286 </a>
287 </td>
288 </tr>
289 <tr>
290 <th>Triggered by</th>
291 <td>{row.triggeredByName || "system"}</td>
292 </tr>
293 <tr>
294 <th>Created</th>
295 <td>{fmtTs(row.createdAt)}</td>
296 </tr>
297 <tr>
298 <th>Completed</th>
299 <td>{fmtTs(row.completedAt)}</td>
300 </tr>
301 {row.blockedReason && (
302 <tr>
303 <th>Blocked reason</th>
304 <td style="color: var(--red)">{row.blockedReason}</td>
305 </tr>
306 )}
1e162a8Claude307 {(() => {
308 const n = parseAutoIssueNumber(row.blockedReason);
309 return n !== null ? (
310 <tr>
311 <th>Incident issue</th>
312 <td>
313 <a href={`/${owner}/${repo}/issues/${n}`}>#{n}</a>
314 </td>
315 </tr>
316 ) : null;
317 })()}
24cf2caClaude318 </tbody>
319 </table>
1e162a8Claude320 {row.status === "failed" && (
321 <form
322 method="post"
323 action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`}
324 style="margin-top: 16px"
325 >
326 <button type="submit" class="btn btn-secondary">
327 Re-run incident analysis
328 </button>
329 </form>
330 )}
24cf2caClaude331 </div>
332 </Layout>
333 );
334});
335
1e162a8Claude336// D4: re-trigger the AI incident responder for a failed deployment. Owner-only.
337// Redirects back to the deployment detail page in all cases.
338dep.post(
339 "/:owner/:repo/deployments/:id/retry-incident",
340 requireAuth,
341 async (c) => {
342 const { owner, repo, id } = c.req.param();
343 const user = c.get("user")!;
344 const repoRow = await resolveRepo(owner, repo);
345 const back = `/${owner}/${repo}/deployments/${id}`;
346 if (!repoRow) return c.notFound();
347 if (repoRow.ownerId !== user.id) {
348 return c.redirect(back);
349 }
350 try {
351 const [depRow] = await db
352 .select()
353 .from(deployments)
354 .where(
355 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
356 )
357 .limit(1);
358 if (!depRow || depRow.status !== "failed") return c.redirect(back);
359 await onDeployFailure({
360 repositoryId: repoRow.id,
361 deploymentId: depRow.id,
362 ref: depRow.ref,
363 commitSha: depRow.commitSha,
364 target: depRow.target,
365 errorMessage: depRow.blockedReason,
366 });
367 } catch (err) {
368 console.error("[deployments] retry-incident:", err);
369 }
370 return c.redirect(back);
371 }
372);
373
24cf2caClaude374export default dep;