Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

workflows.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.

workflows.tsxBlame1204 lines · 1 contributor
eafe8c6Claude1/**
2 * Actions-equivalent workflow UI (Block C1).
3 *
4 * GET /:owner/:repo/actions — workflows + recent runs
5 * GET /:owner/:repo/actions/runs/:runId — run detail + job logs
6 * POST /:owner/:repo/actions/:workflowId/run — manual trigger (auth)
7 * POST /:owner/:repo/actions/runs/:runId/cancel — cancel a running run (auth)
8 *
9 * Render philosophy: keep the view shallow — the real execution happens in
10 * the runner (src/lib/workflow-runner.ts). This file is just navigation +
11 * manual triggers. Logs for each job are displayed inline (v1 has no
12 * streaming; workers write the final logs blob to the row).
0c7043cClaude13 *
14 * Visual polish (2026): adopts the gradient-hairline + orb pattern from
15 * admin-integrations / error-page. Page-level CSS is scoped under `.wf-*`
16 * so it can't bleed into the layout. RepoHeader + RepoNav are untouched.
eafe8c6Claude17 */
18
19import { Hono } from "hono";
20import { and, desc, eq } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repositories,
24 users,
25 workflowJobs,
26 workflowRuns,
27 workflows,
28} from "../db/schema";
29import { Layout } from "../views/layout";
30import { RepoHeader, RepoNav } from "../views/components";
abfa9adClaude31import { LogTail } from "../views/log-tail";
eafe8c6Claude32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import { getUnreadCount } from "../lib/unread";
35import { audit } from "../lib/notify";
36import { enqueueRun } from "../lib/workflow-runner";
37
38const actions = new Hono<AuthEnv>();
39actions.use("*", softAuth);
40
41async function loadRepo(owner: string, repo: string) {
42 const [row] = await db
43 .select({
44 id: repositories.id,
45 name: repositories.name,
46 defaultBranch: repositories.defaultBranch,
47 ownerId: repositories.ownerId,
48 starCount: repositories.starCount,
49 forkCount: repositories.forkCount,
50 })
51 .from(repositories)
52 .innerJoin(users, eq(repositories.ownerId, users.id))
53 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
54 .limit(1);
55 return row;
56}
57
58function relTime(d: Date | string | null): string {
59 if (!d) return "—";
60 const t = typeof d === "string" ? new Date(d) : d;
61 const diffMs = Date.now() - t.getTime();
62 const mins = Math.floor(diffMs / 60000);
63 if (mins < 1) return "just now";
64 if (mins < 60) return `${mins}m ago`;
65 const hrs = Math.floor(mins / 60);
66 if (hrs < 24) return `${hrs}h ago`;
67 const days = Math.floor(hrs / 24);
68 if (days < 30) return `${days}d ago`;
69 return t.toLocaleDateString();
70}
71
72function durationMs(start: Date | string | null, end: Date | string | null): string {
73 if (!start) return "";
74 const s = typeof start === "string" ? new Date(start) : start;
75 const e = end ? (typeof end === "string" ? new Date(end) : end) : new Date();
76 const ms = e.getTime() - s.getTime();
77 if (ms < 1000) return `${ms}ms`;
78 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
79 const m = Math.floor(ms / 60_000);
80 const s2 = Math.floor((ms % 60_000) / 1000);
81 return `${m}m ${s2}s`;
82}
83
84function statusColor(status: string, conclusion: string | null): string {
85 if (status === "running") return "var(--yellow, #e3b341)";
86 if (status === "queued") return "var(--text-muted)";
87 if (status === "cancelled") return "var(--text-muted)";
88 const concl = conclusion || status;
89 if (concl === "success") return "var(--green)";
90 if (concl === "failure") return "var(--red)";
91 return "var(--text-muted)";
92}
93
94function statusGlyph(status: string, conclusion: string | null): string {
0c7043cClaude95 if (status === "running") return "◐"; // half-circle
96 if (status === "queued") return "○"; // hollow circle
97 if (status === "cancelled") return "✕"; // x
eafe8c6Claude98 const concl = conclusion || status;
0c7043cClaude99 if (concl === "success") return "✓"; // check
100 if (concl === "failure") return "✗"; // heavy x
101 if (concl === "skipped") return "–"; // en dash
102 return "●";
eafe8c6Claude103}
104
0c7043cClaude105function statusPillClass(status: string, conclusion: string | null): string {
106 if (status === "running") return "wf-pill is-running";
107 if (status === "queued") return "wf-pill is-queued";
108 if (status === "cancelled") return "wf-pill is-cancelled";
109 const concl = conclusion || status;
110 if (concl === "success") return "wf-pill is-success";
111 if (concl === "failure") return "wf-pill is-failure";
112 if (concl === "skipped") return "wf-pill is-skipped";
113 return "wf-pill";
114}
115
116/* ─────────────────────────────────────────────────────────────────────────
117 * Scoped CSS — every class prefixed `.wf-*` so this surface can't bleed
118 * into other pages. Mirrors the gradient hairline + orb language from
119 * admin-integrations and error-page.
120 * ───────────────────────────────────────────────────────────────────── */
121const wfStyles = `
eed4684Claude122 .wf-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
0c7043cClaude123
124 .wf-head {
125 position: relative;
126 margin-bottom: var(--space-5);
127 padding: var(--space-4) var(--space-5);
128 background: var(--bg-elevated);
129 border: 1px solid var(--border);
130 border-radius: 14px;
131 overflow: hidden;
132 }
133 .wf-head::before {
134 content: '';
135 position: absolute;
136 top: 0; left: 0; right: 0;
137 height: 2px;
138 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
139 opacity: 0.7;
140 pointer-events: none;
141 }
142 .wf-head-orb {
143 position: absolute;
144 inset: -30% -10% auto auto;
145 width: 320px; height: 320px;
146 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
147 filter: blur(70px);
148 opacity: 0.7;
149 pointer-events: none;
150 z-index: 0;
151 }
152 .wf-head-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
153 .wf-head-text { flex: 1; min-width: 240px; max-width: 720px; }
154 .wf-eyebrow {
155 display: inline-flex;
156 align-items: center;
157 gap: 8px;
158 font-family: var(--font-mono);
159 font-size: 11px;
160 letter-spacing: 0.14em;
161 text-transform: uppercase;
162 font-weight: 600;
163 color: var(--text-muted);
164 margin-bottom: 10px;
165 }
166 .wf-eyebrow-dot {
167 width: 8px; height: 8px;
168 border-radius: 9999px;
169 background: linear-gradient(135deg, #8c6dff, #36c5d6);
170 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
171 }
172 .wf-title {
173 margin: 0 0 6px;
174 font-family: var(--font-display);
175 font-size: clamp(22px, 2.6vw, 30px);
176 font-weight: 800;
177 letter-spacing: -0.022em;
178 line-height: 1.1;
179 color: var(--text-strong);
180 }
181 .wf-title-grad {
182 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
183 -webkit-background-clip: text;
184 background-clip: text;
185 -webkit-text-fill-color: transparent;
186 color: transparent;
187 }
188 .wf-sub {
189 margin: 0;
190 font-size: 13.5px;
191 line-height: 1.5;
192 color: var(--text-muted);
193 }
194
195 .wf-grid {
196 display: grid;
197 grid-template-columns: 300px 1fr;
198 gap: var(--space-4);
199 }
200 @media (max-width: 820px) {
201 .wf-grid { grid-template-columns: 1fr; }
202 }
203
204 .wf-col-title {
205 margin: 0 0 var(--space-2);
206 font-family: var(--font-mono);
207 font-size: 11px;
208 text-transform: uppercase;
209 letter-spacing: 0.14em;
210 font-weight: 600;
211 color: var(--text-muted);
212 }
213
214 /* ─── workflow item card ─── */
215 .wf-card-list {
216 display: flex;
217 flex-direction: column;
218 gap: var(--space-2);
219 }
220 .wf-card {
221 position: relative;
222 padding: 12px 14px;
223 background: var(--bg-elevated);
224 border: 1px solid var(--border);
225 border-radius: 12px;
226 overflow: hidden;
227 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
228 }
229 .wf-card::before {
230 content: '';
231 position: absolute;
232 top: 0; left: 12px; right: 12px;
233 height: 1px;
234 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
235 opacity: 0;
236 transition: opacity 160ms ease;
237 }
238 .wf-card:hover {
239 transform: translateY(-1px);
240 border-color: rgba(140,109,255,0.32);
241 box-shadow: 0 8px 22px -10px rgba(0,0,0,0.40);
242 }
243 .wf-card:hover::before { opacity: 1; }
244 .wf-card.is-disabled { opacity: 0.55; }
245
246 .wf-card-top {
247 display: flex;
248 align-items: center;
249 justify-content: space-between;
250 gap: 8px;
251 }
252 .wf-card-title {
253 font-family: var(--font-display);
254 font-size: 14px;
255 font-weight: 700;
256 color: var(--text-strong);
257 overflow: hidden;
258 text-overflow: ellipsis;
259 white-space: nowrap;
260 letter-spacing: -0.005em;
261 }
262 .wf-card-meta {
263 margin-top: 4px;
264 font-family: var(--font-mono);
265 font-size: 11.5px;
266 color: var(--text-muted);
267 overflow: hidden;
268 text-overflow: ellipsis;
269 white-space: nowrap;
270 }
271 .wf-card-actions {
272 display: flex;
273 gap: 6px;
274 flex-wrap: wrap;
275 align-items: center;
276 }
277 .wf-card-actions form { margin: 0; }
278
279 /* ─── pills ─── */
280 .wf-pill {
281 display: inline-flex;
282 align-items: center;
283 gap: 5px;
284 padding: 2px 9px;
285 border-radius: 9999px;
286 font-size: 10.5px;
287 font-weight: 600;
288 letter-spacing: 0.05em;
289 text-transform: uppercase;
290 font-family: var(--font-mono);
291 background: rgba(255,255,255,0.04);
292 color: var(--text-muted);
293 box-shadow: inset 0 0 0 1px var(--border);
294 }
295 .wf-pill.is-success {
296 background: rgba(52,211,153,0.10);
297 color: #6ee7b7;
298 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
299 }
300 .wf-pill.is-failure {
301 background: rgba(248,113,113,0.10);
302 color: #fecaca;
303 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.34);
304 }
305 .wf-pill.is-running {
306 background: rgba(251,191,36,0.10);
307 color: #fde68a;
308 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
309 }
310 .wf-pill.is-queued, .wf-pill.is-cancelled, .wf-pill.is-skipped {
311 background: rgba(140,149,167,0.10);
312 color: #b6bcc8;
313 box-shadow: inset 0 0 0 1px rgba(140,149,167,0.30);
314 }
315 .wf-pill-dot {
316 width: 6px; height: 6px;
317 border-radius: 9999px;
318 background: currentColor;
319 }
320
321 /* ─── ghost buttons (page-local; we don't reuse .btn here so the card
322 actions can stay compact) ─── */
323 .wf-btn {
324 display: inline-flex;
325 align-items: center;
326 justify-content: center;
327 padding: 4px 10px;
328 font-size: 12px;
329 font-weight: 600;
330 line-height: 1;
331 color: var(--text);
332 background: transparent;
333 border: 1px solid var(--border-strong, var(--border));
334 border-radius: 7px;
335 cursor: pointer;
336 text-decoration: none;
337 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
338 font-family: inherit;
339 }
340 .wf-btn:hover {
341 background: rgba(140,109,255,0.07);
342 border-color: rgba(140,109,255,0.45);
343 color: var(--text-strong);
344 text-decoration: none;
345 }
346 .wf-btn.is-primary {
347 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
348 color: #fff;
349 border-color: transparent;
350 box-shadow: 0 4px 12px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.14);
351 }
352 .wf-btn.is-primary:hover { transform: translateY(-1px); color: #fff; }
353 .wf-btn.is-danger {
354 color: #fecaca;
355 border-color: rgba(248,113,113,0.35);
356 }
357 .wf-btn.is-danger:hover {
358 background: rgba(248,113,113,0.10);
359 border-color: rgba(248,113,113,0.55);
360 color: #fecaca;
361 }
362
363 /* ─── run list (right column) ─── */
364 .wf-run-list {
365 display: flex;
366 flex-direction: column;
367 gap: var(--space-2);
368 }
369 .wf-run {
370 position: relative;
371 display: flex;
372 gap: 12px;
373 padding: 12px 14px;
374 background: var(--bg-elevated);
375 border: 1px solid var(--border);
376 border-radius: 12px;
377 text-decoration: none;
378 color: inherit;
379 overflow: hidden;
380 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
381 }
382 .wf-run::before {
383 content: '';
384 position: absolute;
385 top: 0; left: 12px; right: 12px;
386 height: 1px;
387 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
388 opacity: 0;
389 transition: opacity 160ms ease;
390 }
391 .wf-run:hover {
392 transform: translateY(-1px);
393 border-color: rgba(140,109,255,0.30);
394 box-shadow: 0 8px 22px -10px rgba(0,0,0,0.40);
395 text-decoration: none;
396 }
397 .wf-run:hover::before { opacity: 1; }
398 .wf-run-glyph {
399 min-width: 18px;
400 text-align: center;
401 font-weight: 700;
402 padding-top: 1px;
403 }
404 .wf-run-body { flex: 1; min-width: 0; }
405 .wf-run-title {
406 font-family: var(--font-display);
407 font-weight: 700;
408 color: var(--text-strong);
409 font-size: 14px;
410 letter-spacing: -0.005em;
411 }
412 .wf-run-title .runnum {
413 font-family: var(--font-mono);
414 color: var(--text-muted);
415 font-weight: 500;
416 margin-left: 4px;
417 }
418 .wf-run-meta {
419 margin-top: 4px;
420 font-size: 12px;
421 color: var(--text-muted);
422 font-variant-numeric: tabular-nums;
423 }
424 .wf-run-meta code {
425 font-family: var(--font-mono);
426 background: rgba(255,255,255,0.04);
427 padding: 1px 5px;
428 border-radius: 4px;
429 font-size: 11px;
430 color: var(--text);
431 }
432 .wf-run-meta .sep { opacity: 0.5; padding: 0 6px; }
433
434 /* ─── empty state — dashed card with orb ─── */
435 .wf-empty {
436 position: relative;
437 padding: var(--space-6) var(--space-5);
438 background: var(--bg-elevated);
439 border: 1px dashed var(--border-strong, var(--border));
440 border-radius: 14px;
441 text-align: center;
442 overflow: hidden;
443 }
444 .wf-empty-orb {
445 position: absolute;
446 inset: auto auto -40% 50%;
447 transform: translateX(-50%);
448 width: 320px; height: 320px;
449 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
450 filter: blur(70px);
451 opacity: 0.7;
452 pointer-events: none;
453 }
454 .wf-empty-inner { position: relative; z-index: 1; max-width: 460px; margin: 0 auto; }
455 .wf-empty-icon {
456 width: 44px; height: 44px;
457 margin: 0 auto var(--space-3);
458 border-radius: 14px;
459 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
460 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
461 display: flex; align-items: center; justify-content: center;
462 color: #b69dff;
463 }
464 .wf-empty-title {
465 font-family: var(--font-display);
466 font-size: 16px;
467 font-weight: 700;
468 color: var(--text-strong);
469 margin: 0 0 6px;
470 letter-spacing: -0.01em;
471 }
472 .wf-empty-body {
473 font-size: 13.5px;
474 color: var(--text-muted);
475 line-height: 1.5;
476 margin: 0 0 var(--space-3);
477 }
478 .wf-empty-body code {
479 font-family: var(--font-mono);
480 font-size: 12px;
481 background: rgba(255,255,255,0.05);
482 padding: 1px 6px;
483 border-radius: 4px;
484 color: var(--text);
485 }
486
487 /* ─── run detail page ─── */
488 .wf-detail-head {
489 position: relative;
490 margin-bottom: var(--space-4);
491 padding: var(--space-4) var(--space-5);
492 background: var(--bg-elevated);
493 border: 1px solid var(--border);
494 border-radius: 14px;
495 overflow: hidden;
496 display: flex;
497 align-items: flex-start;
498 justify-content: space-between;
499 gap: var(--space-3);
500 flex-wrap: wrap;
501 }
502 .wf-detail-head::before {
503 content: '';
504 position: absolute;
505 top: 0; left: 0; right: 0;
506 height: 2px;
507 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
508 opacity: 0.6;
509 pointer-events: none;
510 }
511 .wf-back {
512 font-size: 12px;
513 color: var(--text-muted);
514 margin-bottom: 6px;
515 }
516 .wf-back a { color: var(--text-muted); text-decoration: none; }
517 .wf-back a:hover { color: var(--accent); }
518 .wf-detail-title {
519 margin: 0;
520 font-family: var(--font-display);
521 font-size: clamp(20px, 2.2vw, 26px);
522 font-weight: 800;
523 letter-spacing: -0.018em;
524 color: var(--text-strong);
525 line-height: 1.2;
526 }
527 .wf-detail-meta {
528 margin-top: 8px;
529 font-size: 12.5px;
530 color: var(--text-muted);
531 font-variant-numeric: tabular-nums;
532 }
533 .wf-detail-meta code {
534 font-family: var(--font-mono);
535 background: rgba(255,255,255,0.04);
536 padding: 1px 5px;
537 border-radius: 4px;
538 font-size: 11.5px;
539 color: var(--text);
540 }
541
542 .wf-job {
543 margin-bottom: var(--space-3);
544 background: var(--bg-elevated);
545 border: 1px solid var(--border);
546 border-radius: 12px;
547 overflow: hidden;
548 }
549 .wf-job summary {
550 padding: 10px 14px;
551 cursor: pointer;
552 display: flex;
553 gap: 10px;
554 align-items: center;
555 background: rgba(255,255,255,0.02);
556 list-style: none;
557 }
558 .wf-job summary::-webkit-details-marker { display: none; }
559 .wf-job-name { flex: 1; font-family: var(--font-display); font-weight: 600; color: var(--text-strong); }
560 .wf-job-dur { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
561 .wf-job-steps {
562 padding: 6px 14px 4px;
563 border-top: 1px solid var(--border);
564 }
565 .wf-step {
566 padding: 8px 0;
567 border-bottom: 1px solid var(--border);
568 display: flex;
569 gap: 10px;
570 font-size: 13px;
571 }
572 .wf-step:last-child { border-bottom: 0; }
573 .wf-step-glyph { min-width: 18px; font-weight: 700; }
574 .wf-step-body { flex: 1; min-width: 0; }
575 .wf-step-name { font-weight: 600; color: var(--text); }
576 .wf-step-cmd {
577 display: block;
578 font-family: var(--font-mono);
579 font-size: 11.5px;
580 color: var(--text-muted);
581 margin-top: 2px;
582 overflow: hidden;
583 text-overflow: ellipsis;
584 white-space: nowrap;
585 }
586 .wf-step-dur, .wf-step-exit { font-size: 11px; font-variant-numeric: tabular-nums; }
587 .wf-step-dur { color: var(--text-muted); }
588 .wf-step-exit { color: var(--red, #f87171); }
589 .wf-logs {
590 margin: 0;
591 padding: 12px 14px;
592 background: rgba(0,0,0,0.30);
593 color: var(--text);
594 font-family: var(--font-mono);
595 font-size: 12px;
596 line-height: 1.5;
597 overflow-x: auto;
598 max-height: 480px;
599 border-top: 1px solid var(--border);
600 }
601`;
602
eafe8c6Claude603// ---------- List workflows + recent runs ----------
604
605actions.get("/:owner/:repo/actions", async (c) => {
606 const user = c.get("user");
607 const { owner, repo } = c.req.param();
608 const repoRow = await loadRepo(owner, repo);
609 if (!repoRow) return c.notFound();
610
611 let wfs: (typeof workflows.$inferSelect)[] = [];
612 let runs: (typeof workflowRuns.$inferSelect & { workflowName: string | null })[] =
613 [];
614 try {
615 wfs = await db
616 .select()
617 .from(workflows)
618 .where(eq(workflows.repositoryId, repoRow.id))
619 .orderBy(desc(workflows.updatedAt));
620
621 const joined = await db
622 .select({
623 id: workflowRuns.id,
624 workflowId: workflowRuns.workflowId,
625 repositoryId: workflowRuns.repositoryId,
626 runNumber: workflowRuns.runNumber,
627 event: workflowRuns.event,
628 ref: workflowRuns.ref,
629 commitSha: workflowRuns.commitSha,
630 triggeredBy: workflowRuns.triggeredBy,
631 status: workflowRuns.status,
632 conclusion: workflowRuns.conclusion,
633 queuedAt: workflowRuns.queuedAt,
634 startedAt: workflowRuns.startedAt,
635 finishedAt: workflowRuns.finishedAt,
636 createdAt: workflowRuns.createdAt,
637 workflowName: workflows.name,
638 })
639 .from(workflowRuns)
640 .leftJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
641 .where(eq(workflowRuns.repositoryId, repoRow.id))
642 .orderBy(desc(workflowRuns.queuedAt))
643 .limit(50);
644 runs = joined as typeof runs;
645 } catch (err) {
646 console.error("[actions] list:", err);
647 }
648
649 const unread = user ? await getUnreadCount(user.id) : 0;
650 const canRun = !!user && user.id === repoRow.ownerId;
651
652 return c.html(
653 <Layout
654 title={`Actions — ${owner}/${repo}`}
655 user={user}
656 notificationCount={unread}
657 >
658 <RepoHeader
659 owner={owner}
660 repo={repo}
661 starCount={repoRow.starCount}
662 forkCount={repoRow.forkCount}
663 currentUser={user?.username || null}
664 />
665 <RepoNav owner={owner} repo={repo} active="actions" />
666
0c7043cClaude667 <div class="wf-wrap">
668 <section class="wf-head">
669 <div class="wf-head-orb" aria-hidden="true" />
670 <div class="wf-head-inner">
671 <div class="wf-head-text">
672 <div class="wf-eyebrow">
673 <span class="wf-eyebrow-dot" aria-hidden="true" />
674 Continuous integration · {owner}/{repo}
675 </div>
676 <h2 class="wf-title">
677 <span class="wf-title-grad">Workflows.</span>
678 </h2>
679 <p class="wf-sub">
680 YAML pipelines that run on push, on a schedule, or on demand —
681 with live logs and one-click cancel.
682 </p>
eafe8c6Claude683 </div>
0c7043cClaude684 </div>
685 </section>
686
687 <div class="wf-grid">
688 <aside>
689 <h4 class="wf-col-title">Workflows</h4>
690 {wfs.length === 0 ? (
691 <div class="wf-empty">
692 <div class="wf-empty-orb" aria-hidden="true" />
693 <div class="wf-empty-inner">
694 <div class="wf-empty-icon" aria-hidden="true">
695 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
696 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
697 <polyline points="14 2 14 8 20 8" />
698 <path d="M9 13l2 2 4-4" />
699 </svg>
700 </div>
701 <h3 class="wf-empty-title">Add your first workflow</h3>
702 <p class="wf-empty-body">
703 Drop a YAML file under{" "}
704 <code>.gluecron/workflows/</code> on your default branch
705 and push — it will appear here automatically.
706 </p>
707 </div>
708 </div>
709 ) : (
710 <div class="wf-card-list">
711 {wfs.map((w) => (
712 <div class={`wf-card${w.disabled ? " is-disabled" : ""}`}>
713 <div class="wf-card-top">
714 <div style="flex:1;min-width:0">
715 <div class="wf-card-title" title={w.name}>{w.name}</div>
716 <div class="wf-card-meta" title={w.path}>{w.path}</div>
eafe8c6Claude717 </div>
0c7043cClaude718 <div class="wf-card-actions">
719 <span
720 class={w.disabled ? "wf-pill is-cancelled" : "wf-pill is-success"}
721 title={w.disabled ? "Disabled" : "Active"}
722 >
723 <span class="wf-pill-dot" aria-hidden="true" />
724 {w.disabled ? "Disabled" : "Active"}
725 </span>
726 {canRun && !w.disabled && (
727 <form
728 method="post"
729 action={`/${owner}/${repo}/actions/${w.id}/run`}
730 style="margin:0"
731 >
732 <button
733 type="submit"
734 class="wf-btn is-primary"
735 title="Trigger manual run"
736 >
737 Run
738 </button>
739 </form>
740 )}
eafe8c6Claude741 </div>
742 </div>
743 </div>
0c7043cClaude744 ))}
745 </div>
746 )}
747 </aside>
748
749 <section>
750 <h4 class="wf-col-title">Recent runs</h4>
751 {runs.length === 0 ? (
752 <div class="wf-empty">
753 <div class="wf-empty-orb" aria-hidden="true" />
754 <div class="wf-empty-inner">
755 <div class="wf-empty-icon" aria-hidden="true">
756 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
757 <polygon points="5 3 19 12 5 21 5 3" />
758 </svg>
759 </div>
760 <h3 class="wf-empty-title">No runs yet</h3>
761 <p class="wf-empty-body">
762 Push a commit on a trigger branch, or use the{" "}
763 <strong>Run</strong> button next to a workflow to start one
764 manually.
765 </p>
eafe8c6Claude766 </div>
0c7043cClaude767 </div>
768 ) : (
769 <div class="wf-run-list">
770 {runs.map((r) => (
771 <a
772 href={`/${owner}/${repo}/actions/runs/${r.id}`}
773 class="wf-run"
eafe8c6Claude774 >
0c7043cClaude775 <span
776 class="wf-run-glyph"
777 style={`color: ${statusColor(r.status, r.conclusion)}`}
778 title={r.conclusion || r.status}
779 >
780 {statusGlyph(r.status, r.conclusion)}
781 </span>
782 <div class="wf-run-body">
783 <div class="wf-run-title">
784 {r.workflowName || "(workflow deleted)"}
785 <span class="runnum">#{r.runNumber}</span>
786 {" "}
787 <span
788 class={statusPillClass(r.status, r.conclusion)}
789 style="margin-left:6px;vertical-align:1px"
790 >
791 <span class="wf-pill-dot" aria-hidden="true" />
792 {r.conclusion || r.status}
793 </span>
794 </div>
795 <div class="wf-run-meta">
796 <span>{r.event}</span>
797 {r.ref && (
798 <>
799 <span class="sep">·</span>
800 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
801 </>
802 )}
803 {r.commitSha && (
804 <>
805 <span class="sep">·</span>
806 <code>{r.commitSha.slice(0, 7)}</code>
807 </>
808 )}
809 <span class="sep">·</span>
810 <span title={r.queuedAt ? new Date(r.queuedAt).toISOString() : ""}>
811 {relTime(r.queuedAt)}
812 </span>
813 {r.startedAt && r.finishedAt && (
814 <>
815 <span class="sep">·</span>
816 <span>{durationMs(r.startedAt, r.finishedAt)}</span>
817 </>
818 )}
819 </div>
eafe8c6Claude820 </div>
0c7043cClaude821 </a>
822 ))}
823 </div>
824 )}
825 </section>
826 </div>
eafe8c6Claude827 </div>
0c7043cClaude828
829 <style dangerouslySetInnerHTML={{ __html: wfStyles }} />
eafe8c6Claude830 </Layout>
831 );
832});
833
834// ---------- Run detail ----------
835
836actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
837 const user = c.get("user");
838 const { owner, repo, runId } = c.req.param();
839 const repoRow = await loadRepo(owner, repo);
840 if (!repoRow) return c.notFound();
841
842 let run: typeof workflowRuns.$inferSelect | null = null;
843 let workflowRow: typeof workflows.$inferSelect | null = null;
844 let jobs: (typeof workflowJobs.$inferSelect)[] = [];
845 try {
846 const [r] = await db
847 .select()
848 .from(workflowRuns)
849 .where(
850 and(
851 eq(workflowRuns.id, runId),
852 eq(workflowRuns.repositoryId, repoRow.id)
853 )
854 )
855 .limit(1);
856 run = r || null;
857 if (run) {
858 const [w] = await db
859 .select()
860 .from(workflows)
861 .where(eq(workflows.id, run.workflowId))
862 .limit(1);
863 workflowRow = w || null;
864 jobs = await db
865 .select()
866 .from(workflowJobs)
867 .where(eq(workflowJobs.runId, run.id))
868 .orderBy(workflowJobs.jobOrder);
869 }
870 } catch (err) {
871 console.error("[actions] run detail:", err);
872 }
873
874 if (!run) return c.notFound();
875
876 const unread = user ? await getUnreadCount(user.id) : 0;
877 const canCancel =
878 !!user &&
879 user.id === repoRow.ownerId &&
880 (run.status === "queued" || run.status === "running");
881
882 return c.html(
883 <Layout
884 title={`Run #${run.runNumber} — ${owner}/${repo}`}
885 user={user}
886 notificationCount={unread}
887 >
888 <RepoHeader
889 owner={owner}
890 repo={repo}
891 starCount={repoRow.starCount}
892 forkCount={repoRow.forkCount}
893 currentUser={user?.username || null}
894 />
895 <RepoNav owner={owner} repo={repo} active="actions" />
896
0c7043cClaude897 <div class="wf-wrap">
898 <section class="wf-detail-head">
899 <div style="flex:1;min-width:0">
900 <div class="wf-back">
901 <a href={`/${owner}/${repo}/actions`}>← Workflows</a>
902 </div>
903 <h3 class="wf-detail-title">
904 <span
905 style={`color: ${statusColor(run.status, run.conclusion)}; margin-right: 8px`}
906 >
907 {statusGlyph(run.status, run.conclusion)}
908 </span>
909 {workflowRow?.name || "(deleted workflow)"}
910 <span style="color:var(--text-muted);font-weight:500;margin-left:6px;font-family:var(--font-mono)">
911 #{run.runNumber}
912 </span>
913 {" "}
914 <span
915 class={statusPillClass(run.status, run.conclusion)}
916 style="vertical-align:3px;margin-left:6px"
917 >
918 <span class="wf-pill-dot" aria-hidden="true" />
919 {run.conclusion || run.status}
920 </span>
921 </h3>
922 <div class="wf-detail-meta">
923 <span>{run.event}</span>
924 {run.ref && (
925 <>
926 <span style="opacity:0.5;padding:0 6px">·</span>
927 <span>{run.ref.replace(/^refs\/heads\//, "")}</span>
928 </>
929 )}
930 {run.commitSha && (
931 <>
932 <span style="opacity:0.5;padding:0 6px">·</span>
933 <a href={`/${owner}/${repo}/commit/${run.commitSha}`}>
934 <code>{run.commitSha.slice(0, 7)}</code>
935 </a>
936 </>
937 )}
938 <span style="opacity:0.5;padding:0 6px">·</span>
939 <span title={run.queuedAt ? new Date(run.queuedAt).toISOString() : ""}>
940 queued {relTime(run.queuedAt)}
941 </span>
942 {run.startedAt && run.finishedAt && (
943 <>
944 <span style="opacity:0.5;padding:0 6px">·</span>
945 <span>duration {durationMs(run.startedAt, run.finishedAt)}</span>
946 </>
947 )}
948 </div>
eafe8c6Claude949 </div>
0c7043cClaude950 {canCancel && (
951 <form
952 method="post"
953 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
954 onsubmit="return confirm('Cancel this run?')"
eafe8c6Claude955 >
0c7043cClaude956 <button type="submit" class="wf-btn is-danger">
957 Cancel run
958 </button>
959 </form>
960 )}
961 </section>
eafe8c6Claude962
0c7043cClaude963 {jobs.length === 0 ? (
964 <div class="wf-empty">
965 <div class="wf-empty-orb" aria-hidden="true" />
966 <div class="wf-empty-inner">
967 <div class="wf-empty-icon" aria-hidden="true">
968 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
969 <circle cx="12" cy="12" r="10" />
970 <polyline points="12 6 12 12 16 14" />
971 </svg>
972 </div>
973 <h3 class="wf-empty-title">
974 {run.status === "queued" ? "Waiting for a runner" : "No jobs recorded"}
975 </h3>
976 <p class="wf-empty-body">
977 {run.status === "queued"
978 ? "Jobs will appear here the moment the runner picks this up."
979 : "This run produced no job records — check the workflow definition."}
980 </p>
981 </div>
982 </div>
983 ) : (
984 <div>
985 {jobs.map((j) => {
986 let steps: Array<{
987 name?: string;
988 run?: string;
989 status?: string;
990 exitCode?: number | null;
991 durationMs?: number;
992 stdout?: string;
993 stderr?: string;
994 }> = [];
995 try {
996 steps = JSON.parse(j.steps || "[]");
997 } catch {
998 steps = [];
999 }
1000 return (
1001 <details class="wf-job" open>
1002 <summary>
1003 <span
1004 style={`color: ${statusColor(j.status, j.conclusion)}; font-weight: 700`}
1005 >
1006 {statusGlyph(j.status, j.conclusion)}
1007 </span>
1008 <span class="wf-job-name">{j.name}</span>
1009 <span
1010 class={statusPillClass(j.status, j.conclusion)}
1011 >
1012 <span class="wf-pill-dot" aria-hidden="true" />
1013 {j.conclusion || j.status}
1014 </span>
1015 <span class="wf-job-dur">
1016 {j.startedAt && j.finishedAt
1017 ? durationMs(j.startedAt, j.finishedAt)
1018 : ""}
1019 </span>
1020 </summary>
1021 {steps.length > 0 && (
1022 <div class="wf-job-steps">
1023 {steps.map((s, i) => (
1024 <div class="wf-step">
1025 <span
1026 class="wf-step-glyph"
1027 style={`color: ${statusColor(s.status || "", null)}`}
1028 >
1029 {statusGlyph(s.status || "", null)}
1030 </span>
1031 <div class="wf-step-body">
1032 <div class="wf-step-name">
1033 {s.name || `Step ${i + 1}`}
1034 </div>
1035 {s.run && (
1036 <code class="wf-step-cmd">$ {s.run}</code>
1037 )}
eafe8c6Claude1038 </div>
0c7043cClaude1039 {typeof s.durationMs === "number" && (
1040 <span class="wf-step-dur">
1041 {s.durationMs < 1000
1042 ? `${s.durationMs}ms`
1043 : `${(s.durationMs / 1000).toFixed(1)}s`}
1044 </span>
1045 )}
1046 {typeof s.exitCode === "number" && s.exitCode !== 0 && (
1047 <span class="wf-step-exit">exit {s.exitCode}</span>
eafe8c6Claude1048 )}
1049 </div>
0c7043cClaude1050 ))}
1051 </div>
1052 )}
1053 {(() => {
1054 const runLive =
1055 run.status === "running" || run.status === "queued";
1056 const jobTerminal =
1057 j.status === "success" ||
1058 j.status === "failure" ||
1059 j.status === "cancelled" ||
1060 j.status === "skipped" ||
1061 j.conclusion === "success" ||
1062 j.conclusion === "failure" ||
1063 j.conclusion === "cancelled" ||
1064 j.conclusion === "skipped";
1065 if (runLive && !jobTerminal) {
1066 return (
1067 <LogTail
1068 runId={run.id}
1069 jobId={j.id}
1070 fallbackLogs={j.logs || ""}
1071 />
1072 );
1073 }
1074 if (j.logs && j.logs.length > 0) {
1075 return <pre class="wf-logs">{j.logs}</pre>;
1076 }
1077 return null;
1078 })()}
1079 </details>
1080 );
1081 })}
1082 </div>
1083 )}
1084 </div>
1085
1086 <style dangerouslySetInnerHTML={{ __html: wfStyles }} />
eafe8c6Claude1087 </Layout>
1088 );
1089});
1090
1091// ---------- Manual trigger ----------
1092
1093actions.post("/:owner/:repo/actions/:workflowId/run", requireAuth, async (c) => {
1094 const user = c.get("user")!;
1095 const { owner, repo, workflowId } = c.req.param();
1096 const repoRow = await loadRepo(owner, repo);
1097 if (!repoRow) return c.notFound();
1098 if (repoRow.ownerId !== user.id) {
1099 return c.redirect(`/${owner}/${repo}/actions`);
1100 }
1101
1102 let workflowRow: typeof workflows.$inferSelect | null = null;
1103 try {
1104 const [w] = await db
1105 .select()
1106 .from(workflows)
1107 .where(
1108 and(
1109 eq(workflows.id, workflowId),
1110 eq(workflows.repositoryId, repoRow.id)
1111 )
1112 )
1113 .limit(1);
1114 workflowRow = w || null;
1115 } catch (err) {
1116 console.error("[actions] manual trigger lookup:", err);
1117 }
1118 if (!workflowRow) return c.notFound();
1119 if (workflowRow.disabled) {
1120 return c.redirect(`/${owner}/${repo}/actions`);
1121 }
1122
1123 const ref = `refs/heads/${repoRow.defaultBranch || "main"}`;
1124
1125 const runId = await enqueueRun({
1126 workflowId: workflowRow.id,
1127 repositoryId: repoRow.id,
1128 event: "manual",
1129 ref,
1130 commitSha: null,
1131 triggeredBy: user.id,
1132 });
1133
1134 await audit({
1135 userId: user.id,
1136 repositoryId: repoRow.id,
1137 action: "workflow.manual_trigger",
1138 targetType: "workflow",
1139 targetId: workflowRow.id,
1140 metadata: { runId },
1141 });
1142
1143 if (runId) {
1144 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
1145 }
1146 return c.redirect(`/${owner}/${repo}/actions`);
1147});
1148
1149// ---------- Cancel a run ----------
1150
1151actions.post(
1152 "/:owner/:repo/actions/runs/:runId/cancel",
1153 requireAuth,
1154 async (c) => {
1155 const user = c.get("user")!;
1156 const { owner, repo, runId } = c.req.param();
1157 const repoRow = await loadRepo(owner, repo);
1158 if (!repoRow) return c.notFound();
1159 if (repoRow.ownerId !== user.id) {
1160 return c.redirect(`/${owner}/${repo}/actions`);
1161 }
1162
1163 try {
1164 await db
1165 .update(workflowRuns)
1166 .set({
1167 status: "cancelled",
1168 conclusion: "cancelled",
1169 finishedAt: new Date(),
1170 })
1171 .where(
1172 and(
1173 eq(workflowRuns.id, runId),
1174 eq(workflowRuns.repositoryId, repoRow.id)
1175 )
1176 );
1177 // Mark any queued/running jobs as cancelled for display. The worker
1178 // will observe the parent run's status on its next check, but v1 runs
1179 // a step to completion before checking.
1180 await db
1181 .update(workflowJobs)
1182 .set({
1183 status: "cancelled",
1184 conclusion: "cancelled",
1185 finishedAt: new Date(),
1186 })
1187 .where(eq(workflowJobs.runId, runId));
1188 } catch (err) {
1189 console.error("[actions] cancel:", err);
1190 }
1191
1192 await audit({
1193 userId: user.id,
1194 repositoryId: repoRow.id,
1195 action: "workflow.cancel",
1196 targetType: "workflow_run",
1197 targetId: runId,
1198 });
1199
1200 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
1201 }
1202);
1203
1204export default actions;