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

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