Skip to content

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

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