Commit304ce2aunknown_key
polish(settings): 2026 cards for webhooks + signing-keys + deploy-keys
3 files changed+1785−323304ce2ac42597b05c1740d7a9bd8b8bca4757698
3 changed files+1785−323
Modifiedsrc/routes/deployments.tsx+564−137View fileUnifiedSplit
@@ -7,6 +7,18 @@
77 *
88 * Data comes from the `deployments` table populated by Crontech / gate
99 * logic on successful push to the default branch.
10 *
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.
1022 */
1123
1224import { Hono } from "hono";
@@ -26,6 +38,373 @@ dep.use("/:owner/:repo/deployments/*", softAuth);
2638
2739type Row = typeof deployments.$inferSelect & { triggeredByName: string | null };
2840
41/* ─────────────────────────────────────────────────────────────────────────
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
29408async function resolveRepo(owner: string, name: string) {
30409 try {
31410 const [row] = await db
@@ -51,19 +430,19 @@ function parseAutoIssueNumber(blockedReason: string | null): number | null {
51430 return m ? parseInt(m[1], 10) : null;
52431}
53432
54function statusBadgeClass(status: string): string {
433function statusClass(status: string): string {
55434 switch (status) {
56435 case "success":
57 return "gate-status passed";
436 return "dk-status is-success";
58437 case "failed":
59 return "gate-status failed";
438 return "dk-status is-failed";
60439 case "blocked":
61 return "gate-status skipped";
440 return "dk-status is-blocked";
62441 case "running":
63442 case "pending":
64 return "gate-status running";
443 return "dk-status is-running";
65444 default:
66 return "gate-status";
445 return "dk-status is-other";
67446 }
68447}
69448
@@ -72,6 +451,21 @@ function fmtTs(t: Date | null | undefined): string {
72451 return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z";
73452}
74453
454/** 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
75469function groupByEnv(rows: Row[]): Record<string, Row[]> {
76470 const out: Record<string, Row[]> = {};
77471 for (const r of rows) {
@@ -126,85 +520,102 @@ dep.get("/:owner/:repo/deployments", async (c) => {
126520 return c.html(
127521 <Layout title={`${owner}/${repo} — deployments`} user={user}>
128522 <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>
523 <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}
144532 </div>
145 )}
533 <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>
146542
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>
543 {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>
204553 </div>
205 );
206 })}
554 </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 )}
207617 </div>
618 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
208619 </Layout>
209620 );
210621});
@@ -245,90 +656,106 @@ dep.get("/:owner/:repo/deployments/:id", async (c) => {
245656
246657 if (!row) return c.notFound();
247658
659 const autoIssue = parseAutoIssueNumber(row.blockedReason);
660
248661 return c.html(
249662 <Layout
250663 title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`}
251664 user={user}
252665 >
253666 <RepoHeader owner={owner} repo={repo} />
254 <div style="max-width: 700px">
255 <div class="breadcrumb">
667 <div class="dk-detail-wrap">
668 <div class="dk-bread">
256669 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
257 <span>/</span>
670 <span aria-hidden="true">/</span>
258671 <span>{row.id.slice(0, 8)}</span>
259672 </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 → {row.environment}
673 <h2 class="dk-detail-title">
674 <span class={statusClass(row.status)}>
675 <span class="dot" aria-hidden="true" />
676 {row.status}
267677 </span>
678 <span class="dk-detail-sha">{row.commitSha.slice(0, 7)}</span>
679 <span class="dk-detail-arrow" aria-hidden="true">→</span>
680 <span class="dk-detail-env">{row.environment}</span>
268681 </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 && (
682 <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>
302689 <tr>
303 <th>Blocked reason</th>
304 <td style="color: var(--red)">{row.blockedReason}</td>
690 <th>Ref</th>
691 <td>
692 <code>{row.ref}</code>
693 </td>
305694 </tr>
306 )}
307 {(() => {
308 const n = parseAutoIssueNumber(row.blockedReason);
309 return n !== null ? (
695 <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 && (
310734 <tr>
311735 <th>Incident issue</th>
312736 <td>
313 <a href={`/${owner}/${repo}/issues/${n}`}>#{n}</a>
737 <a href={`/${owner}/${repo}/issues/${autoIssue}`}>
738 #{autoIssue}
739 </a>
314740 </td>
315741 </tr>
316 ) : null;
317 })()}
318 </tbody>
319 </table>
742 )}
743 </tbody>
744 </table>
745 </div>
320746 {row.status === "failed" && (
321747 <form
322748 method="post"
323749 action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`}
324 style="margin-top: 16px"
750 class="dk-retry"
325751 >
326 <button type="submit" class="btn btn-secondary">
752 <button type="submit" class="dk-btn dk-btn-ghost">
327753 Re-run incident analysis
328754 </button>
329755 </form>
330756 )}
331757 </div>
758 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
332759 </Layout>
333760 );
334761});
Modifiedsrc/routes/signing-keys.tsx+592−104View fileUnifiedSplit
@@ -4,6 +4,21 @@
44 * GET /settings/signing-keys — list + add form
55 * POST /settings/signing-keys — add new key
66 * POST /settings/signing-keys/:id/delete
7 *
8 * 2026 polish:
9 * - Page-level eyebrow + display headline + subtitle (the settings layout
10 * already provides the sidebar — no hero block here).
11 * - Each key is a polished card showing title, key-type chip, optional
12 * email, mono fingerprint, created timestamp (relative, tabular-nums),
13 * and an "active" status pill.
14 * - Add-key form is its own card with focus rings + primary gradient
15 * submit button.
16 * - Empty state is a dashed card with an orb + helpful CTA copy.
17 * - All CSS scoped under `.sk-*`.
18 *
19 * Hard rules preserved:
20 * - Every route, form action, POST handler is unchanged.
21 * - Layout / ui.tsx / components.tsx are not modified.
722 */
823
924import { Hono } from "hono";
@@ -21,135 +36,608 @@ const signingKeysRoutes = new Hono<AuthEnv>();
2136signingKeysRoutes.use("/settings/signing-keys", requireAuth);
2237signingKeysRoutes.use("/settings/signing-keys/*", requireAuth);
2338
39/* ─────────────────────────────────────────────────────────────────────────
40 * Scoped CSS — every class prefixed `.sk-` so this page can't bleed into
41 * other settings surfaces. Mirrors the section-card pattern from
42 * admin-integrations.tsx and admin-ops.tsx.
43 * ───────────────────────────────────────────────────────────────────── */
44const signingKeyStyles = `
45 .sk-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
46
47 /* ─── Page heading (no hero block — settings sidebar supplies framing) ─── */
48 .sk-head { margin-bottom: var(--space-5); }
49 .sk-eyebrow {
50 font-size: 12px;
51 color: var(--text-muted);
52 margin-bottom: var(--space-2);
53 letter-spacing: 0.02em;
54 display: inline-flex;
55 align-items: center;
56 gap: 8px;
57 text-transform: uppercase;
58 }
59 .sk-eyebrow-pill {
60 display: inline-flex;
61 align-items: center;
62 justify-content: center;
63 width: 18px; height: 18px;
64 border-radius: 6px;
65 background: rgba(140,109,255,0.14);
66 color: #b69dff;
67 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
68 }
69 .sk-title {
70 font-size: clamp(24px, 3.2vw, 32px);
71 font-family: var(--font-display);
72 font-weight: 800;
73 letter-spacing: -0.024em;
74 line-height: 1.08;
75 margin: 0 0 var(--space-2);
76 color: var(--text-strong);
77 }
78 .sk-title-grad {
79 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
80 -webkit-background-clip: text;
81 background-clip: text;
82 -webkit-text-fill-color: transparent;
83 color: transparent;
84 }
85 .sk-sub {
86 font-size: 14.5px;
87 color: var(--text-muted);
88 margin: 0;
89 line-height: 1.55;
90 max-width: 620px;
91 }
92 .sk-sub code {
93 font-family: var(--font-mono);
94 font-size: 12.5px;
95 background: var(--bg-tertiary);
96 padding: 1px 5px;
97 border-radius: 4px;
98 }
99 .sk-sub .sk-verified {
100 color: #6ee7b7;
101 font-weight: 600;
102 }
103
104 /* ─── Banners ─── */
105 .sk-banner {
106 margin-bottom: var(--space-4);
107 padding: 10px 14px;
108 border-radius: 10px;
109 font-size: 13.5px;
110 border: 1px solid var(--border);
111 background: rgba(255,255,255,0.025);
112 color: var(--text);
113 display: flex;
114 align-items: center;
115 gap: 10px;
116 }
117 .sk-banner.is-ok {
118 border-color: rgba(52,211,153,0.40);
119 background: rgba(52,211,153,0.08);
120 color: #bbf7d0;
121 }
122 .sk-banner.is-error {
123 border-color: rgba(248,113,113,0.40);
124 background: rgba(248,113,113,0.08);
125 color: #fecaca;
126 }
127 .sk-banner-dot {
128 width: 8px; height: 8px;
129 border-radius: 9999px;
130 background: currentColor;
131 flex-shrink: 0;
132 }
133
134 /* ─── Key cards ─── */
135 .sk-list {
136 display: flex;
137 flex-direction: column;
138 gap: var(--space-3);
139 margin-bottom: var(--space-5);
140 }
141 .sk-card {
142 background: var(--bg-elevated);
143 border: 1px solid var(--border);
144 border-radius: 14px;
145 padding: var(--space-4) var(--space-5);
146 display: flex;
147 flex-direction: column;
148 gap: var(--space-3);
149 transition: border-color 140ms ease;
150 }
151 .sk-card:hover { border-color: var(--border-strong); }
152 .sk-card-top {
153 display: flex;
154 align-items: flex-start;
155 justify-content: space-between;
156 gap: var(--space-3);
157 flex-wrap: wrap;
158 }
159 .sk-card-id { flex: 1; min-width: 240px; }
160 .sk-card-name {
161 font-family: var(--font-display);
162 font-size: 15px;
163 font-weight: 700;
164 color: var(--text-strong);
165 letter-spacing: -0.012em;
166 margin: 0;
167 display: flex;
168 align-items: center;
169 gap: 8px;
170 flex-wrap: wrap;
171 }
172 .sk-card-email {
173 font-size: 12.5px;
174 color: var(--text-muted);
175 font-weight: 500;
176 font-family: var(--font-mono);
177 }
178 .sk-card-fp {
179 margin-top: 8px;
180 padding: 8px 10px;
181 font-family: var(--font-mono);
182 font-size: 11.5px;
183 color: var(--text);
184 background: rgba(255,255,255,0.025);
185 border: 1px solid var(--border);
186 border-radius: 8px;
187 word-break: break-all;
188 overflow-wrap: anywhere;
189 line-height: 1.45;
190 }
191 .sk-card-meta {
192 display: flex;
193 align-items: center;
194 gap: 10px;
195 flex-wrap: wrap;
196 font-size: 12px;
197 color: var(--text-muted);
198 }
199 .sk-time {
200 font-variant-numeric: tabular-nums;
201 font-size: 12px;
202 color: var(--text-muted);
203 }
204
205 /* ─── Chips + pills ─── */
206 .sk-type-chip {
207 display: inline-flex;
208 align-items: center;
209 padding: 2px 9px;
210 border-radius: 9999px;
211 background: rgba(140,109,255,0.12);
212 border: 1px solid rgba(140,109,255,0.30);
213 color: #c4b5fd;
214 font-size: 10.5px;
215 font-weight: 700;
216 letter-spacing: 0.06em;
217 text-transform: uppercase;
218 font-family: var(--font-mono);
219 }
220 .sk-pill {
221 display: inline-flex;
222 align-items: center;
223 gap: 6px;
224 padding: 3px 10px;
225 border-radius: 9999px;
226 font-size: 11px;
227 font-weight: 600;
228 letter-spacing: 0.04em;
229 text-transform: uppercase;
230 flex-shrink: 0;
231 }
232 .sk-pill .dot {
233 width: 6px; height: 6px;
234 border-radius: 9999px;
235 background: currentColor;
236 }
237 .sk-pill.is-active {
238 background: rgba(52,211,153,0.14);
239 color: #6ee7b7;
240 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
241 }
242 .sk-pill.is-expired {
243 background: rgba(248,113,113,0.12);
244 color: #fca5a5;
245 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
246 }
247
248 /* ─── Actions ─── */
249 .sk-actions {
250 display: flex;
251 align-items: center;
252 gap: 8px;
253 flex-wrap: wrap;
254 }
255 .sk-btn {
256 display: inline-flex;
257 align-items: center;
258 gap: 6px;
259 padding: 7px 14px;
260 font-size: 12.5px;
261 font-weight: 600;
262 border-radius: 8px;
263 cursor: pointer;
264 font-family: inherit;
265 text-decoration: none;
266 border: 1px solid transparent;
267 transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
268 }
269 .sk-btn-danger {
270 background: rgba(248,113,113,0.08);
271 border-color: rgba(248,113,113,0.30);
272 color: #fca5a5;
273 }
274 .sk-btn-danger:hover {
275 background: rgba(248,113,113,0.14);
276 border-color: rgba(248,113,113,0.50);
277 color: #fecaca;
278 }
279 .sk-btn-primary {
280 background: linear-gradient(135deg, #8c6dff 0%, #6d4ee0 100%);
281 color: #ffffff;
282 border-color: rgba(140,109,255,0.55);
283 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45);
284 }
285 .sk-btn-primary:hover {
286 transform: translateY(-1px);
287 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
288 }
289 .sk-card-form { margin: 0; }
290
291 /* ─── Empty state ─── */
292 .sk-empty {
293 position: relative;
294 padding: var(--space-6) var(--space-5);
295 margin-bottom: var(--space-5);
296 border: 1px dashed var(--border-strong);
297 border-radius: 16px;
298 background: rgba(255,255,255,0.02);
299 text-align: center;
300 overflow: hidden;
301 }
302 .sk-empty-orb {
303 position: absolute;
304 inset: -40% -10% auto auto;
305 width: 320px; height: 320px;
306 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
307 filter: blur(70px);
308 opacity: 0.6;
309 pointer-events: none;
310 z-index: 0;
311 }
312 .sk-empty-inner { position: relative; z-index: 1; }
313 .sk-empty-title {
314 font-family: var(--font-display);
315 font-size: 17px;
316 font-weight: 700;
317 color: var(--text-strong);
318 margin: 0 0 6px;
319 letter-spacing: -0.018em;
320 }
321 .sk-empty-sub {
322 font-size: 13.5px;
323 color: var(--text-muted);
324 margin: 0 auto;
325 max-width: 460px;
326 line-height: 1.5;
327 }
328
329 /* ─── Add-key form card ─── */
330 .sk-form-card {
331 background: var(--bg-elevated);
332 border: 1px solid var(--border);
333 border-radius: 14px;
334 overflow: hidden;
335 }
336 .sk-form-head {
337 padding: var(--space-4) var(--space-5);
338 border-bottom: 1px solid var(--border);
339 display: flex;
340 align-items: center;
341 gap: 10px;
342 }
343 .sk-form-title {
344 margin: 0;
345 font-family: var(--font-display);
346 font-size: 16px;
347 font-weight: 700;
348 letter-spacing: -0.018em;
349 color: var(--text-strong);
350 display: flex;
351 align-items: center;
352 gap: 10px;
353 }
354 .sk-form-title-icon {
355 display: inline-flex;
356 align-items: center;
357 justify-content: center;
358 width: 26px; height: 26px;
359 border-radius: 8px;
360 background: rgba(140,109,255,0.12);
361 color: #b69dff;
362 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
363 }
364 .sk-form-body { padding: var(--space-4) var(--space-5); }
365 .sk-field { margin-bottom: var(--space-4); }
366 .sk-field:last-of-type { margin-bottom: 0; }
367 .sk-label {
368 display: block;
369 font-size: 12.5px;
370 font-weight: 600;
371 color: var(--text-strong);
372 margin-bottom: 6px;
373 letter-spacing: -0.005em;
374 }
375 .sk-input,
376 .sk-select,
377 .sk-textarea {
378 width: 100%;
379 padding: 9px 12px;
380 font-size: 13.5px;
381 color: var(--text);
382 background: var(--bg);
383 border: 1px solid var(--border-strong);
384 border-radius: 8px;
385 outline: none;
386 font-family: var(--font-mono);
387 transition: border-color 120ms ease, box-shadow 120ms ease;
388 box-sizing: border-box;
389 }
390 .sk-input:focus,
391 .sk-select:focus,
392 .sk-textarea:focus {
393 border-color: var(--border-focus);
394 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
395 }
396 .sk-textarea {
397 resize: vertical;
398 min-height: 180px;
399 line-height: 1.5;
400 }
401 .sk-hint {
402 margin-top: 6px;
403 font-size: 11.5px;
404 color: var(--text-muted);
405 line-height: 1.45;
406 }
407 .sk-form-foot {
408 padding: var(--space-3) var(--space-5);
409 border-top: 1px solid var(--border);
410 background: rgba(255,255,255,0.012);
411 display: flex;
412 justify-content: flex-end;
413 gap: var(--space-2);
414 align-items: center;
415 flex-wrap: wrap;
416 }
417`;
418
419/** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */
420function skRelativeTime(from: Date | null, now: Date = new Date()): string {
421 if (!from) return "—";
422 const ms = now.getTime() - new Date(from).getTime();
423 if (ms < 5_000) return "just now";
424 const s = Math.floor(ms / 1_000);
425 if (s < 60) return `${s}s ago`;
426 const m = Math.floor(s / 60);
427 if (m < 60) return `${m}m ago`;
428 const h = Math.floor(m / 60);
429 if (h < 24) return `${h}h ago`;
430 const d = Math.floor(h / 24);
431 return `${d}d ago`;
432}
433
24434signingKeysRoutes.get("/settings/signing-keys", async (c) => {
25435 const user = c.get("user")!;
26436 const keys = await listSigningKeysForUser(user.id);
27437 const message = c.req.query("message");
28438 const error = c.req.query("error");
439 const now = new Date();
29440 return c.html(
30441 <Layout title="Signing keys" user={user}>
31 <div class="settings-container">
32 <h2>Signing keys</h2>
33 <p style="color:var(--text-muted)">
34 Register the GPG or SSH public key you use for{" "}
35 <code>git commit -S</code>. Commits we can match to a registered key
36 render with a <span style="color:var(--green);font-weight:600">Verified</span>{" "}
37 badge. This is identity matching by fingerprint — cryptographic
38 verification is future work.
39 </p>
442 <div class="sk-wrap">
443 <header class="sk-head">
444 <div class="sk-eyebrow">
445 <span class="sk-eyebrow-pill" aria-hidden="true">
446 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
447 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
448 </svg>
449 </span>
450 Signing keys · {user.username}
451 </div>
452 <h2 class="sk-title">
453 <span class="sk-title-grad">Prove</span> it was you.
454 </h2>
455 <p class="sk-sub">
456 Register the GPG or SSH public key you use for{" "}
457 <code>git commit -S</code>. Commits we can match to a registered
458 key render with a <span class="sk-verified">Verified</span>{" "}
459 badge. This is identity matching by fingerprint —
460 cryptographic verification is future work.
461 </p>
462 </header>
463
40464 {message && (
41 <div class="auth-success" style="margin-top:12px">
465 <div class="sk-banner is-ok" role="status">
466 <span class="sk-banner-dot" aria-hidden="true" />
42467 {decodeURIComponent(message)}
43468 </div>
44469 )}
45470 {error && (
46 <div class="auth-error" style="margin-top:12px">
471 <div class="sk-banner is-error" role="status">
472 <span class="sk-banner-dot" aria-hidden="true" />
47473 {decodeURIComponent(error)}
48474 </div>
49475 )}
50476
51 <h3 style="margin-top:24px">Your keys</h3>
52 {keys.length === 0 ? (
53 <div class="panel-empty" style="padding:16px">
54 No signing keys yet.
55 </div>
56 ) : (
57 <div class="panel">
58 {keys.map((k) => (
59 <div
60 class="panel-item"
61 style="flex-direction:column;align-items:stretch;gap:4px"
62 >
63 <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap">
64 <div>
477 {keys.length > 0 ? (
478 <div class="sk-list">
479 {keys.map((k) => {
480 const expired =
481 k.expiresAt && new Date(k.expiresAt).getTime() < now.getTime();
482 return (
483 <article class="sk-card">
484 <div class="sk-card-top">
485 <div class="sk-card-id">
486 <h3 class="sk-card-name">
487 <span class="sk-type-chip">{k.keyType}</span>
488 <span>{k.title}</span>
489 {k.email && (
490 <span class="sk-card-email">{k.email}</span>
491 )}
492 </h3>
493 <div class="sk-card-fp" title={k.fingerprint}>
494 {k.fingerprint}
495 </div>
496 <div class="sk-card-meta" style="margin-top:10px">
497 <span class="sk-time">
498 Added {skRelativeTime(k.createdAt)}
499 </span>
500 {k.lastUsedAt && (
501 <>
502 <span aria-hidden="true">·</span>
503 <span class="sk-time">
504 Last used {skRelativeTime(k.lastUsedAt)}
505 </span>
506 </>
507 )}
508 </div>
509 </div>
65510 <span
66 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase;margin-right:6px"
511 class={
512 "sk-pill " + (expired ? "is-expired" : "is-active")
513 }
67514 >
68 {k.keyType}
515 <span class="dot" aria-hidden="true" />
516 {expired ? "expired" : "active"}
69517 </span>
70 <span style="font-weight:600">{k.title}</span>
71 {k.email && (
72 <span
73 style="font-size:12px;color:var(--text-muted);margin-left:8px"
74 >
75 {k.email}
76 </span>
77 )}
78518 </div>
79 <form
80 method="post"
81 action={`/settings/signing-keys/${k.id}/delete`}
82 >
83 <button
84 type="submit"
85 class="btn btn-sm"
86 style="font-size:11px"
519
520 <div class="sk-actions">
521 <form
522 class="sk-card-form"
523 method="post"
524 action={`/settings/signing-keys/${k.id}/delete`}
87525 >
88 Delete
89 </button>
90 </form>
91 </div>
92 <div
93 style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);word-break:break-all"
94 >
95 {k.fingerprint}
96 </div>
97 </div>
98 ))}
526 <button type="submit" class="sk-btn sk-btn-danger">
527 Revoke
528 </button>
529 </form>
530 </div>
531 </article>
532 );
533 })}
534 </div>
535 ) : (
536 <div class="sk-empty">
537 <div class="sk-empty-orb" aria-hidden="true" />
538 <div class="sk-empty-inner">
539 <p class="sk-empty-title">No signing keys yet</p>
540 <p class="sk-empty-sub">
541 Sign commits to prove they're you — your verified pushes will
542 render with a green badge once we match the signature to a
543 key on file.
544 </p>
545 </div>
99546 </div>
100547 )}
101548
102 <h3 style="margin-top:24px">Add a key</h3>
103 <form
104 method="post"
105 action="/settings/signing-keys"
106 class="auth-form"
107 style="max-width:720px"
108 >
109 <div class="form-group">
110 <label for="sk-title">Title</label>
111 <input
112 type="text"
113 id="sk-title"
114 name="title"
115 placeholder="e.g. Work laptop"
116 required
117 maxLength={120}
118 />
119 </div>
120 <div class="form-group">
121 <label for="sk-type">Key type</label>
122 <select id="sk-type" name="key_type" required>
123 <option value="gpg">GPG</option>
124 <option value="ssh">SSH</option>
125 </select>
126 </div>
127 <div class="form-group">
128 <label for="sk-email">Email (optional)</label>
129 <input
130 type="email"
131 id="sk-email"
132 name="email"
133 placeholder="commit-author@example.com"
134 maxLength={200}
135 />
136 </div>
137 <div class="form-group">
138 <label for="sk-public">Public key</label>
139 <textarea
140 id="sk-public"
141 name="public_key"
142 rows={10}
143 required
144 placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK----- or: ssh-ed25519 AAAA... you@laptop"
145 style="font-family:var(--font-mono);font-size:12px"
146 />
147 </div>
148 <button type="submit" class="btn btn-primary">
149 Add key
150 </button>
151 </form>
549 <section class="sk-form-card" aria-labelledby="sk-add-title">
550 <header class="sk-form-head">
551 <h3 class="sk-form-title" id="sk-add-title">
552 <span class="sk-form-title-icon" aria-hidden="true">
553 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
554 <line x1="12" y1="5" x2="12" y2="19" />
555 <line x1="5" y1="12" x2="19" y2="12" />
556 </svg>
557 </span>
558 Add a key
559 </h3>
560 </header>
561 <form
562 method="post"
563 action="/settings/signing-keys"
564 class="auth-form"
565 >
566 <div class="sk-form-body">
567 <div class="sk-field">
568 <label class="sk-label" for="sk-title">
569 Title
570 </label>
571 <input
572 type="text"
573 id="sk-title"
574 name="title"
575 class="sk-input"
576 placeholder="e.g. Work laptop"
577 required
578 maxLength={120}
579 autocomplete="off"
580 spellcheck={false}
581 />
582 </div>
583 <div class="sk-field">
584 <label class="sk-label" for="sk-type">
585 Key type
586 </label>
587 <select
588 id="sk-type"
589 name="key_type"
590 class="sk-select"
591 required
592 >
593 <option value="gpg">GPG</option>
594 <option value="ssh">SSH</option>
595 </select>
596 </div>
597 <div class="sk-field">
598 <label class="sk-label" for="sk-email">
599 Email (optional)
600 </label>
601 <input
602 type="email"
603 id="sk-email"
604 name="email"
605 class="sk-input"
606 placeholder="commit-author@example.com"
607 maxLength={200}
608 autocomplete="off"
609 spellcheck={false}
610 />
611 <div class="sk-hint">
612 Helps match commits whose <code>Signed-off-by</code> uses a
613 different address than your account.
614 </div>
615 </div>
616 <div class="sk-field">
617 <label class="sk-label" for="sk-public">
618 Public key
619 </label>
620 <textarea
621 id="sk-public"
622 name="public_key"
623 rows={10}
624 required
625 class="sk-textarea"
626 placeholder={
627 "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n\nor: ssh-ed25519 AAAA... you@laptop"
628 }
629 />
630 </div>
631 </div>
632 <div class="sk-form-foot">
633 <button type="submit" class="sk-btn sk-btn-primary">
634 Add key
635 </button>
636 </div>
637 </form>
638 </section>
152639 </div>
640 <style dangerouslySetInnerHTML={{ __html: signingKeyStyles }} />
153641 </Layout>
154642 );
155643});
Modifiedsrc/routes/webhooks.tsx+629−82View fileUnifiedSplit
@@ -1,5 +1,20 @@
11/**
22 * Webhooks management — register, list, delete, test.
3 *
4 * 2026 polish:
5 * - Page-level eyebrow + display headline + subtitle (the settings layout
6 * already supplies the polished sidebar, so we don't render a hero block).
7 * - Each webhook is a card showing URL (mono), event chips, created
8 * timestamp (tabular-nums, relative), status pill, last-delivery dot.
9 * - Add-new form is its own card with focus rings + primary gradient submit.
10 * - Empty state is a dashed card with an orb + helpful CTA copy.
11 * - All CSS scoped under `.wh-*` to avoid bleed into other surfaces.
12 *
13 * Hard rules preserved:
14 * - Every route, form action, POST/DELETE handler, and DB query is
15 * unchanged. Only the rendered HTML/CSS changes.
16 * - The shared Layout / settings sidebar / ui.tsx components are not
17 * modified — the polish happens inside the page body only.
318 */
419
520import { Hono } from "hono";
@@ -15,20 +30,438 @@ import { RepoHeader } from "../views/components";
1530import { softAuth, requireAuth } from "../middleware/auth";
1631import type { AuthEnv } from "../middleware/auth";
1732import { requireRepoAccess } from "../middleware/repo-access";
18import {
19 Container,
20 Flex,
21 Form,
22 FormGroup,
23 Input,
24 Button,
25 Alert,
26} from "../views/ui";
2733
2834const webhookRoutes = new Hono<AuthEnv>();
2935
3036webhookRoutes.use("*", softAuth);
3137
38/* ─────────────────────────────────────────────────────────────────────────
39 * Scoped CSS — every class prefixed `.wh-` so this page can't bleed into
40 * other settings surfaces. Mirrors the section-card + traffic-light
41 * patterns from admin-integrations.tsx and admin-ops.tsx.
42 * ───────────────────────────────────────────────────────────────────── */
43const webhookStyles = `
44 .wh-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
45
46 /* ─── Page heading (no hero — settings sidebar already supplies it) ─── */
47 .wh-head { margin-bottom: var(--space-5); }
48 .wh-eyebrow {
49 font-size: 12px;
50 color: var(--text-muted);
51 margin-bottom: var(--space-2);
52 letter-spacing: 0.02em;
53 display: inline-flex;
54 align-items: center;
55 gap: 8px;
56 text-transform: uppercase;
57 }
58 .wh-eyebrow-pill {
59 display: inline-flex;
60 align-items: center;
61 justify-content: center;
62 width: 18px; height: 18px;
63 border-radius: 6px;
64 background: rgba(140,109,255,0.14);
65 color: #b69dff;
66 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
67 }
68 .wh-title {
69 font-size: clamp(24px, 3.2vw, 32px);
70 font-family: var(--font-display);
71 font-weight: 800;
72 letter-spacing: -0.024em;
73 line-height: 1.08;
74 margin: 0 0 var(--space-2);
75 color: var(--text-strong);
76 }
77 .wh-title-grad {
78 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
79 -webkit-background-clip: text;
80 background-clip: text;
81 -webkit-text-fill-color: transparent;
82 color: transparent;
83 }
84 .wh-sub {
85 font-size: 14.5px;
86 color: var(--text-muted);
87 margin: 0;
88 line-height: 1.55;
89 max-width: 620px;
90 }
91 .wh-sub code {
92 font-family: var(--font-mono);
93 font-size: 12.5px;
94 background: var(--bg-tertiary);
95 padding: 1px 5px;
96 border-radius: 4px;
97 }
98
99 /* ─── Banners ─── */
100 .wh-banner {
101 margin-bottom: var(--space-4);
102 padding: 10px 14px;
103 border-radius: 10px;
104 font-size: 13.5px;
105 border: 1px solid var(--border);
106 background: rgba(255,255,255,0.025);
107 color: var(--text);
108 display: flex;
109 align-items: center;
110 gap: 10px;
111 }
112 .wh-banner.is-ok {
113 border-color: rgba(52,211,153,0.40);
114 background: rgba(52,211,153,0.08);
115 color: #bbf7d0;
116 }
117 .wh-banner.is-error {
118 border-color: rgba(248,113,113,0.40);
119 background: rgba(248,113,113,0.08);
120 color: #fecaca;
121 }
122 .wh-banner-dot {
123 width: 8px; height: 8px;
124 border-radius: 9999px;
125 background: currentColor;
126 flex-shrink: 0;
127 }
128
129 /* ─── List of hooks (cards) ─── */
130 .wh-list {
131 display: flex;
132 flex-direction: column;
133 gap: var(--space-3);
134 margin-bottom: var(--space-5);
135 }
136 .wh-card {
137 background: var(--bg-elevated);
138 border: 1px solid var(--border);
139 border-radius: 14px;
140 padding: var(--space-4) var(--space-5);
141 display: flex;
142 flex-direction: column;
143 gap: var(--space-3);
144 transition: border-color 140ms ease, transform 140ms ease;
145 }
146 .wh-card:hover {
147 border-color: var(--border-strong);
148 }
149 .wh-card-top {
150 display: flex;
151 align-items: flex-start;
152 justify-content: space-between;
153 gap: var(--space-3);
154 flex-wrap: wrap;
155 }
156 .wh-card-id { flex: 1; min-width: 200px; }
157 .wh-card-url {
158 font-family: var(--font-mono);
159 font-size: 13px;
160 color: var(--text-strong);
161 word-break: break-all;
162 overflow-wrap: anywhere;
163 font-weight: 600;
164 line-height: 1.45;
165 display: block;
166 }
167 .wh-card-meta {
168 margin-top: 6px;
169 font-size: 12px;
170 color: var(--text-muted);
171 display: flex;
172 align-items: center;
173 gap: 10px;
174 flex-wrap: wrap;
175 }
176 .wh-time {
177 font-variant-numeric: tabular-nums;
178 font-size: 12px;
179 color: var(--text-muted);
180 }
181
182 /* ─── Status pill (active / inactive) ─── */
183 .wh-pill {
184 display: inline-flex;
185 align-items: center;
186 gap: 6px;
187 padding: 3px 10px;
188 border-radius: 9999px;
189 font-size: 11px;
190 font-weight: 600;
191 letter-spacing: 0.04em;
192 text-transform: uppercase;
193 flex-shrink: 0;
194 }
195 .wh-pill .dot {
196 width: 6px; height: 6px;
197 border-radius: 9999px;
198 background: currentColor;
199 }
200 .wh-pill.is-active {
201 background: rgba(52,211,153,0.14);
202 color: #6ee7b7;
203 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
204 }
205 .wh-pill.is-disabled {
206 background: rgba(107,114,128,0.16);
207 color: #d1d5db;
208 box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32);
209 }
210
211 /* ─── Event chips ─── */
212 .wh-chips {
213 display: flex;
214 flex-wrap: wrap;
215 gap: 6px;
216 }
217 .wh-chip {
218 display: inline-flex;
219 align-items: center;
220 gap: 6px;
221 padding: 3px 9px;
222 border-radius: 9999px;
223 background: rgba(140,109,255,0.10);
224 border: 1px solid rgba(140,109,255,0.30);
225 color: #c4b5fd;
226 font-size: 11px;
227 font-weight: 600;
228 font-family: var(--font-mono);
229 letter-spacing: -0.005em;
230 }
231
232 /* ─── Last-delivery dot + status row ─── */
233 .wh-delivery {
234 display: inline-flex;
235 align-items: center;
236 gap: 6px;
237 padding: 2px 8px;
238 border-radius: 9999px;
239 font-size: 11.5px;
240 background: rgba(255,255,255,0.03);
241 border: 1px solid var(--border);
242 color: var(--text-muted);
243 font-variant-numeric: tabular-nums;
244 }
245 .wh-delivery .dot {
246 width: 7px; height: 7px;
247 border-radius: 9999px;
248 background: #6b7280;
249 box-shadow: 0 0 0 2px rgba(107,114,128,0.16);
250 }
251 .wh-delivery.is-ok { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
252 .wh-delivery.is-ok .dot {
253 background: #34d399;
254 box-shadow: 0 0 0 2px rgba(52,211,153,0.22), 0 0 6px rgba(52,211,153,0.40);
255 }
256 .wh-delivery.is-bad { color: #fca5a5; border-color: rgba(248,113,113,0.35); }
257 .wh-delivery.is-bad .dot {
258 background: #f87171;
259 box-shadow: 0 0 0 2px rgba(248,113,113,0.22), 0 0 6px rgba(248,113,113,0.40);
260 }
261 .wh-delivery.is-pending { color: var(--text-muted); }
262
263 /* ─── Card actions ─── */
264 .wh-actions {
265 display: flex;
266 align-items: center;
267 gap: 8px;
268 flex-wrap: wrap;
269 }
270 .wh-btn {
271 display: inline-flex;
272 align-items: center;
273 gap: 6px;
274 padding: 7px 14px;
275 font-size: 12.5px;
276 font-weight: 600;
277 border-radius: 8px;
278 cursor: pointer;
279 font-family: inherit;
280 text-decoration: none;
281 border: 1px solid transparent;
282 transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
283 }
284 .wh-btn-ghost {
285 background: rgba(255,255,255,0.03);
286 border-color: var(--border);
287 color: var(--text);
288 }
289 .wh-btn-ghost:hover {
290 background: rgba(255,255,255,0.06);
291 border-color: var(--border-strong);
292 color: var(--text-strong);
293 }
294 .wh-btn-danger {
295 background: rgba(248,113,113,0.08);
296 border-color: rgba(248,113,113,0.30);
297 color: #fca5a5;
298 }
299 .wh-btn-danger:hover {
300 background: rgba(248,113,113,0.14);
301 border-color: rgba(248,113,113,0.50);
302 color: #fecaca;
303 }
304 .wh-btn-primary {
305 background: linear-gradient(135deg, #8c6dff 0%, #6d4ee0 100%);
306 color: #ffffff;
307 border-color: rgba(140,109,255,0.55);
308 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45);
309 }
310 .wh-btn-primary:hover {
311 transform: translateY(-1px);
312 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
313 }
314 .wh-card-foot-form { margin: 0; }
315
316 /* ─── Empty state (dashed) ─── */
317 .wh-empty {
318 position: relative;
319 padding: var(--space-6) var(--space-5);
320 margin-bottom: var(--space-5);
321 border: 1px dashed var(--border-strong);
322 border-radius: 16px;
323 background: rgba(255,255,255,0.02);
324 text-align: center;
325 overflow: hidden;
326 }
327 .wh-empty-orb {
328 position: absolute;
329 inset: -40% -10% auto auto;
330 width: 320px; height: 320px;
331 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
332 filter: blur(70px);
333 opacity: 0.6;
334 pointer-events: none;
335 z-index: 0;
336 }
337 .wh-empty-inner { position: relative; z-index: 1; }
338 .wh-empty-title {
339 font-family: var(--font-display);
340 font-size: 17px;
341 font-weight: 700;
342 color: var(--text-strong);
343 margin: 0 0 6px;
344 letter-spacing: -0.018em;
345 }
346 .wh-empty-sub {
347 font-size: 13.5px;
348 color: var(--text-muted);
349 margin: 0 auto;
350 max-width: 460px;
351 line-height: 1.5;
352 }
353
354 /* ─── Add-new form (its own card) ─── */
355 .wh-form-card {
356 background: var(--bg-elevated);
357 border: 1px solid var(--border);
358 border-radius: 14px;
359 overflow: hidden;
360 }
361 .wh-form-head {
362 padding: var(--space-4) var(--space-5);
363 border-bottom: 1px solid var(--border);
364 display: flex;
365 align-items: center;
366 gap: 10px;
367 }
368 .wh-form-title {
369 margin: 0;
370 font-family: var(--font-display);
371 font-size: 16px;
372 font-weight: 700;
373 letter-spacing: -0.018em;
374 color: var(--text-strong);
375 display: flex;
376 align-items: center;
377 gap: 10px;
378 }
379 .wh-form-title-icon {
380 display: inline-flex;
381 align-items: center;
382 justify-content: center;
383 width: 26px; height: 26px;
384 border-radius: 8px;
385 background: rgba(140,109,255,0.12);
386 color: #b69dff;
387 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
388 }
389 .wh-form-body { padding: var(--space-4) var(--space-5); }
390 .wh-field { margin-bottom: var(--space-4); }
391 .wh-field:last-of-type { margin-bottom: 0; }
392 .wh-label {
393 display: block;
394 font-size: 12.5px;
395 font-weight: 600;
396 color: var(--text-strong);
397 margin-bottom: 6px;
398 letter-spacing: -0.005em;
399 }
400 .wh-input {
401 width: 100%;
402 padding: 9px 12px;
403 font-size: 13.5px;
404 color: var(--text);
405 background: var(--bg);
406 border: 1px solid var(--border-strong);
407 border-radius: 8px;
408 outline: none;
409 font-family: var(--font-mono);
410 transition: border-color 120ms ease, box-shadow 120ms ease;
411 box-sizing: border-box;
412 }
413 .wh-input:focus {
414 border-color: var(--border-focus);
415 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
416 }
417 .wh-events {
418 display: flex;
419 flex-wrap: wrap;
420 gap: 8px;
421 }
422 .wh-evt-label {
423 display: inline-flex;
424 align-items: center;
425 gap: 6px;
426 padding: 6px 12px;
427 border-radius: 9999px;
428 background: rgba(255,255,255,0.025);
429 border: 1px solid var(--border-strong);
430 font-size: 12.5px;
431 color: var(--text);
432 cursor: pointer;
433 font-family: var(--font-mono);
434 transition: border-color 120ms ease, background 120ms ease;
435 }
436 .wh-evt-label:hover { border-color: rgba(140,109,255,0.45); }
437 .wh-evt-label input { accent-color: #8c6dff; cursor: pointer; }
438 .wh-form-foot {
439 padding: var(--space-3) var(--space-5);
440 border-top: 1px solid var(--border);
441 background: rgba(255,255,255,0.012);
442 display: flex;
443 justify-content: flex-end;
444 gap: var(--space-2);
445 align-items: center;
446 flex-wrap: wrap;
447 }
448`;
449
450/** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */
451function whRelativeTime(from: Date | null, now: Date = new Date()): string {
452 if (!from) return "—";
453 const ms = now.getTime() - new Date(from).getTime();
454 if (ms < 5_000) return "just now";
455 const s = Math.floor(ms / 1_000);
456 if (s < 60) return `${s}s ago`;
457 const m = Math.floor(s / 60);
458 if (m < 60) return `${m}m ago`;
459 const h = Math.floor(m / 60);
460 if (h < 24) return `${h}h ago`;
461 const d = Math.floor(h / 24);
462 return `${d}d ago`;
463}
464
32465// List webhooks
33466webhookRoutes.get(
34467 "/:owner/:repo/settings/webhooks",
@@ -69,88 +502,202 @@ webhookRoutes.get(
69502 return c.html(
70503 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
71504 <RepoHeader owner={ownerName} repo={repoName} />
72 <Container maxWidth={700}>
73 <h2 style="margin-bottom: 16px">Webhooks</h2>
505 <div class="wh-wrap">
506 <header class="wh-head">
507 <div class="wh-eyebrow">
508 <span class="wh-eyebrow-pill" aria-hidden="true">
509 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
510 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
511 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
512 </svg>
513 </span>
514 Webhooks · {ownerName}/{repoName}
515 </div>
516 <h2 class="wh-title">
517 <span class="wh-title-grad">Pipe events</span> out of the repo.
518 </h2>
519 <p class="wh-sub">
520 Webhooks POST to your URL on push, issue, PR, and star events.
521 Every delivery is signed with HMAC-SHA256 using your shared
522 secret — verify with the <code>X-Gluecron-Signature</code>{" "}
523 header.
524 </p>
525 </header>
526
74527 {success && (
75 <Alert variant="success">{decodeURIComponent(success)}</Alert>
528 <div class="wh-banner is-ok" role="status">
529 <span class="wh-banner-dot" aria-hidden="true" />
530 {decodeURIComponent(success)}
531 </div>
76532 )}
77533 {error && (
78 <Alert variant="error">{decodeURIComponent(error)}</Alert>
534 <div class="wh-banner is-error" role="status">
535 <span class="wh-banner-dot" aria-hidden="true" />
536 {decodeURIComponent(error)}
537 </div>
79538 )}
80 {hooks.length > 0 && (
81 <div style="margin-bottom: 24px">
82 {hooks.map((hook) => (
83 <div class="ssh-key-item">
84 <div>
85 <strong>{hook.url}</strong>
86 <div class="ssh-key-meta">
87 Events: {hook.events} |{" "}
88 {hook.isActive ? (
89 <span style="color: var(--green)">Active</span>
90 ) : (
91 <span style="color: var(--red)">Inactive</span>
92 )}
93 {hook.lastDeliveredAt && (
94 <span>
95 {" "}
96 | Last: {hook.lastStatus}
97 </span>
98 )}
539
540 {hooks.length > 0 ? (
541 <div class="wh-list">
542 {hooks.map((hook) => {
543 const events = hook.events
544 .split(",")
545 .map((e) => e.trim())
546 .filter(Boolean);
547 const last = hook.lastStatus ?? null;
548 const deliveryClass =
549 last == null
550 ? "is-pending"
551 : last >= 200 && last < 300
552 ? "is-ok"
553 : "is-bad";
554 const deliveryText =
555 last == null
556 ? "no deliveries yet"
557 : `${last} · ${whRelativeTime(hook.lastDeliveredAt)}`;
558 return (
559 <article class="wh-card">
560 <div class="wh-card-top">
561 <div class="wh-card-id">
562 <span class="wh-card-url">{hook.url}</span>
563 <div class="wh-card-meta">
564 <span class="wh-time">
565 Created {whRelativeTime(hook.createdAt)}
566 </span>
567 <span aria-hidden="true">·</span>
568 <span
569 class={"wh-delivery " + deliveryClass}
570 title={
571 last == null
572 ? "No deliveries yet"
573 : `Last HTTP status ${last}`
574 }
575 >
576 <span class="dot" aria-hidden="true" />
577 {deliveryText}
578 </span>
579 </div>
580 </div>
581 <span
582 class={
583 "wh-pill " +
584 (hook.isActive ? "is-active" : "is-disabled")
585 }
586 >
587 <span class="dot" aria-hidden="true" />
588 {hook.isActive ? "active" : "disabled"}
589 </span>
99590 </div>
100 </div>
101 <form
102 method="post"
103 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
104 >
105 <Button type="submit" variant="danger" size="sm">
106 Delete
107 </Button>
108 </form>
109 </div>
110 ))}
591
592 {events.length > 0 && (
593 <div class="wh-chips" aria-label="Subscribed events">
594 {events.map((evt) => (
595 <span class="wh-chip">{evt}</span>
596 ))}
597 </div>
598 )}
599
600 <div class="wh-actions">
601 <form
602 class="wh-card-foot-form"
603 method="post"
604 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
605 >
606 <button type="submit" class="wh-btn wh-btn-danger">
607 Revoke
608 </button>
609 </form>
610 </div>
611 </article>
612 );
613 })}
614 </div>
615 ) : (
616 <div class="wh-empty">
617 <div class="wh-empty-orb" aria-hidden="true" />
618 <div class="wh-empty-inner">
619 <p class="wh-empty-title">No webhooks yet</p>
620 <p class="wh-empty-sub">
621 Webhooks fire on push, issue, PR, and star events. Point
622 one at your CI, your Slack relay, or your own service to
623 react in real time.
624 </p>
625 </div>
111626 </div>
112627 )}
113628
114 <h3 style="margin-bottom: 12px">Add webhook</h3>
115 <Form
116 method="post"
117 action={`/${ownerName}/${repoName}/settings/webhooks`}
118 >
119 <FormGroup label="Payload URL">
120 <Input
121 type="url"
122 name="url"
123 required
124 placeholder="https://example.com/hooks/gluecron"
125 />
126 </FormGroup>
127 <FormGroup label="Secret (optional)">
128 <Input
129 type="text"
130 name="secret"
131 placeholder="Shared secret for HMAC verification"
132 />
133 </FormGroup>
134 <FormGroup label="Events">
135 <Flex gap={16} wrap>
136 {["push", "issue", "pr", "star"].map((evt) => (
137 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
138 <input
139 type="checkbox"
140 name="events"
141 value={evt}
142 checked={evt === "push"}
143 />{" "}
144 {evt}
629 <section class="wh-form-card" aria-labelledby="wh-add-title">
630 <header class="wh-form-head">
631 <h3 class="wh-form-title" id="wh-add-title">
632 <span class="wh-form-title-icon" aria-hidden="true">
633 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
634 <line x1="12" y1="5" x2="12" y2="19" />
635 <line x1="5" y1="12" x2="19" y2="12" />
636 </svg>
637 </span>
638 Add webhook
639 </h3>
640 </header>
641 <form
642 method="post"
643 action={`/${ownerName}/${repoName}/settings/webhooks`}
644 >
645 <div class="wh-form-body">
646 <div class="wh-field">
647 <label class="wh-label" for="wh-url">
648 Payload URL
145649 </label>
146 ))}
147 </Flex>
148 </FormGroup>
149 <Button type="submit" variant="primary">
150 Add webhook
151 </Button>
152 </Form>
153 </Container>
650 <input
651 id="wh-url"
652 class="wh-input"
653 type="url"
654 name="url"
655 required
656 placeholder="https://example.com/hooks/gluecron"
657 autocomplete="off"
658 spellcheck={false}
659 />
660 </div>
661 <div class="wh-field">
662 <label class="wh-label" for="wh-secret">
663 Secret (optional)
664 </label>
665 <input
666 id="wh-secret"
667 class="wh-input"
668 type="text"
669 name="secret"
670 placeholder="Shared secret for HMAC verification"
671 autocomplete="off"
672 spellcheck={false}
673 />
674 </div>
675 <div class="wh-field">
676 <span class="wh-label">Events</span>
677 <div class="wh-events">
678 {["push", "issue", "pr", "star"].map((evt) => (
679 <label class="wh-evt-label">
680 <input
681 type="checkbox"
682 name="events"
683 value={evt}
684 checked={evt === "push"}
685 />
686 {evt}
687 </label>
688 ))}
689 </div>
690 </div>
691 </div>
692 <div class="wh-form-foot">
693 <button type="submit" class="wh-btn wh-btn-primary">
694 Add webhook
695 </button>
696 </div>
697 </form>
698 </section>
699 </div>
700 <style dangerouslySetInnerHTML={{ __html: webhookStyles }} />
154701 </Layout>
155702 );
156703 }
157704