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

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.tsxBlame600 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).
13 */
14
15import { Hono } from "hono";
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 repositories,
20 users,
21 workflowJobs,
22 workflowRuns,
23 workflows,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { getUnreadCount } from "../lib/unread";
30import { audit } from "../lib/notify";
31import { enqueueRun } from "../lib/workflow-runner";
32
33const actions = new Hono<AuthEnv>();
34actions.use("*", softAuth);
35
36async function loadRepo(owner: string, repo: string) {
37 const [row] = await db
38 .select({
39 id: repositories.id,
40 name: repositories.name,
41 defaultBranch: repositories.defaultBranch,
42 ownerId: repositories.ownerId,
43 starCount: repositories.starCount,
44 forkCount: repositories.forkCount,
45 })
46 .from(repositories)
47 .innerJoin(users, eq(repositories.ownerId, users.id))
48 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
49 .limit(1);
50 return row;
51}
52
53function relTime(d: Date | string | null): string {
54 if (!d) return "—";
55 const t = typeof d === "string" ? new Date(d) : d;
56 const diffMs = Date.now() - t.getTime();
57 const mins = Math.floor(diffMs / 60000);
58 if (mins < 1) return "just now";
59 if (mins < 60) return `${mins}m ago`;
60 const hrs = Math.floor(mins / 60);
61 if (hrs < 24) return `${hrs}h ago`;
62 const days = Math.floor(hrs / 24);
63 if (days < 30) return `${days}d ago`;
64 return t.toLocaleDateString();
65}
66
67function durationMs(start: Date | string | null, end: Date | string | null): string {
68 if (!start) return "";
69 const s = typeof start === "string" ? new Date(start) : start;
70 const e = end ? (typeof end === "string" ? new Date(end) : end) : new Date();
71 const ms = e.getTime() - s.getTime();
72 if (ms < 1000) return `${ms}ms`;
73 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
74 const m = Math.floor(ms / 60_000);
75 const s2 = Math.floor((ms % 60_000) / 1000);
76 return `${m}m ${s2}s`;
77}
78
79function statusColor(status: string, conclusion: string | null): string {
80 if (status === "running") return "var(--yellow, #e3b341)";
81 if (status === "queued") return "var(--text-muted)";
82 if (status === "cancelled") return "var(--text-muted)";
83 const concl = conclusion || status;
84 if (concl === "success") return "var(--green)";
85 if (concl === "failure") return "var(--red)";
86 return "var(--text-muted)";
87}
88
89function statusGlyph(status: string, conclusion: string | null): string {
90 if (status === "running") return "\u25D0"; // half-circle
91 if (status === "queued") return "\u25CB"; // hollow circle
92 if (status === "cancelled") return "\u2715"; // x
93 const concl = conclusion || status;
94 if (concl === "success") return "\u2713"; // check
95 if (concl === "failure") return "\u2717"; // heavy x
96 if (concl === "skipped") return "\u2013"; // en dash
97 return "\u25CF";
98}
99
100// ---------- List workflows + recent runs ----------
101
102actions.get("/:owner/:repo/actions", async (c) => {
103 const user = c.get("user");
104 const { owner, repo } = c.req.param();
105 const repoRow = await loadRepo(owner, repo);
106 if (!repoRow) return c.notFound();
107
108 let wfs: (typeof workflows.$inferSelect)[] = [];
109 let runs: (typeof workflowRuns.$inferSelect & { workflowName: string | null })[] =
110 [];
111 try {
112 wfs = await db
113 .select()
114 .from(workflows)
115 .where(eq(workflows.repositoryId, repoRow.id))
116 .orderBy(desc(workflows.updatedAt));
117
118 const joined = await db
119 .select({
120 id: workflowRuns.id,
121 workflowId: workflowRuns.workflowId,
122 repositoryId: workflowRuns.repositoryId,
123 runNumber: workflowRuns.runNumber,
124 event: workflowRuns.event,
125 ref: workflowRuns.ref,
126 commitSha: workflowRuns.commitSha,
127 triggeredBy: workflowRuns.triggeredBy,
128 status: workflowRuns.status,
129 conclusion: workflowRuns.conclusion,
130 queuedAt: workflowRuns.queuedAt,
131 startedAt: workflowRuns.startedAt,
132 finishedAt: workflowRuns.finishedAt,
133 createdAt: workflowRuns.createdAt,
134 workflowName: workflows.name,
135 })
136 .from(workflowRuns)
137 .leftJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
138 .where(eq(workflowRuns.repositoryId, repoRow.id))
139 .orderBy(desc(workflowRuns.queuedAt))
140 .limit(50);
141 runs = joined as typeof runs;
142 } catch (err) {
143 console.error("[actions] list:", err);
144 }
145
146 const unread = user ? await getUnreadCount(user.id) : 0;
147 const canRun = !!user && user.id === repoRow.ownerId;
148
149 return c.html(
150 <Layout
151 title={`Actions — ${owner}/${repo}`}
152 user={user}
153 notificationCount={unread}
154 >
155 <RepoHeader
156 owner={owner}
157 repo={repo}
158 starCount={repoRow.starCount}
159 forkCount={repoRow.forkCount}
160 currentUser={user?.username || null}
161 />
162 <RepoNav owner={owner} repo={repo} active="actions" />
163
164 <div style="display: grid; grid-template-columns: 280px 1fr; gap: 20px">
165 <aside>
166 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
167 Workflows
168 </h4>
169 {wfs.length === 0 ? (
170 <div class="panel" style="padding: 12px; font-size: 12px; color: var(--text-muted)">
171 No workflows yet. Add a YAML file under
172 {" "}
173 <code>.gluecron/workflows/</code> on your default branch.
174 </div>
175 ) : (
176 <div class="panel" style="overflow: hidden">
177 {wfs.map((w) => (
178 <div
179 style={`padding: 10px 12px; border-bottom: 1px solid var(--border); ${w.disabled ? "opacity: 0.5" : ""}`}
180 >
181 <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px">
182 <div style="flex: 1; min-width: 0">
183 <div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
184 {w.name}
185 </div>
186 <div style="font-size: 11px; color: var(--text-muted); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
187 {w.path}
188 </div>
189 </div>
190 {canRun && !w.disabled && (
191 <form
001af43Claude192 method="post"
eafe8c6Claude193 action={`/${owner}/${repo}/actions/${w.id}/run`}
194 style="margin: 0"
195 >
196 <button type="submit" class="btn btn-sm" title="Trigger manual run">
197 Run
198 </button>
199 </form>
200 )}
201 </div>
202 </div>
203 ))}
204 </div>
205 )}
206 </aside>
207
208 <section>
209 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
210 Recent runs
211 </h4>
212 {runs.length === 0 ? (
213 <div class="empty-state">
214 <p>No workflow runs yet. Push a commit or trigger one manually.</p>
215 </div>
216 ) : (
217 <div class="panel" style="overflow: hidden">
218 {runs.map((r) => (
219 <a
220 href={`/${owner}/${repo}/actions/runs/${r.id}`}
221 style="display: flex; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
222 >
223 <span
224 style={`display: inline-block; min-width: 18px; text-align: center; color: ${statusColor(r.status, r.conclusion)}; font-weight: 700`}
225 title={r.conclusion || r.status}
226 >
227 {statusGlyph(r.status, r.conclusion)}
228 </span>
229 <div style="flex: 1; min-width: 0">
230 <div style="font-weight: 500">
231 {r.workflowName || "(workflow deleted)"}
232 {" "}
233 <span style="color: var(--text-muted); font-weight: 400">
234 #{r.runNumber}
235 </span>
236 </div>
237 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
238 <span>{r.event}</span>
239 {r.ref && (
240 <>
241 {" · "}
242 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
243 </>
244 )}
245 {r.commitSha && (
246 <>
247 {" · "}
248 <code>{r.commitSha.slice(0, 7)}</code>
249 </>
250 )}
251 {" · "}
252 <span>{relTime(r.queuedAt)}</span>
253 {r.startedAt && r.finishedAt && (
254 <>
255 {" · "}
256 <span>{durationMs(r.startedAt, r.finishedAt)}</span>
257 </>
258 )}
259 </div>
260 </div>
261 </a>
262 ))}
263 </div>
264 )}
265 </section>
266 </div>
267 </Layout>
268 );
269});
270
271// ---------- Run detail ----------
272
273actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
274 const user = c.get("user");
275 const { owner, repo, runId } = c.req.param();
276 const repoRow = await loadRepo(owner, repo);
277 if (!repoRow) return c.notFound();
278
279 let run: typeof workflowRuns.$inferSelect | null = null;
280 let workflowRow: typeof workflows.$inferSelect | null = null;
281 let jobs: (typeof workflowJobs.$inferSelect)[] = [];
282 try {
283 const [r] = await db
284 .select()
285 .from(workflowRuns)
286 .where(
287 and(
288 eq(workflowRuns.id, runId),
289 eq(workflowRuns.repositoryId, repoRow.id)
290 )
291 )
292 .limit(1);
293 run = r || null;
294 if (run) {
295 const [w] = await db
296 .select()
297 .from(workflows)
298 .where(eq(workflows.id, run.workflowId))
299 .limit(1);
300 workflowRow = w || null;
301 jobs = await db
302 .select()
303 .from(workflowJobs)
304 .where(eq(workflowJobs.runId, run.id))
305 .orderBy(workflowJobs.jobOrder);
306 }
307 } catch (err) {
308 console.error("[actions] run detail:", err);
309 }
310
311 if (!run) return c.notFound();
312
313 const unread = user ? await getUnreadCount(user.id) : 0;
314 const canCancel =
315 !!user &&
316 user.id === repoRow.ownerId &&
317 (run.status === "queued" || run.status === "running");
318
319 return c.html(
320 <Layout
321 title={`Run #${run.runNumber} — ${owner}/${repo}`}
322 user={user}
323 notificationCount={unread}
324 >
325 <RepoHeader
326 owner={owner}
327 repo={repo}
328 starCount={repoRow.starCount}
329 forkCount={repoRow.forkCount}
330 currentUser={user?.username || null}
331 />
332 <RepoNav owner={owner} repo={repo} active="actions" />
333
334 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; gap: 12px">
335 <div>
336 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
337 <a href={`/${owner}/${repo}/actions`}>Actions</a>
338 </div>
339 <h3 style="margin: 0">
340 <span
341 style={`color: ${statusColor(run.status, run.conclusion)}; margin-right: 6px`}
342 >
343 {statusGlyph(run.status, run.conclusion)}
344 </span>
345 {workflowRow?.name || "(deleted workflow)"}
346 {" "}
347 <span style="color: var(--text-muted); font-weight: 400">
348 #{run.runNumber}
349 </span>
350 </h3>
351 <div style="font-size: 12px; color: var(--text-muted); margin-top: 6px">
352 <span>{run.event}</span>
353 {run.ref && (
354 <>
355 {" · "}
356 <span>{run.ref.replace(/^refs\/heads\//, "")}</span>
357 </>
358 )}
359 {run.commitSha && (
360 <>
361 {" · "}
362 <a href={`/${owner}/${repo}/commit/${run.commitSha}`}>
363 <code>{run.commitSha.slice(0, 7)}</code>
364 </a>
365 </>
366 )}
367 {" · queued "}
368 <span>{relTime(run.queuedAt)}</span>
369 {run.startedAt && run.finishedAt && (
370 <>
371 {" · duration "}
372 <span>{durationMs(run.startedAt, run.finishedAt)}</span>
373 </>
374 )}
375 </div>
376 </div>
377 {canCancel && (
378 <form
001af43Claude379 method="post"
eafe8c6Claude380 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
381 onsubmit="return confirm('Cancel this run?')"
382 >
383 <button type="submit" class="btn btn-sm btn-danger">
384 Cancel
385 </button>
386 </form>
387 )}
388 </div>
389
390 {jobs.length === 0 ? (
391 <div class="empty-state">
392 <p>
393 {run.status === "queued"
394 ? "Queued — jobs will appear once the runner picks this up."
395 : "No jobs recorded for this run."}
396 </p>
397 </div>
398 ) : (
399 <div>
400 {jobs.map((j) => {
401 let steps: Array<{
402 name?: string;
403 run?: string;
404 status?: string;
405 exitCode?: number | null;
406 durationMs?: number;
407 stdout?: string;
408 stderr?: string;
409 }> = [];
410 try {
411 steps = JSON.parse(j.steps || "[]");
412 } catch {
413 steps = [];
414 }
415 return (
416 <details class="panel" style="margin-bottom: 16px; overflow: hidden" open>
417 <summary
418 style="padding: 10px 14px; cursor: pointer; display: flex; gap: 10px; align-items: center; background: var(--bg-tertiary)"
419 >
420 <span
421 style={`color: ${statusColor(j.status, j.conclusion)}; font-weight: 700`}
422 >
423 {statusGlyph(j.status, j.conclusion)}
424 </span>
425 <span style="flex: 1; font-weight: 500">{j.name}</span>
426 <span style="font-size: 12px; color: var(--text-muted)">
427 {j.startedAt && j.finishedAt
428 ? durationMs(j.startedAt, j.finishedAt)
429 : j.status}
430 </span>
431 </summary>
432 {steps.length > 0 && (
433 <div style="padding: 8px 14px; border-top: 1px solid var(--border)">
434 {steps.map((s, i) => (
435 <div
436 style="padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; gap: 10px; font-size: 13px"
437 >
438 <span
439 style={`color: ${statusColor(s.status || "", null)}; font-weight: 700; min-width: 18px`}
440 >
441 {statusGlyph(s.status || "", null)}
442 </span>
443 <div style="flex: 1; min-width: 0">
444 <div style="font-weight: 500">
445 {s.name || `Step ${i + 1}`}
446 </div>
447 {s.run && (
448 <code
449 style="display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
450 >
451 $ {s.run}
452 </code>
453 )}
454 </div>
455 {typeof s.durationMs === "number" && (
456 <span style="font-size: 11px; color: var(--text-muted)">
457 {s.durationMs < 1000
458 ? `${s.durationMs}ms`
459 : `${(s.durationMs / 1000).toFixed(1)}s`}
460 </span>
461 )}
462 {typeof s.exitCode === "number" && s.exitCode !== 0 && (
463 <span style="font-size: 11px; color: var(--red)">
464 exit {s.exitCode}
465 </span>
466 )}
467 </div>
468 ))}
469 </div>
470 )}
471 {j.logs && j.logs.length > 0 && (
472 <pre
473 style="margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
474 >
475 {j.logs}
476 </pre>
477 )}
478 </details>
479 );
480 })}
481 </div>
482 )}
483 </Layout>
484 );
485});
486
487// ---------- Manual trigger ----------
488
489actions.post("/:owner/:repo/actions/:workflowId/run", requireAuth, async (c) => {
490 const user = c.get("user")!;
491 const { owner, repo, workflowId } = c.req.param();
492 const repoRow = await loadRepo(owner, repo);
493 if (!repoRow) return c.notFound();
494 if (repoRow.ownerId !== user.id) {
495 return c.redirect(`/${owner}/${repo}/actions`);
496 }
497
498 let workflowRow: typeof workflows.$inferSelect | null = null;
499 try {
500 const [w] = await db
501 .select()
502 .from(workflows)
503 .where(
504 and(
505 eq(workflows.id, workflowId),
506 eq(workflows.repositoryId, repoRow.id)
507 )
508 )
509 .limit(1);
510 workflowRow = w || null;
511 } catch (err) {
512 console.error("[actions] manual trigger lookup:", err);
513 }
514 if (!workflowRow) return c.notFound();
515 if (workflowRow.disabled) {
516 return c.redirect(`/${owner}/${repo}/actions`);
517 }
518
519 const ref = `refs/heads/${repoRow.defaultBranch || "main"}`;
520
521 const runId = await enqueueRun({
522 workflowId: workflowRow.id,
523 repositoryId: repoRow.id,
524 event: "manual",
525 ref,
526 commitSha: null,
527 triggeredBy: user.id,
528 });
529
530 await audit({
531 userId: user.id,
532 repositoryId: repoRow.id,
533 action: "workflow.manual_trigger",
534 targetType: "workflow",
535 targetId: workflowRow.id,
536 metadata: { runId },
537 });
538
539 if (runId) {
540 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
541 }
542 return c.redirect(`/${owner}/${repo}/actions`);
543});
544
545// ---------- Cancel a run ----------
546
547actions.post(
548 "/:owner/:repo/actions/runs/:runId/cancel",
549 requireAuth,
550 async (c) => {
551 const user = c.get("user")!;
552 const { owner, repo, runId } = c.req.param();
553 const repoRow = await loadRepo(owner, repo);
554 if (!repoRow) return c.notFound();
555 if (repoRow.ownerId !== user.id) {
556 return c.redirect(`/${owner}/${repo}/actions`);
557 }
558
559 try {
560 await db
561 .update(workflowRuns)
562 .set({
563 status: "cancelled",
564 conclusion: "cancelled",
565 finishedAt: new Date(),
566 })
567 .where(
568 and(
569 eq(workflowRuns.id, runId),
570 eq(workflowRuns.repositoryId, repoRow.id)
571 )
572 );
573 // Mark any queued/running jobs as cancelled for display. The worker
574 // will observe the parent run's status on its next check, but v1 runs
575 // a step to completion before checking.
576 await db
577 .update(workflowJobs)
578 .set({
579 status: "cancelled",
580 conclusion: "cancelled",
581 finishedAt: new Date(),
582 })
583 .where(eq(workflowJobs.runId, runId));
584 } catch (err) {
585 console.error("[actions] cancel:", err);
586 }
587
588 await audit({
589 userId: user.id,
590 repositoryId: repoRow.id,
591 action: "workflow.cancel",
592 targetType: "workflow_run",
593 targetId: runId,
594 });
595
596 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
597 }
598);
599
600export default actions;