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.
| 24cf2ca | 1 | /** |
| 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. | |
| 304ce2a | 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. | |
| 24cf2ca | 22 | */ |
| 23 | ||
| 24 | import { Hono } from "hono"; | |
| 25 | import { desc, eq, and } from "drizzle-orm"; | |
| 26 | import { db } from "../db"; | |
| 27 | import { deployments, repositories, users } from "../db/schema"; | |
| 28 | import type { AuthEnv } from "../middleware/auth"; | |
| 1e162a8 | 29 | import { softAuth, requireAuth } from "../middleware/auth"; |
| 24cf2ca | 30 | import { Layout } from "../views/layout"; |
| 31 | import { RepoHeader } from "../views/components"; | |
| 1e162a8 | 32 | import { onDeployFailure } from "../lib/ai-incident"; |
| 24cf2ca | 33 | |
| 34 | const dep = new Hono<AuthEnv>(); | |
| 35 | ||
| 36 | dep.use("/:owner/:repo/deployments", softAuth); | |
| 37 | dep.use("/:owner/:repo/deployments/*", softAuth); | |
| 38 | ||
| 39 | type Row = typeof deployments.$inferSelect & { triggeredByName: string | null }; | |
| 40 | ||
| 304ce2a | 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 46 | const 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 | ||
| 24cf2ca | 408 | async function resolveRepo(owner: string, name: string) { |
| 409 | try { | |
| 410 | const [row] = await db | |
| 411 | .select({ | |
| 412 | id: repositories.id, | |
| 413 | name: repositories.name, | |
| 1e162a8 | 414 | ownerId: repositories.ownerId, |
| 24cf2ca | 415 | }) |
| 416 | .from(repositories) | |
| 417 | .innerJoin(users, eq(users.id, repositories.ownerId)) | |
| 418 | .where(and(eq(users.username, owner), eq(repositories.name, name))) | |
| 419 | .limit(1); | |
| 420 | return row || null; | |
| 421 | } catch { | |
| 422 | return null; | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 1e162a8 | 426 | /** Parse "auto-issue #42" from a blockedReason string. Returns null if absent. */ |
| 427 | function parseAutoIssueNumber(blockedReason: string | null): number | null { | |
| 428 | if (!blockedReason) return null; | |
| 429 | const m = blockedReason.match(/auto-issue #(\d+)/); | |
| 430 | return m ? parseInt(m[1], 10) : null; | |
| 431 | } | |
| 432 | ||
| 304ce2a | 433 | function statusClass(status: string): string { |
| 24cf2ca | 434 | switch (status) { |
| 435 | case "success": | |
| 304ce2a | 436 | return "dk-status is-success"; |
| 24cf2ca | 437 | case "failed": |
| 304ce2a | 438 | return "dk-status is-failed"; |
| 24cf2ca | 439 | case "blocked": |
| 304ce2a | 440 | return "dk-status is-blocked"; |
| 24cf2ca | 441 | case "running": |
| 442 | case "pending": | |
| 304ce2a | 443 | return "dk-status is-running"; |
| 24cf2ca | 444 | default: |
| 304ce2a | 445 | return "dk-status is-other"; |
| 24cf2ca | 446 | } |
| 447 | } | |
| 448 | ||
| 449 | function fmtTs(t: Date | null | undefined): string { | |
| 450 | if (!t) return "—"; | |
| 451 | return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z"; | |
| 452 | } | |
| 453 | ||
| 304ce2a | 454 | /** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */ |
| 455 | function 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 | ||
| 24cf2ca | 469 | function groupByEnv(rows: Row[]): Record<string, Row[]> { |
| 470 | const out: Record<string, Row[]> = {}; | |
| 471 | for (const r of rows) { | |
| 472 | (out[r.environment] ||= []).push(r); | |
| 473 | } | |
| 474 | return out; | |
| 475 | } | |
| 476 | ||
| 477 | function envSummary(rows: Row[]): { last: Row | undefined; successRate: number } { | |
| 478 | const last = rows[0]; | |
| 479 | const recent = rows.slice(0, 20); | |
| 480 | const successes = recent.filter((r) => r.status === "success").length; | |
| 481 | const rate = recent.length ? successes / recent.length : 1; | |
| 482 | return { last, successRate: rate }; | |
| 483 | } | |
| 484 | ||
| 485 | dep.get("/:owner/:repo/deployments", async (c) => { | |
| 486 | const { owner, repo } = c.req.param(); | |
| 487 | const user = c.get("user"); | |
| 488 | const repoRow = await resolveRepo(owner, repo); | |
| 489 | if (!repoRow) return c.notFound(); | |
| 490 | ||
| 491 | let rows: Row[] = []; | |
| 492 | try { | |
| 493 | rows = (await db | |
| 494 | .select({ | |
| 495 | id: deployments.id, | |
| 496 | repositoryId: deployments.repositoryId, | |
| 497 | environment: deployments.environment, | |
| 498 | commitSha: deployments.commitSha, | |
| 499 | ref: deployments.ref, | |
| 500 | status: deployments.status, | |
| 501 | blockedReason: deployments.blockedReason, | |
| 502 | target: deployments.target, | |
| 503 | triggeredBy: deployments.triggeredBy, | |
| 504 | createdAt: deployments.createdAt, | |
| 505 | completedAt: deployments.completedAt, | |
| 506 | triggeredByName: users.username, | |
| 507 | }) | |
| 508 | .from(deployments) | |
| 509 | .leftJoin(users, eq(users.id, deployments.triggeredBy)) | |
| 510 | .where(eq(deployments.repositoryId, repoRow.id)) | |
| 511 | .orderBy(desc(deployments.createdAt)) | |
| 512 | .limit(500)) as Row[]; | |
| 513 | } catch (err) { | |
| 514 | console.error("[deployments] list:", err); | |
| 515 | } | |
| 516 | ||
| 517 | const envs = groupByEnv(rows); | |
| 518 | const envNames = Object.keys(envs).sort(); | |
| 519 | ||
| 520 | return c.html( | |
| 521 | <Layout title={`${owner}/${repo} — deployments`} user={user}> | |
| 522 | <RepoHeader owner={owner} repo={repo} /> | |
| 304ce2a | 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} | |
| 24cf2ca | 532 | </div> |
| 304ce2a | 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> | |
| 24cf2ca | 542 | |
| 304ce2a | 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> | |
| 24cf2ca | 553 | </div> |
| 304ce2a | 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 | )} | |
| 24cf2ca | 617 | </div> |
| 304ce2a | 618 | <style dangerouslySetInnerHTML={{ __html: deployStyles }} /> |
| 24cf2ca | 619 | </Layout> |
| 620 | ); | |
| 621 | }); | |
| 622 | ||
| 623 | dep.get("/:owner/:repo/deployments/:id", async (c) => { | |
| 624 | const { owner, repo, id } = c.req.param(); | |
| 625 | const user = c.get("user"); | |
| 626 | const repoRow = await resolveRepo(owner, repo); | |
| 627 | if (!repoRow) return c.notFound(); | |
| 628 | ||
| 629 | let row: Row | null = null; | |
| 630 | try { | |
| 631 | const [r] = await db | |
| 632 | .select({ | |
| 633 | id: deployments.id, | |
| 634 | repositoryId: deployments.repositoryId, | |
| 635 | environment: deployments.environment, | |
| 636 | commitSha: deployments.commitSha, | |
| 637 | ref: deployments.ref, | |
| 638 | status: deployments.status, | |
| 639 | blockedReason: deployments.blockedReason, | |
| 640 | target: deployments.target, | |
| 641 | triggeredBy: deployments.triggeredBy, | |
| 642 | createdAt: deployments.createdAt, | |
| 643 | completedAt: deployments.completedAt, | |
| 644 | triggeredByName: users.username, | |
| 645 | }) | |
| 646 | .from(deployments) | |
| 647 | .leftJoin(users, eq(users.id, deployments.triggeredBy)) | |
| 648 | .where( | |
| 649 | and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id)) | |
| 650 | ) | |
| 651 | .limit(1); | |
| 652 | row = (r as Row) || null; | |
| 653 | } catch (err) { | |
| 654 | console.error("[deployments] detail:", err); | |
| 655 | } | |
| 656 | ||
| 657 | if (!row) return c.notFound(); | |
| 658 | ||
| 304ce2a | 659 | const autoIssue = parseAutoIssueNumber(row.blockedReason); |
| 660 | ||
| 24cf2ca | 661 | return c.html( |
| 662 | <Layout | |
| 663 | title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`} | |
| 664 | user={user} | |
| 665 | > | |
| 666 | <RepoHeader owner={owner} repo={repo} /> | |
| 304ce2a | 667 | <div class="dk-detail-wrap"> |
| 668 | <div class="dk-bread"> | |
| 24cf2ca | 669 | <a href={`/${owner}/${repo}/deployments`}>deployments</a> |
| 304ce2a | 670 | <span aria-hidden="true">/</span> |
| 24cf2ca | 671 | <span>{row.id.slice(0, 8)}</span> |
| 672 | </div> | |
| 304ce2a | 673 | <h2 class="dk-detail-title"> |
| 674 | <span class={statusClass(row.status)}> | |
| 675 | <span class="dot" aria-hidden="true" /> | |
| 676 | {row.status} | |
| 24cf2ca | 677 | </span> |
| 304ce2a | 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> | |
| 24cf2ca | 681 | </h2> |
| 304ce2a | 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> | |
| 24cf2ca | 689 | <tr> |
| 304ce2a | 690 | <th>Ref</th> |
| 691 | <td> | |
| 692 | <code>{row.ref}</code> | |
| 693 | </td> | |
| 24cf2ca | 694 | </tr> |
| 304ce2a | 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 && ( | |
| 1e162a8 | 734 | <tr> |
| 735 | <th>Incident issue</th> | |
| 736 | <td> | |
| 304ce2a | 737 | <a href={`/${owner}/${repo}/issues/${autoIssue}`}> |
| 738 | #{autoIssue} | |
| 739 | </a> | |
| 1e162a8 | 740 | </td> |
| 741 | </tr> | |
| 304ce2a | 742 | )} |
| 743 | </tbody> | |
| 744 | </table> | |
| 745 | </div> | |
| 1e162a8 | 746 | {row.status === "failed" && ( |
| 747 | <form | |
| 748 | method="post" | |
| 749 | action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`} | |
| 304ce2a | 750 | class="dk-retry" |
| 1e162a8 | 751 | > |
| 304ce2a | 752 | <button type="submit" class="dk-btn dk-btn-ghost"> |
| 1e162a8 | 753 | Re-run incident analysis |
| 754 | </button> | |
| 755 | </form> | |
| 756 | )} | |
| 24cf2ca | 757 | </div> |
| 304ce2a | 758 | <style dangerouslySetInnerHTML={{ __html: deployStyles }} /> |
| 24cf2ca | 759 | </Layout> |
| 760 | ); | |
| 761 | }); | |
| 762 | ||
| 1e162a8 | 763 | // D4: re-trigger the AI incident responder for a failed deployment. Owner-only. |
| 764 | // Redirects back to the deployment detail page in all cases. | |
| 765 | dep.post( | |
| 766 | "/:owner/:repo/deployments/:id/retry-incident", | |
| 767 | requireAuth, | |
| 768 | async (c) => { | |
| 769 | const { owner, repo, id } = c.req.param(); | |
| 770 | const user = c.get("user")!; | |
| 771 | const repoRow = await resolveRepo(owner, repo); | |
| 772 | const back = `/${owner}/${repo}/deployments/${id}`; | |
| 773 | if (!repoRow) return c.notFound(); | |
| 774 | if (repoRow.ownerId !== user.id) { | |
| 775 | return c.redirect(back); | |
| 776 | } | |
| 777 | try { | |
| 778 | const [depRow] = await db | |
| 779 | .select() | |
| 780 | .from(deployments) | |
| 781 | .where( | |
| 782 | and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id)) | |
| 783 | ) | |
| 784 | .limit(1); | |
| 785 | if (!depRow || depRow.status !== "failed") return c.redirect(back); | |
| 786 | await onDeployFailure({ | |
| 787 | repositoryId: repoRow.id, | |
| 788 | deploymentId: depRow.id, | |
| 789 | ref: depRow.ref, | |
| 790 | commitSha: depRow.commitSha, | |
| 791 | target: depRow.target, | |
| 792 | errorMessage: depRow.blockedReason, | |
| 793 | }); | |
| 794 | } catch (err) { | |
| 795 | console.error("[deployments] retry-incident:", err); | |
| 796 | } | |
| 797 | return c.redirect(back); | |
| 798 | } | |
| 799 | ); | |
| 800 | ||
| 24cf2ca | 801 | export default dep; |