Skip to content

g1t/apps/web/app/routes/repo/actions-run.tsx

353 lines15,732 bytesCodeBlameRaw
1import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, Trash2, XCircle } from "lucide-react";
2import { type ReactNode } from "react";
3import { Form, Link, useLoaderData, useSearchParams } from "react-router";
4
5import type { Annotation, Job, StepState } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions-run";
8import { page } from "../../lib/meta";
9import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
10import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
11import { Hint } from "../../components/ui/hint";
12import { listArtifacts } from "../../lib/artifacts.server";
13import { expiresIn, formatBytes } from "../../lib/artifacts";
14import { actions } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
16import { accessTo, refusal } from "../../lib/access.server";
17import { useRefreshWhile } from "../../lib/refresh";
18
19export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
20 const run = loaderData?.detail.run;
21 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
22}
23
24export async function loader({ params, context }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 const repo = { namespace: params.owner, name: params.repo };
27 const [detail, artifacts] = await Promise.all([
28 actions.run(repo, viewer, params.id).then(unwrap),
29 listArtifacts(repo, viewer, params.id).catch(() => []),
30 ]);
31 // Cancelling and re-running need Write.
32 return {
33 detail,
34 artifacts,
35 member: (await accessTo(context, params)).can.run,
36 // The runners page is the workspace owners'.
37 runnersPage: roleIn(viewer, params.owner) === "owner",
38 };
39}
40
41export async function action({ request, params, context }: Route.ActionArgs) {
42 assertSameOrigin(request);
43 const user = requireUser(context, request);
44 const repo = { namespace: params.owner, name: params.repo };
45 const form = await request.formData();
46 const intent = String(form.get("intent"));
47 const refused = await refusal(context, params, "run");
48 if (refused) return { error: refused };
49 if (intent === "delete-artifact") {
50 const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact")));
51 return deleted.ok ? {} : { error: deleted.error.message };
52 }
53 const done =
54 intent === "cancel"
55 ? await actions.cancel(user, repo, params.id)
56 : await actions.rerun(user, repo, params.id, intent === "rerun-failed");
57 return done.ok ? {} : { error: done.error.message };
58}
59
60const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
61 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
62 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
63 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
64};
65
66function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) {
67 return (
68 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
69 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
70 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
71 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
72 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
73 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
74 </summary>
75 <div className="border-t border-line bg-bg/60">
76 {text === undefined ? (
77 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
78 ) : (
79 <LogText text={text} />
80 )}
81 </div>
82 </details>
83 );
84}
85
86/** Why a job did not run as asked, with "Integrations" linked where it says so. */
87function Reason({ text, workspace }: { text: string; workspace: string }) {
88 const [before, after] = text.split("Integrations");
89 return (
90 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">
91 {after === undefined ? (
92 text
93 ) : (
94 <>
95 {before}
96 <Link to={`/${workspace}/-/integrations`} className="text-accent hover:underline">
97 Integrations
98 </Link>
99 {after}
100 </>
101 )}
102 </p>
103 );
104}
105
106/** Where the job ran: g1t's own runners, or a self-hosted one by name. */
107function RanOn({ job, workspace }: { job: Job; workspace: string }) {
108 const runnersPage = useLoaderData<typeof loader>().runnersPage;
109 if (job.selfHosted) {
110 const badge = "ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted";
111 const label = (
112 <>
113 <ServerCog size={13} />
114 {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"}
115 </>
116 );
117 // A link to the runners for those who manage them, a label for everyone else.
118 return runnersPage ? (
119 <Hint label="Self-hosted runner">
120 <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}>
121 {label}
122 </Link>
123 </Hint>
124 ) : (
125 <Hint label="Self-hosted runner">
126 <span className={badge}>{label}</span>
127 </Hint>
128 );
129 }
130 if (!job.startedAt) return null;
131 return (
132 <span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted">
133 <Cloud size={13} />
134 g1t
135 </span>
136 );
137}
138
139function JobView({ job, base }: { job: Job; base: string }) {
140 const live = job.status !== "completed";
141 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
142 const failed = job.steps.find((s) => s.conclusion === "failure");
143 const running = job.steps.find((s) => s.status === "in_progress");
144 return (
145 <section className="min-w-0 space-y-4">
146 <div className="flex flex-wrap items-center gap-3">
147 <StatusIcon status={job.status} conclusion={job.conclusion} size={18} />
148 <h3 className="text-base font-semibold">{job.name}</h3>
149 <span className="text-sm text-muted">
150 {standingWord(job)}
151 {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
152 </span>
153 <RanOn job={job} workspace={base.split("/")[1]!} />
154 </div>
155 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
156 {job.annotations.length > 0 && (
157 <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
158 {job.annotations.map((note, index) => (
159 <li key={index} className="flex gap-2">
160 {ANNOTATION_ICON[note.level]}
161 <span className="min-w-0">
162 {note.title && <span className="font-medium">{note.title}: </span>}
163 <span className="whitespace-pre-wrap">{note.message}</span>
164 {note.file && (
165 <span className="block font-mono text-xs text-faint">
166 {note.file}
167 {note.line != null && `:${note.line}`}
168 </span>
169 )}
170 </span>
171 </li>
172 ))}
173 </ul>
174 )}
175 <div className="overflow-hidden rounded-xl border border-line bg-surface">
176 <StepRow
177 step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }}
178 text={log.get(0)}
179 defaultOpen={false}
180 />
181 {job.steps.map((step) => (
182 <StepRow
183 key={step.number}
184 step={step}
185 text={log.get(step.number)}
186 defaultOpen={step.number === (failed ?? running)?.number}
187 />
188 ))}
189 </div>
190 </section>
191 );
192}
193
194export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
195 const { detail, artifacts, member } = loaderData;
196 const { run, jobs, notes } = detail;
197 const base = `/${params.owner}/${params.repo}`;
198 const [search] = useSearchParams();
199 // One of the run's buttons is working: the others wait for it.
200 const busy = usePending();
201 const live = run.status !== "completed";
202 useRefreshWhile(live, 2500);
203
204 // The job asked for, else one that failed, is running, or the first.
205 const selected =
206 jobs.find((job) => job.id === search.get("job")) ??
207 jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ??
208 jobs.find((job) => job.status === "in_progress") ??
209 jobs[0];
210 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
211
212 return (
213 <div className="max-w-6xl space-y-6">
214 <header className="space-y-3">
215 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
216 {run.name}
217 </Link>
218 <div className="flex flex-wrap items-start justify-between gap-4">
219 <h2 className="flex min-w-0 items-center gap-2.5 text-xl font-semibold tracking-tight">
220 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
221 <span className="min-w-0 truncate">{run.title || run.name}</span>
222 <span className="font-normal text-muted">#{run.number}</span>
223 </h2>
224 {member && !run.error && (
225 <Form method="post" className="flex gap-2">
226 {live ? (
227 <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
228 <Square size={13} />
229 Cancel run
230 </SubmitButton>
231 ) : (
232 <>
233 {anyFailed && (
234 <SubmitButton name="intent" value="rerun-failed" variant="quiet" disabled={busy} pending="Re-running…">
235 <RotateCw size={13} />
236 Re-run failed jobs
237 </SubmitButton>
238 )}
239 <SubmitButton name="intent" value="rerun" variant="quiet" disabled={busy} pending="Re-running…">
240 <RotateCw size={13} />
241 Re-run all jobs
242 </SubmitButton>
243 </>
244 )}
245 </Form>
246 )}
247 </div>
248 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
249 <span>{standingWord(run)}</span>
250 <span className="inline-flex items-center gap-1 font-mono text-xs">
251 <GitBranch size={12} />
252 {shortRef(run.ref)}
253 </span>
254 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
255 <GitCommitHorizontal size={12} />
256 {run.sha.slice(0, 7)}
257 </Link>
258 {run.pull != null && (
259 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
260 #{run.pull}
261 </Link>
262 )}
263 <span>
264 {run.event}
265 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
266 </span>
267 {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
268 {run.attempt > 1 && <span>Attempt {run.attempt}</span>}
269 </p>
270 </header>
271
272 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
273 {run.error && (
274 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
275 <p className="font-medium text-danger">The workflow file could not be used</p>
276 <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
277 </div>
278 )}
279 <Notes notes={notes} />
280 {artifacts.length > 0 && (
281 <section className="rounded-xl border border-line bg-surface p-4">
282 <h3 className="flex items-center gap-2 text-sm font-medium">
283 <Package size={14} className="text-muted" />
284 Artifacts
285 <span className="font-normal text-faint">
286 · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))}
287 </span>
288 </h3>
289 <ul className="mt-3 divide-y divide-line text-sm">
290 {artifacts.map((artifact) => (
291 <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
292 <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span>
293 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
294 <span>{formatBytes(artifact.size)}</span>
295 {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>}
296 </span>
297 <span className="ml-auto flex shrink-0 items-center gap-1.5">
298 <a
299 href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(artifact.name)}`}
300 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg"
301 >
302 <Download size={12} />
303 Download
304 </a>
305 {member && artifact.id != null && (
306 <Form method="post" preventScrollReset>
307 <input type="hidden" name="artifact" value={artifact.id} />
308 <Hint label={`Delete ${artifact.name} now`}>
309 <SubmitButton
310 name="intent"
311 value="delete-artifact"
312 icon
313 disabled={busy}
314 aria-label={`Delete ${artifact.name}`}
315 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-danger disabled:opacity-50"
316 >
317 <Trash2 size={12} />
318 </SubmitButton>
319 </Hint>
320 </Form>
321 )}
322 </span>
323 </li>
324 ))}
325 </ul>
326 </section>
327 )}
328
329 {jobs.length > 0 && (
330 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
331 <nav aria-label="Jobs" className="space-y-0.5 text-sm">
332 {jobs.map((job) => (
333 <Link
334 key={job.id}
335 to={`?job=${job.id}`}
336 preventScrollReset
337 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
338 job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
339 }`}
340 >
341 <StatusIcon status={job.status} conclusion={job.conclusion} size={14} />
342 <span className="min-w-0 truncate">{job.name}</span>
343 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(job.startedAt, job.finishedAt)}</span>
344 </Link>
345 ))}
346 </nav>
347 {/* Run again, a job keeps its id but its log starts afresh. */}
348 {selected && <JobView key={`${selected.id}:${run.attempt}`} job={selected} base={base} />}
349 </div>
350 )}
351 </div>
352 );
353}