Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,523 linesCodeBlameRaw
1import {
2 AlertTriangle,
3 ArrowDown,
4 Bug,
5 Check,
6 ChevronDown,
7 ChevronRight,
8 Clock,
9 Cloud,
10 Download,
11 FileCode,
12 FileText,
13 GitBranch,
14 GitCommitHorizontal,
15 History,
16 Hourglass,
17 Info,
18 LayoutList,
19 Package,
20 Play,
21 RotateCw,
22 Search,
23 ServerCog,
24 ShieldAlert,
25 Square,
26 Trash2,
27 Users,
28 Workflow,
29 X,
30 XCircle,
31} from "lucide-react";
32import { type ReactNode, type Ref, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
33import { Form, Link, useLoaderData, useSearchParams } from "react-router";
34
35import {
36 type Annotation,
37 type EnvironmentReviewer,
38 type Job,
39 type JobSummary,
40 type PendingDeployment,
41 type RunApproval,
42 type RunAttempt,
43 type StepState,
44 isManagedWorkflow,
45} from "@g1t/contracts";
46
47import type { Route } from "./+types/actions-run";
48import { page } from "../../lib/meta";
49import { isFailed, part } from "../../lib/partial";
50import { Boundary, Unavailable } from "../../components/boundary";
51import { Duration, LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog, useNow } from "../../components/actions";
52import { Markdown } from "../../components/markdown";
53import { RunGraph } from "../../components/run-graph";
54import { DetailsDisclosure } from "../../components/details-disclosure";
55import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
56import { Badge } from "../../components/ui/badge";
57import { Button } from "../../components/ui/button";
58import { Card } from "../../components/ui/card";
59import { CheckboxOption } from "../../components/ui/checkbox";
60import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
61import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
62import { Hint } from "../../components/ui/hint";
63import { type StepLog, searchStepLog } from "../../lib/log-lines";
64import { quietWords } from "../../lib/log-poll";
65import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server";
66import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts";
67import { actions } from "../../lib/services.server";
68import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
69import { accessTo, refusal } from "../../lib/access.server";
70import { useRefreshWhile } from "../../lib/refresh";
71import { runControls } from "../../lib/run-controls";
72import { endPlace } from "../../lib/log-follow";
73import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph";
74import { repoPages } from "../../lib/repo-live";
75import { useRepoLive } from "../../lib/use-repo-live";
76
77export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
78 const run = loaderData?.detail.run;
79 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
80}
81
82export async function loader({ params, context, request }: Route.LoaderArgs) {
83 const viewer = getViewer(context);
84 const repo = { namespace: params.owner, name: params.repo };
85 // An earlier attempt, when one is asked for.
86 const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
87 const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined;
88 // The run is what the page is about; what it kept and its jobs' summaries
89 // are parts, each said in its own place when it cannot be read.
90 const [detail, kept, summaries] = await Promise.all([
91 actions.run(repo, viewer, params.id, attempt).then(unwrap),
92 part(listArtifacts(repo, viewer, params.id), "Artifacts"),
93 part(
94 actions.summaries(repo, viewer, params.id, attempt).then((found): JobSummary[] => (found.ok ? found.value : [])),
95 "Job summaries",
96 ),
97 ]);
98 // Artifacts an older runner kept in KV: asked for only once the service
99 // has shown the viewer the run, and never while it is still going.
100 const artifacts = !isFailed(kept) && legacyArtifactsWorthAsking(detail.run) ? await withLegacyArtifacts(kept, params.id) : kept;
101 // Cancelling and re-running need Write.
102 return {
103 detail,
104 artifacts,
105 summaries,
106 member: (await accessTo(context, params)).can.run,
107 // The runners page is the workspace owners'.
108 runnersPage: roleIn(viewer, params.owner) === "owner",
109 };
110}
111
112export async function action({ request, params, context }: Route.ActionArgs) {
113 assertSameOrigin(request);
114 const user = requireUser(context, request);
115 const repo = { namespace: params.owner, name: params.repo };
116 const form = await request.formData();
117 const intent = String(form.get("intent"));
118 // Reviewing a deployment is the environment's reviewers' to do: the
119 // service says who may.
120 if (intent === "approve-deployment" || intent === "reject-deployment") {
121 const environment = String(form.get("environment") ?? "");
122 const comment = String(form.get("comment") ?? "").trim();
123 const done = await actions.reviewDeployments(
124 user,
125 repo,
126 params.id,
127 intent === "approve-deployment" ? "approved" : "rejected",
128 environment ? [environment] : [],
129 comment || undefined,
130 );
131 return done.ok ? {} : { error: done.error.message };
132 }
133 // Everything else, approving a run from outside included, needs Write.
134 const refused = await refusal(context, params, "run");
135 if (refused) return { error: refused };
136 if (intent === "delete-artifact") {
137 const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact")));
138 return deleted.ok ? {} : { error: deleted.error.message };
139 }
140 // Debug logging, for a re-run that asks for it.
141 const debug = form.get("debug") === "on";
142 const job = String(form.get("job") ?? "") || undefined;
143 const done =
144 intent === "approve-run"
145 ? await actions.approveRun(user, repo, params.id)
146 : intent === "cancel" || intent === "force-cancel"
147 ? await actions.cancel(user, repo, params.id, intent === "force-cancel")
148 : await actions.rerun(user, repo, params.id, intent === "rerun-failed", { job: intent === "rerun-job" ? job : undefined, debug });
149 return done.ok ? {} : { error: done.error.message };
150}
151
152const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
153 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
154 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
155 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
156};
157
158function StepRow({
159 step,
160 log,
161 defaultOpen,
162 query = "",
163 matches = 0,
164 timestamps = false,
165 lastOutput = null,
166 now,
167 end,
168 target = null,
169}: {
170 step: StepState;
171 log: StepLog | undefined;
172 defaultOpen: boolean;
173 /** A search of the job's log: only its matching lines are shown. */
174 query?: string;
175 matches?: number;
176 timestamps?: boolean;
177 /** When the step last printed something, in ms: its last line's time, or when its last chunk arrived. */
178 lastOutput?: number | null;
179 /** Now, ticking while the job runs, for a running step's times. */
180 now: number;
181 /** Marks the end of the running step's log, for following it. */
182 end?: Ref<HTMLDivElement>;
183 /** The line of this step a link names (`#step:3:12`). */
184 target?: number | null;
185}) {
186 const running = step.status === "in_progress";
187 const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null;
188 return (
189 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
190 <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2.5 px-3 py-2 text-sm hover:bg-raised/40 sm:min-h-0 sm:px-4">
191 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
192 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
193 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
194 {query && (
195 <span className="shrink-0 rounded-full bg-accent/15 px-1.5 text-xs text-accent">
196 {matches} {matches === 1 ? "line" : "lines"}
197 </span>
198 )}
199 <span className="ml-auto shrink-0 font-mono text-xs text-faint" suppressHydrationWarning>
200 {step.startedAt ? duration(step.startedAt, step.finishedAt ?? (running ? new Date(now).toISOString() : null)) : ""}
201 </span>
202 </summary>
203 <div className="border-t border-line bg-bg/60">
204 {log === undefined && !running ? (
205 <p className="px-4 py-2 text-xs text-faint">
206 {step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}
207 </p>
208 ) : (
209 log !== undefined && <LogText log={log} query={query} timestamps={timestamps} anchor={`step:${step.number}`} target={target} />
210 )}
211 {quiet && (
212 <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning>
213 <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-warn" aria-hidden />
214 {quiet}
215 </p>
216 )}
217 {/* The end of the log: kept clear of what covers the window's bottom (a phone's tab bar), with room under the last line. */}
218 {running && <div ref={end} aria-hidden className="h-px scroll-mb-[calc(var(--tabbar-h)+0.75rem)]" />}
219 </div>
220 </details>
221 );
222}
223
224/**
225 * The line of a job's log the page's address names, as GitHub's do:
226 * `#step:3:12` is line 12 of step 3 (0 is Set up job). A line's number
227 * links to it.
228 */
229function useLineTarget(): { step: number; line: number } | null {
230 const [target, setTarget] = useState<{ step: number; line: number } | null>(null);
231 useEffect(() => {
232 const read = () => {
233 const found = /^#step:(\d+):(\d+)$/.exec(window.location.hash);
234 setTarget((known) => {
235 if (!found) return null;
236 const next = { step: Number(found[1]), line: Number(found[2]) };
237 return known && known.step === next.step && known.line === next.line ? known : next;
238 });
239 };
240 read();
241 window.addEventListener("hashchange", read);
242 return () => window.removeEventListener("hashchange", read);
243 }, []);
244 return target;
245}
246
247/** Whether the viewer wants each log line's time shown, kept in this browser. */
248function useTimestamps(): [boolean, (on: boolean) => void] {
249 const [on, setOn] = useState(false);
250 useEffect(() => {
251 try {
252 setOn(window.localStorage.getItem(TIMESTAMPS_KEY) === "on");
253 } catch {
254 // Storage refused (a private window): off.
255 }
256 }, []);
257 const set = (next: boolean) => {
258 setOn(next);
259 try {
260 if (next) window.localStorage.setItem(TIMESTAMPS_KEY, "on");
261 else window.localStorage.removeItem(TIMESTAMPS_KEY);
262 } catch {
263 // It lasts until the page is left.
264 }
265 };
266 return [on, set];
267}
268
269const TIMESTAMPS_KEY = "g1t:actions:log-timestamps";
270
271/**
272 * Following the running step's log as it grows, as GitHub does: while the
273 * end of it is in view, new lines keep it in view; once the viewer
274 * scrolls up, it stays put and `behind` says there is more below. The end
275 * is in view only with the last line wholly above whatever covers the
276 * window's bottom (lib/log-follow.ts), and following pins it there again
277 * once the new lines have been laid out.
278 */
279function useFollow(running: number | null, grew: unknown): { end: RefObject<HTMLDivElement | null>; behind: boolean; jump: () => void } {
280 const end = useRef<HTMLDivElement | null>(null);
281 const following = useRef(false);
282 // Jump to latest's smooth scroll is on its way: the scrolls it makes are
283 // not the viewer scrolling up.
284 const jumping = useRef(false);
285 const [behind, setBehind] = useState(false);
286 const where = () => {
287 const marker = end.current;
288 const covered = marker ? parseFloat(getComputedStyle(marker).scrollMarginBottom) || 0 : 0;
289 return endPlace(marker?.getBoundingClientRect() ?? null, { height: window.innerHeight, covered });
290 };
291 // The end of the log at the bottom of what can be seen, now and once
292 // anything still settling (a long log's rows being measured, the Jump to
293 // latest bar going away) has been laid out.
294 const pin = () => {
295 end.current?.scrollIntoView({ block: "end" });
296 requestAnimationFrame(() => {
297 if (following.current && where() !== "folded") end.current?.scrollIntoView({ block: "end" });
298 });
299 };
300 useEffect(() => {
301 if (running === null) {
302 following.current = false;
303 setBehind(false);
304 return;
305 }
306 // The viewer moving decides: at the end, follow; scrolled up, stay.
307 const moved = () => {
308 if (jumping.current) return;
309 const at = where();
310 following.current = at === "here";
311 setBehind(at === "below");
312 };
313 // A step folded or opened, or the window resized (a phone's address
314 // bar or keyboard), moves nothing the viewer chose: a log followed
315 // stays at its end, and whether there is anything below to jump to may
316 // change.
317 const toggled = () => {
318 const at = where();
319 if (at === "folded") following.current = false;
320 else if (following.current && at === "below" && !jumping.current) {
321 pin();
322 return;
323 }
324 setBehind(at === "below");
325 };
326 window.addEventListener("scroll", moved, { passive: true });
327 window.addEventListener("resize", toggled);
328 document.addEventListener("toggle", toggled, true);
329 return () => {
330 window.removeEventListener("scroll", moved);
331 window.removeEventListener("resize", toggled);
332 document.removeEventListener("toggle", toggled, true);
333 };
334 }, [running]);
335 useLayoutEffect(() => {
336 if (running === null || !end.current) return;
337 if (following.current && where() !== "folded") {
338 pin();
339 return;
340 }
341 // Not following: once there is a log and its end is on screen, follow
342 // from here; a log that arrives longer than the screen is left at its
343 // top rather than the page jumping to its end.
344 const at = where();
345 if (grew && at === "here") following.current = true;
346 setBehind(at === "below");
347 }, [grew, running]);
348 // Smoothly there, then pinned where the end is once the scroll is over:
349 // lines that arrived on the way, and the bar this button sits in going
350 // away, move it.
351 const jump = () => {
352 following.current = true;
353 const marker = end.current;
354 if (!marker) return;
355 jumping.current = true;
356 setBehind(false);
357 let settled = false;
358 const settle = () => {
359 if (settled) return;
360 settled = true;
361 jumping.current = false;
362 window.removeEventListener("scrollend", settle);
363 if (following.current && where() !== "folded") pin();
364 };
365 window.addEventListener("scrollend", settle);
366 // A browser without `scrollend`, or nothing left to scroll.
367 window.setTimeout(settle, 800);
368 marker.scrollIntoView({ block: "end", behavior: "smooth" });
369 };
370 return { end, behind, jump };
371}
372
373/**
374 * Runs again, after asking whether with debug logging: the whole run, its
375 * failed jobs, or one job (with the jobs that need it).
376 */
377function RerunDialog({
378 intent,
379 job,
380 title,
381 description,
382 children,
383 busy,
384}: {
385 intent: "rerun" | "rerun-failed" | "rerun-job";
386 job?: string;
387 title: string;
388 description: string;
389 /** What opens it. */
390 children: (open: () => void) => ReactNode;
391 busy: boolean;
392}) {
393 const [open, setOpen] = useState(false);
394 // Open, saying it is starting, until the new attempt shows or the error does.
395 const running = usePending({ intent });
396 const was = useRef(false);
397 useEffect(() => {
398 if (was.current && !running) setOpen(false);
399 was.current = running;
400 }, [running]);
401 return (
402 <>
403 {children(() => setOpen(true))}
404 <Dialog open={open} onOpenChange={setOpen}>
405 <DialogContent>
406 <Form method="post" preventScrollReset className="grid gap-4">
407 <DialogHeader>
408 <DialogTitle>{title}</DialogTitle>
409 <DialogDescription>{description}</DialogDescription>
410 </DialogHeader>
411 {job && <input type="hidden" name="job" value={job} />}
412 <CheckboxOption
413 name="debug"
414 label="Enable debug logging"
415 description="Sets RUNNER_DEBUG=1 and ACTIONS_STEP_DEBUG: ::debug:: lines are shown, and how each step's if: read."
416 />
417 <DialogFooter>
418 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
419 Cancel
420 </Button>
421 <SubmitButton name="intent" value={intent} variant="accent" disabled={busy} pending="Re-running…">
422 <RotateCw size={13} />
423 {title}
424 </SubmitButton>
425 </DialogFooter>
426 </Form>
427 </DialogContent>
428 </Dialog>
429 </>
430 );
431}
432
433/** A link or menu button that sits beside the run's quiet buttons, at their size. */
434/** The run's attempts, newest first, each a link to how it went. */
435function AttemptPicker({ attempts, shown }: { attempts: RunAttempt[]; shown: number }) {
436 const latest = attempts[attempts.length - 1]?.attempt ?? shown;
437 return (
438 <DropdownMenu>
439 <DropdownMenuTrigger asChild>
440 <Button type="button" variant="outline">
441 <History size={13} />
442 Attempt #{shown}
443 <ChevronDown size={13} />
444 </Button>
445 </DropdownMenuTrigger>
446 <DropdownMenuContent align="end" className="min-w-64">
447 {[...attempts].reverse().map((attempt) => (
448 <DropdownMenuItem key={attempt.attempt} asChild>
449 <Link
450 to={attempt.attempt === latest ? "?" : `?attempt=${attempt.attempt}`}
451 preventScrollReset
452 className={`flex items-center gap-2 ${attempt.attempt === shown ? "text-fg" : ""}`}
453 >
454 <StatusIcon status={attempt.status} conclusion={attempt.conclusion} size={14} />
455 <span className="font-medium">Attempt #{attempt.attempt}</span>
456 {attempt.debug && <Bug size={12} className="text-faint" aria-label="Debug logging" />}
457 <span className="ml-auto truncate text-xs text-faint">
458 {attempt.actor && `${attempt.actor} · `}
459 {attempt.startedAt ? <TimeAgo at={attempt.startedAt} /> : "not started"}
460 </span>
461 </Link>
462 </DropdownMenuItem>
463 ))}
464 </DropdownMenuContent>
465 </DropdownMenu>
466 );
467}
468
469/** What the jobs' steps wrote to $GITHUB_STEP_SUMMARY, a card per job. */
470function Summaries({
471 summaries,
472 repo,
473 jobHref,
474}: {
475 summaries: JobSummary[];
476 repo: { namespace: string; name: string };
477 jobHref: (job: { id: string }) => string;
478}) {
479 if (summaries.length === 0) return null;
480 return (
481 <section aria-label="Job summaries" className="space-y-4">
482 {summaries.map((summary) => (
483 <Card asChild key={summary.jobId} className="overflow-hidden">
484 <article>
485 <header className="flex min-w-0 items-center gap-2 border-b border-line px-4 py-2.5 text-sm">
486 <FileText size={14} className="shrink-0 text-muted" />
487 <Link to={jobHref({ id: summary.jobId })} preventScrollReset className="min-w-0 truncate font-medium hover:underline">
488 {summary.name}
489 </Link>
490 <span className="shrink-0 text-faint">summary</span>
491 </header>
492 <div className="space-y-4 px-5 py-4">
493 {summary.steps.map((step) => (
494 <Markdown key={step.step} source={step.markdown} repo={repo} />
495 ))}
496 </div>
497 </article>
498 </Card>
499 ))}
500 </section>
501 );
502}
503
504/** Why a job did not run as asked, with "Integrations" linked where it says so. */
505function Reason({ text, workspace }: { text: string; workspace: string }) {
506 const [before, after] = text.split("Integrations");
507 return (
508 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">
509 {after === undefined ? (
510 text
511 ) : (
512 <>
513 {before}
514 <Link to={`/${workspace}/-/integrations`} className="text-accent hover:underline">
515 Integrations
516 </Link>
517 {after}
518 </>
519 )}
520 </p>
521 );
522}
523
524/** Where the job ran: g1t's own runners, or a self-hosted one by name. */
525function RanOn({ job, workspace }: { job: Job; workspace: string }) {
526 const runnersPage = useLoaderData<typeof loader>().runnersPage;
527 if (job.selfHosted) {
528 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";
529 const label = (
530 <>
531 <ServerCog size={13} />
532 {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"}
533 </>
534 );
535 // A link to the runners for those who manage them, a label for everyone else.
536 return runnersPage ? (
537 <Hint label="Self-hosted runner">
538 <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}>
539 {label}
540 </Link>
541 </Hint>
542 ) : (
543 <Hint label="Self-hosted runner">
544 <span className={badge}>{label}</span>
545 </Hint>
546 );
547 }
548 if (!job.startedAt) return null;
549 return (
550 <Badge size="md" className="ml-auto gap-1.5 px-2.5">
551 <Cloud size={13} />
552 g1t
553 </Badge>
554 );
555}
556
557function JobView({ job, base, rerun }: { job: Job; base: string; rerun: ReactNode }) {
558 const live = job.status !== "completed";
559 const failed = job.steps.find((s) => s.conclusion === "failure");
560 const running = job.steps.find((s) => s.status === "in_progress");
561 const { byStep: log, grewAt, retrying } = useJobLog(`${base}/actions/jobs/${job.id}/log`, live, Boolean(running));
562 const [query, setQuery] = useState("");
563 const [timestamps, setTimestamps] = useTimestamps();
564 const now = useNow(live);
565 const runningLog = running ? log.get(running.number) : undefined;
566 const target = useLineTarget();
567 // The running step's log is followed while its end is in view, unless a
568 // failed step is the one held open.
569 const followed = !failed && running ? running.number : null;
570 const { end, behind, jump } = useFollow(followed, runningLog);
571 // When the running step last printed: its last line's time, else when
572 // its last chunk came.
573 const lastOutput = useMemo(() => {
574 if (!running) return null;
575 const at = runningLog?.lastAt;
576 if (at) return new Date(at).getTime();
577 return runningLog ? (grewAt.get(running.number) ?? null) : null;
578 }, [running, runningLog, grewAt]);
579 const searching = query.trim().length > 0;
580 const setUp: StepState = {
581 number: 0,
582 name: "Set up job",
583 status: job.startedAt ? "completed" : "queued",
584 conclusion: job.startedAt ? "success" : null,
585 startedAt: job.startedAt,
586 finishedAt: job.startedAt,
587 };
588 // While searching, the steps with a match, each with how many.
589 const steps = [setUp, ...job.steps]
590 .map((step) => {
591 const stepLog = log.get(step.number);
592 return { step, matches: searching && stepLog ? searchStepLog(stepLog, query).length : 0 };
593 })
594 .filter(({ matches }) => !searching || matches > 0);
595 const total = steps.reduce((sum, { matches }) => sum + matches, 0);
596 return (
597 <section className="min-w-0 space-y-4">
598 <div className="flex flex-wrap items-center gap-3">
599 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={18} />
600 <h3 className="text-base font-semibold">{job.name}</h3>
601 <span className="min-w-0 text-sm text-muted">
602 {job.cancelling ? "Cancelling: running its cleanup steps" : standingWord({ ...job, of: "job" })}
603 {running && !job.cancelling && (
604 <>
605 {" · "}
606 <span className="text-fg/85">{running.name}</span>
607 </>
608 )}
609 {job.startedAt && (
610 <>
611 {" · "}
612 <span suppressHydrationWarning>{duration(job.startedAt, job.finishedAt ?? (live ? new Date(now).toISOString() : null))}</span>
613 </>
614 )}
615 {retrying && <span className="text-warn"> · Reconnecting to the log…</span>}
616 </span>
617 <span className="ml-auto flex items-center gap-2">
618 {rerun}
619 {job.startedAt && (
620 <Hint label="Download this job's log">
621 <a
622 href={`${base}/actions/jobs/${job.id}/log.txt`}
623 aria-label={`Download the log of ${job.name}`}
624 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg"
625 >
626 <Download size={13} />
627 </a>
628 </Hint>
629 )}
630 <RanOn job={job} workspace={base.split("/")[1]!} />
631 </span>
632 </div>
633 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
634 {job.annotations.length > 0 && (
635 <Card asChild className="space-y-2 p-4 text-sm">
636 <ul>
637 {job.annotations.map((note, index) => (
638 <li key={index} className="flex gap-2">
639 {ANNOTATION_ICON[note.level]}
640 <span className="min-w-0">
641 {note.title && <span className="font-medium">{note.title}: </span>}
642 <span className="whitespace-pre-wrap wrap-break-word">{note.message}</span>
643 {note.file && (
644 <span className="block font-mono text-xs text-faint">
645 {note.file}
646 {note.line != null && `:${note.line}`}
647 </span>
648 )}
649 </span>
650 </li>
651 ))}
652 </ul>
653 </Card>
654 )}
655 {job.startedAt && (
656 <div className="flex items-center gap-2">
657 <Card asChild radius="lg" className="flex min-w-0 grow items-center gap-2 px-3 py-1.5 text-sm focus-within:border-accent-dim">
658 <label>
659 <Search size={14} className="shrink-0 text-faint" />
660 <input
661 type="search"
662 value={query}
663 onChange={(event) => setQuery(event.target.value)}
664 placeholder="Search logs"
665 aria-label={`Search the log of ${job.name}`}
666 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
667 />
668 {searching && (
669 <span className="shrink-0 text-xs text-muted" aria-live="polite">
670 {total} {total === 1 ? "line" : "lines"}
671 </span>
672 )}
673 </label>
674 </Card>
675 <Hint label={timestamps ? "Hide timestamps" : "Show timestamps"}>
676 <Button
677 variant="outline"
678 size="icon-sm"
679 aria-pressed={timestamps}
680 aria-label="Show timestamps"
681 onClick={() => setTimestamps(!timestamps)}
682 className="aria-pressed:border-accent-dim aria-pressed:text-fg"
683 >
684 <Clock size={14} />
685 </Button>
686 </Hint>
687 </div>
688 )}
689 <Card className="overflow-hidden">
690 {searching && steps.length === 0 && <p className="px-4 py-3 text-sm text-muted">No line of this job's log holds “{query.trim()}”.</p>}
691 {steps.map(({ step, matches }) => (
692 <StepRow
693 // Searching opens every step with a match; clearing it puts them back.
694 key={`${step.number}:${searching}`}
695 step={step}
696 log={log.get(step.number)}
697 defaultOpen={searching || step.number === target?.step || (step.number !== 0 && step.number === (failed ?? running)?.number)}
698 query={searching ? query : ""}
699 matches={matches}
700 timestamps={timestamps}
701 now={now}
702 lastOutput={step.number === running?.number ? lastOutput : null}
703 end={step.number === followed ? end : undefined}
704 target={step.number === target?.step ? target.line : null}
705 />
706 ))}
707 </Card>
708 {behind && !searching && (
709 <div className="pointer-events-none sticky bottom-[calc(var(--tabbar-h)+1rem)] z-10 flex justify-center">
710 <Button variant="secondary" size="sm" onClick={jump} className="pointer-events-auto shadow-md ring-1 ring-line">
711 <ArrowDown size={14} />
712 Jump to latest
713 </Button>
714 </div>
715 )}
716 </section>
717 );
718}
719
720/** A run held by its environments, in words that say what for: a review, or only a wait timer. */
721function runWord(status: string, deployments: PendingDeployment[]): string | null {
722 if (status !== "waiting") return null;
723 const waiting = deployments.filter((deployment) => deployment.state === "waiting");
724 return waiting.length > 0 && waiting.every((deployment) => !deployment.needsReview) ? "Waiting for a wait timer" : null;
725}
726
727/** Whether a wait timer has yet to run out. */
728function timerRunning(deployment: PendingDeployment): boolean {
729 return Boolean(deployment.waitUntil && new Date(deployment.waitUntil).getTime() > Date.now());
730}
731
732/** Whether environments still hold some of the run's jobs. */
733function held(deployments: PendingDeployment[]): boolean {
734 return deployments.some((deployment) => deployment.state === "waiting" || (deployment.state === "approved" && timerRunning(deployment)));
735}
736
737/** `in 12m`, `in 2h 5m`: how long until a time. */
738function untilWords(at: string): string {
739 const minutes = Math.max(1, Math.ceil((new Date(at).getTime() - Date.now()) / 60_000));
740 if (minutes < 60) return `in ${minutes}m`;
741 const hours = Math.floor(minutes / 60);
742 if (hours < 48) return `in ${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
743 return `in ${Math.round(hours / 24)}d`;
744}
745
746/** A user by name, a team as @team. */
747function ReviewerChip({ reviewer }: { reviewer: EnvironmentReviewer }) {
748 return (
749 <span className="inline-flex items-center gap-1 rounded-md bg-raised px-1.5 py-0.5 font-mono text-xs text-fg/85">
750 {reviewer.type === "team" && <Users size={11} className="text-muted" />}
751 {reviewer.type === "team" ? `@${reviewer.name}` : reviewer.name}
752 </span>
753 );
754}
755
756/** A pull request's run from outside, waiting for someone with Write to let it start. */
757function ApprovalPanel({ approval, member, busy }: { approval: RunApproval; member: boolean; busy: boolean }) {
758 return (
759 <section className="flex flex-col gap-4 rounded-xl border border-warn/30 bg-warn/5 p-4 sm:flex-row sm:items-start">
760 <ShieldAlert size={18} className="mt-0.5 hidden shrink-0 text-warn sm:block" />
761 <div className="min-w-0 grow space-y-1.5 text-sm">
762 <h3 className="flex items-center gap-2 font-medium">
763 <ShieldAlert size={16} className="shrink-0 text-warn sm:hidden" />
764 Approval required
765 </h3>
766 <p className="text-fg/85">{approval.reason}</p>
767 <p className="text-muted">
768 Nothing in this run starts, and it gets no secrets and no token, until someone with the Write role approves it.
769 {!member && " Ask someone with the Write role on this repository to approve it."}
770 </p>
771 </div>
772 {member && (
773 <Form method="post" className="shrink-0">
774 <SubmitButton name="intent" value="approve-run" variant="accent" disabled={busy} pending="Approving…">
775 <Play size={13} />
776 Approve and run
777 </SubmitButton>
778 </Form>
779 )}
780 </section>
781 );
782}
783
784const DEPLOYMENT_ICON: Record<PendingDeployment["state"], ReactNode> = {
785 waiting: <Hourglass size={15} className="shrink-0 text-warn" />,
786 approved: (
787 <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success">
788 <Check size={11} strokeWidth={3} />
789 </span>
790 ),
791 rejected: (
792 <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger">
793 <X size={11} strokeWidth={3} />
794 </span>
795 ),
796};
797
798/** Where one environment's rules stand for this run, and a way to review it for those who may. */
799function DeploymentRow({ deployment, busy }: { deployment: PendingDeployment; busy: boolean }) {
800 const waiting = deployment.state === "waiting";
801 const timer = timerRunning(deployment);
802 const words =
803 deployment.state === "approved"
804 ? timer
805 ? "Approved · waiting for its wait timer"
806 : "Approved"
807 : deployment.state === "rejected"
808 ? "Rejected"
809 : deployment.needsReview
810 ? "Waiting for review"
811 : "Waiting for its wait timer";
812 return (
813 <li className="space-y-3 px-4 py-4">
814 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
815 {DEPLOYMENT_ICON[deployment.state]}
816 <span className="font-mono text-sm font-medium">{deployment.environment}</span>
817 <span className="text-sm text-muted">{words}</span>
818 </div>
819 <dl className="grid gap-x-4 gap-y-1.5 text-sm sm:grid-cols-[7rem_1fr]">
820 {deployment.jobs.length > 0 && (
821 <>
822 <dt className="text-muted">{deployment.jobs.length === 1 ? "Job" : "Jobs"}</dt>
823 <dd className="flex min-w-0 flex-wrap gap-1.5">
824 {deployment.jobs.map((job) => (
825 <span key={job} className="rounded-md bg-raised px-1.5 py-0.5 text-xs">
826 {job}
827 </span>
828 ))}
829 </dd>
830 </>
831 )}
832 {deployment.reviewers.length > 0 && (
833 <>
834 <dt className="text-muted">Reviewers</dt>
835 <dd className="flex min-w-0 flex-wrap gap-1.5">
836 {deployment.reviewers.map((reviewer) => (
837 <ReviewerChip key={`${reviewer.type}:${reviewer.name}`} reviewer={reviewer} />
838 ))}
839 </dd>
840 </>
841 )}
842 {deployment.waitUntil && (waiting || timer) && (
843 <>
844 <dt className="text-muted">Wait timer</dt>
845 <dd>
846 {timer ? (
847 <>
848 Starts at{" "}
849 <time dateTime={deployment.waitUntil} suppressHydrationWarning>
850 {new Date(deployment.waitUntil).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
851 </time>{" "}
852 <span className="text-muted" suppressHydrationWarning>
853 ({untilWords(deployment.waitUntil)})
854 </span>
855 </>
856 ) : (
857 "Done"
858 )}
859 </dd>
860 </>
861 )}
862 {deployment.reviewedBy && (
863 <>
864 <dt className="text-muted">{deployment.state === "rejected" ? "Rejected by" : "Approved by"}</dt>
865 <dd className="min-w-0">
866 <span className="font-mono text-xs">{deployment.reviewedBy}</span>
867 {deployment.reviewedAt && (
868 <span className="text-muted">
869 {" "}
870 · <TimeAgo at={deployment.reviewedAt} />
871 </span>
872 )}
873 {deployment.comment && <p className="mt-1 whitespace-pre-wrap wrap-break-word text-fg/85">{deployment.comment}</p>}
874 </dd>
875 </>
876 )}
877 </dl>
878 {waiting && deployment.needsReview && deployment.canReview && (
879 <Form method="post" className="space-y-3">
880 <input type="hidden" name="environment" value={deployment.environment} />
881 <label className="block">
882 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
883 <textarea
884 name="comment"
885 rows={2}
886 maxLength={1000}
887 placeholder="Why you approve or reject it"
888 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
889 />
890 </label>
891 <div className="flex flex-wrap gap-2">
892 <SubmitButton name="intent" value="approve-deployment" variant="accent" disabled={busy} pending="Approving…">
893 <Check size={13} />
894 Approve and deploy
895 </SubmitButton>
896 <SubmitButton name="intent" value="reject-deployment" variant="destructive" disabled={busy} pending="Rejecting…">
897 <X size={13} />
898 Reject
899 </SubmitButton>
900 </div>
901 </Form>
902 )}
903 {waiting && deployment.needsReview && !deployment.canReview && (
904 <p className="text-sm text-muted">
905 {deployment.reviewers.length > 0
906 ? "Only its reviewers can approve or reject it."
907 : "Only someone this environment's rules name can approve or reject it."}
908 </p>
909 )}
910 </li>
911 );
912}
913
914/** The environments holding the run's jobs, each with where its rules stand. */
915function DeploymentsPanel({ deployments, busy }: { deployments: PendingDeployment[]; busy: boolean }) {
916 const review = deployments.some((deployment) => deployment.state === "waiting" && deployment.needsReview);
917 return (
918 <Card asChild className="overflow-hidden">
919 <section>
920 <header className="flex items-start gap-2.5 border-b border-line px-4 py-3">
921 <Hourglass size={16} className="mt-0.5 shrink-0 text-warn" />
922 <div className="min-w-0">
923 <h3 className="text-sm font-medium">{review ? "Waiting for review" : "Waiting to deploy"}</h3>
924 <p className="mt-0.5 text-sm text-muted">
925 Jobs that deploy to these environments wait until each environment's protection rules let them through, and only then get its secrets.
926 </p>
927 </div>
928 </header>
929 <ul className="divide-y divide-line">
930 {deployments.map((deployment) => (
931 <DeploymentRow key={deployment.environment} deployment={deployment} busy={busy} />
932 ))}
933 </ul>
934 </section>
935 </Card>
936 );
937}
938
939type RunData = Route.ComponentProps["loaderData"];
940
941/** Where the run came from, how it stands, how long it took and what it kept. */
942function RunCard({
943 detail,
944 artifacts,
945 standing,
946 cancelling,
947 base,
948}: {
949 detail: RunData["detail"];
950 artifacts: RunData["artifacts"];
951 standing: string;
952 cancelling: boolean;
953 base: string;
954}) {
955 const { run } = detail;
956 const label = "text-xs text-muted";
957 return (
958 <Card asChild className="grid gap-x-6 gap-y-4 p-4 text-sm sm:grid-cols-[minmax(0,2fr)_repeat(3,minmax(0,1fr))]">
959 <section aria-label="Run">
960 <div className="min-w-0 space-y-1.5">
961 <p className={label}>
962 Triggered via {run.event} <TimeAgo at={run.createdAt} />
963 </p>
964 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
965 {run.actor && <span className="font-medium">{run.actor}</span>}
966 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs text-muted hover:text-fg">
967 <GitCommitHorizontal size={12} />
968 {run.sha.slice(0, 7)}
969 </Link>
970 {run.pull != null && /^refs\/pull\//.test(run.ref) ? (
971 <Link to={`${base}/pull/${run.pull}`} className="rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent hover:underline">
972 #{run.pull}
973 </Link>
974 ) : (
975 <span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent">
976 <GitBranch size={11} className="shrink-0" />
977 <span className="truncate">{shortRef(run.ref)}</span>
978 </span>
979 )}
980 </p>
981 {detail.approval?.state === "approved" && detail.approval.approvedBy && (
982 <p className="text-xs text-muted">Approved by {detail.approval.approvedBy}</p>
983 )}
984 </div>
985 <div className="grid grid-cols-3 gap-4 sm:contents">
986 <div className="min-w-0 space-y-1.5">
987 <p className={label}>Status</p>
988 <p className="flex items-center gap-1.5 font-medium">
989 <StatusIcon status={run.status} conclusion={run.conclusion} size={14} />
990 <span className="truncate">{standing}</span>
991 </p>
992 {cancelling && <p className="text-xs text-warn">Its jobs are running their cleanup steps</p>}
993 </div>
994 <div className="min-w-0 space-y-1.5">
995 <p className={label}>Total duration</p>
996 <p className="font-medium">{run.startedAt ? <Duration start={run.startedAt} end={run.finishedAt} /> : "—"}</p>
997 {run.attempt > 1 && <p className="text-xs text-muted">Attempt #{run.attempt}</p>}
998 </div>
999 <div className="min-w-0 space-y-1.5">
1000 <p className={label}>Artifacts</p>
1001 {isFailed(artifacts) ? (
1002 <Unavailable what="Artifacts" compact />
1003 ) : (
1004 <p className="font-medium">
1005 {artifacts.length > 0 ? (
1006 <a href="#artifacts" className="text-accent hover:underline">
1007 {artifacts.length}
1008 </a>
1009 ) : (
1010 "—"
1011 )}
1012 </p>
1013 )}
1014 </div>
1015 </div>
1016 </section>
1017 </Card>
1018 );
1019}
1020
1021const LEVELS: { level: Annotation["level"]; one: string; many: string }[] = [
1022 { level: "error", one: "error", many: "errors" },
1023 { level: "warning", one: "warning", many: "warnings" },
1024 { level: "notice", one: "notice", many: "notices" },
1025];
1026
1027/** Every job's errors, warnings and notices, folded under how many of each. */
1028function AnnotationsPanel({ jobs, jobHref }: { jobs: Job[]; jobHref: (job: { id: string }) => string }) {
1029 const notes = jobs.flatMap((job) => job.annotations.map((note) => ({ job, note })));
1030 if (notes.length === 0) return null;
1031 const counts = LEVELS.flatMap(({ level, one, many }) => {
1032 const count = notes.filter(({ note }) => note.level === level).length;
1033 return count > 0 ? [`${count} ${count === 1 ? one : many}`] : [];
1034 });
1035 return (
1036 <Card asChild className="group overflow-hidden">
1037 <details open>
1038 <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-sm hover:bg-raised/40">
1039 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
1040 <span className="font-medium">Annotations</span>
1041 <span className="min-w-0 truncate text-muted">{counts.join(", ")}</span>
1042 </summary>
1043 <ul className="divide-y divide-line border-t border-line text-sm">
1044 {notes.map(({ job, note }, index) => (
1045 <li key={index} className="flex gap-2 px-4 py-3">
1046 {ANNOTATION_ICON[note.level]}
1047 <span className="min-w-0">
1048 <Link to={jobHref(job)} preventScrollReset className="block text-xs font-medium text-muted hover:text-fg hover:underline">
1049 {job.name}
1050 </Link>
1051 {note.title && <span className="font-medium">{note.title}: </span>}
1052 <span className="whitespace-pre-wrap wrap-break-word">{note.message}</span>
1053 {note.file && (
1054 <span className="block font-mono text-xs text-faint">
1055 {note.file}
1056 {note.line != null && `:${note.line}`}
1057 </span>
1058 )}
1059 </span>
1060 </li>
1061 ))}
1062 </ul>
1063 </details>
1064 </Card>
1065 );
1066}
1067
1068/** What the run kept, to download (and, with Write, to delete now). */
1069function ArtifactsList({
1070 artifacts,
1071 member,
1072 busy,
1073 base,
1074 runId,
1075}: {
1076 artifacts: RunData["artifacts"];
1077 member: boolean;
1078 busy: boolean;
1079 base: string;
1080 runId: string;
1081}) {
1082 if (isFailed(artifacts)) {
1083 // Its heading stays, so the run's count links somewhere that says why.
1084 return (
1085 <Card asChild className="scroll-mt-20 p-4">
1086 <section id="artifacts">
1087 <h3 className="flex items-center gap-2 text-sm font-medium">
1088 <Package size={14} className="text-muted" />
1089 Artifacts
1090 </h3>
1091 <div className="mt-3">
1092 <Unavailable what="Artifacts" compact />
1093 </div>
1094 </section>
1095 </Card>
1096 );
1097 }
1098 if (artifacts.length === 0) return null;
1099 return (
1100 <Card asChild className="scroll-mt-20 p-4">
1101 <section id="artifacts">
1102 <h3 className="flex items-center gap-2 text-sm font-medium">
1103 <Package size={14} className="text-muted" />
1104 Artifacts
1105 <span className="font-normal text-faint">
1106 · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))}
1107 </span>
1108 </h3>
1109 <ul className="mt-3 divide-y divide-line text-sm">
1110 {artifacts.map((artifact) => (
1111 <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
1112 <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span>
1113 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
1114 <span>{formatBytes(artifact.size)}</span>
1115 {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>}
1116 </span>
1117 <span className="ml-auto flex shrink-0 items-center gap-1.5">
1118 <a
1119 href={`${base}/actions/runs/${runId}/artifacts/${encodeURIComponent(artifact.name)}`}
1120 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg"
1121 >
1122 <Download size={12} />
1123 Download
1124 </a>
1125 {member && artifact.id != null && (
1126 <Form method="post" preventScrollReset>
1127 <input type="hidden" name="artifact" value={artifact.id} />
1128 <Hint label={`Delete ${artifact.name} now`}>
1129 <SubmitButton
1130 name="intent"
1131 value="delete-artifact"
1132 icon
1133 disabled={busy}
1134 aria-label={`Delete ${artifact.name}`}
1135 variant="ghost"
1136 size="inline"
1137 className="p-1.5 ring-1 ring-line hover:text-danger"
1138 >
1139 <Trash2 size={12} />
1140 </SubmitButton>
1141 </Hint>
1142 </Form>
1143 )}
1144 </span>
1145 </li>
1146 ))}
1147 </ul>
1148 </section>
1149 </Card>
1150 );
1151}
1152
1153/** The run at a glance: the card, its jobs as a graph, their annotations, summaries and artifacts. */
1154function RunSummary({
1155 detail,
1156 artifacts,
1157 summaries,
1158 standing,
1159 cancelling,
1160 member,
1161 busy,
1162 base,
1163 jobHref,
1164}: {
1165 detail: RunData["detail"];
1166 artifacts: RunData["artifacts"];
1167 summaries: RunData["summaries"];
1168 standing: string;
1169 cancelling: boolean;
1170 member: boolean;
1171 busy: boolean;
1172 base: string;
1173 jobHref: (job: { id: string }) => string;
1174}) {
1175 const { run, jobs } = detail;
1176 const [namespace = "", name = ""] = base.slice(1).split("/");
1177 // Each card keeps a failure to itself: the run's card and its logs stay.
1178 return (
1179 <section aria-label="Summary" className="min-w-0 space-y-4">
1180 <RunCard detail={detail} artifacts={artifacts} standing={standing} cancelling={cancelling} base={base} />
1181 <Boundary what="The jobs' graph">
1182 <RunGraph jobs={jobs} title={run.path.split("/").pop() || run.name} trigger={run.event} href={jobHref} />
1183 </Boundary>
1184 <Boundary what="Annotations">
1185 <AnnotationsPanel jobs={jobs} jobHref={jobHref} />
1186 </Boundary>
1187 {isFailed(summaries) ? (
1188 <Unavailable what="Job summaries" />
1189 ) : (
1190 <Boundary what="Job summaries">
1191 <Summaries summaries={summaries} repo={{ namespace, name }} jobHref={jobHref} />
1192 </Boundary>
1193 )}
1194 <ArtifactsList artifacts={artifacts} member={member} busy={busy} base={base} runId={run.id} />
1195 </section>
1196 );
1197}
1198
1199const NAV_ROW = "flex min-h-11 items-center gap-2 rounded-md px-2.5 py-1.5 sm:min-h-0";
1200
1201function navRow(active: boolean): string {
1202 return `${NAV_ROW} ${active ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`;
1203}
1204
1205/** A job's row in the jobs list. */
1206function NavJob({ job, label, selected, jobHref }: { job: GraphJob; label: string; selected: Job | null; jobHref: (job: { id: string }) => string }) {
1207 const active = job.id === selected?.id;
1208 return (
1209 <Link to={jobHref(job)} preventScrollReset aria-current={active ? "page" : undefined} className={navRow(active)}>
1210 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={14} />
1211 <span className="min-w-0 truncate">{label}</span>
1212 <Duration className="ml-auto shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} />
1213 </Link>
1214 );
1215}
1216
1217/** One unit of the jobs list: a job, or a matrix or called workflow folded under its name. */
1218function NavUnit({ unit, selected, jobHref }: { unit: Unit; selected: Job | null; jobHref: (job: { id: string }) => string }) {
1219 if (unit.kind === "job") return <NavJob job={unit.job} label={unit.label} selected={selected} jobHref={jobHref} />;
1220 const inside = jobsOf(unit).some((job) => job.id === selected?.id);
1221 const standing = unitStanding(unit);
1222 return (
1223 <details open={unit.kind === "call" || inside} className="group/unit">
1224 <summary className={`${NAV_ROW} cursor-pointer list-none text-muted hover:bg-raised/60 hover:text-fg`}>
1225 <ChevronRight size={13} className="shrink-0 text-faint transition-transform group-open/unit:rotate-90" />
1226 <StatusIcon status={standing.status === "calling" ? "in_progress" : standing.status} conclusion={standing.conclusion} of="job" size={14} />
1227 <span className="min-w-0 truncate">{unit.label}</span>
1228 {unit.kind === "matrix" && <span className="ml-auto shrink-0 text-xs text-faint">{unit.jobs.length}</span>}
1229 </summary>
1230 <div className="ml-3 space-y-0.5 border-l border-line pl-1.5">
1231 {unit.kind === "matrix" ? (
1232 unit.jobs.map((job) => <NavJob key={job.id} job={job} label={job.name.split(" / ").pop() ?? job.name} selected={selected} jobHref={jobHref} />)
1233 ) : (
1234 <>
1235 {unit.callers.map((job) => (
1236 <Link
1237 key={job.id}
1238 to={jobHref(job)}
1239 preventScrollReset
1240 aria-current={job.id === selected?.id ? "page" : undefined}
1241 className={navRow(job.id === selected?.id)}
1242 >
1243 <Workflow size={14} className="shrink-0 text-faint" />
1244 <span className="min-w-0 truncate">{unit.uses ? `Calls ${unit.uses.split("/").pop()}` : job.name}</span>
1245 </Link>
1246 ))}
1247 {unit.units.map((inner) => (
1248 <NavUnit key={inner.key} unit={inner} selected={selected} jobHref={jobHref} />
1249 ))}
1250 </>
1251 )}
1252 </div>
1253 </details>
1254 );
1255}
1256
1257/** The run's pages: its summary, its jobs grouped as the graph groups them, and where it came from. */
1258function RunNav({
1259 run,
1260 jobs,
1261 selected,
1262 base,
1263 jobHref,
1264 summaryHref,
1265}: {
1266 run: RunData["detail"]["run"];
1267 jobs: Job[];
1268 selected: Job | null;
1269 base: string;
1270 jobHref: (job: { id: string }) => string;
1271 summaryHref: string;
1272}) {
1273 const units = groupJobs(jobs);
1274 const heading = "px-2.5 pt-4 pb-1 text-xs font-medium text-faint";
1275 return (
1276 <nav aria-label="Run" className="space-y-0.5 text-sm">
1277 <Link to={summaryHref} preventScrollReset aria-current={selected ? undefined : "page"} className={navRow(!selected)}>
1278 <LayoutList size={14} className="shrink-0" />
1279 Summary
1280 </Link>
1281 <p className={heading}>All jobs</p>
1282 {units.map((unit) => (
1283 <NavUnit key={unit.key} unit={unit} selected={selected} jobHref={jobHref} />
1284 ))}
1285 <p className={heading}>Run details</p>
1286 {!isManagedWorkflow(run.path) && (
1287 <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}>
1288 <FileCode size={14} className="shrink-0" />
1289 Workflow file
1290 </Link>
1291 )}
1292 </nav>
1293 );
1294}
1295
1296export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
1297 const { detail, artifacts, member, summaries } = loaderData;
1298 const { run, jobs, notes } = detail;
1299 const deployments = detail.pendingDeployments ?? [];
1300 const attempts = detail.attempts ?? [];
1301 const base = `/${params.owner}/${params.repo}`;
1302 const [search] = useSearchParams();
1303 // One of the run's buttons is working: the others wait for it.
1304 const busy = usePending();
1305 // The attempt shown is still going.
1306 const live = run.status !== "completed";
1307 // Cancel run while any of the run is going, from whichever attempt's
1308 // page; re-running is for the latest attempt of a finished run.
1309 const { latest, going, cancelling, canCancel, canRerun } = runControls({ run, jobs, attempts, member });
1310 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
1311 // Its jobs starting and finishing, and its end, arrive live; the steps
1312 // inside a job are read again every 10 s with the live socket (its log
1313 // streams on its own), every few seconds without it. Waiting on a person
1314 // needs no quick refresh.
1315 const { live: heard } = useRepoLive({ match: repoPages.run(run.id, run.sha) });
1316 useRefreshWhile(going, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500);
1317
1318 // The job asked for; none is the run's summary.
1319 const selected = jobs.find((job) => job.id === search.get("job")) ?? null;
1320 const jobHref = (job: { id: string }) => (latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`);
1321 const summaryHref = latest ? "?" : `?attempt=${run.attempt}`;
1322 const standing = runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run));
1323 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
1324
1325 return (
1326 <div className="max-w-6xl space-y-6">
1327 <header className="space-y-3">
1328 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
1329 {run.name}
1330 </Link>
1331 <div className="flex flex-wrap items-start justify-between gap-4">
1332 <h2 className="flex min-w-0 items-start gap-2.5 text-xl font-semibold tracking-tight sm:items-center">
1333 <span className="mt-1 shrink-0 sm:mt-0">
1334 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
1335 </span>
1336 <span className="min-w-0 wrap-break-word sm:truncate">{run.title || run.name}</span>
1337 <span className="font-normal text-muted">#{run.number}</span>
1338 </h2>
1339 <div className="flex flex-wrap items-center gap-2">
1340 {attempts.length > 1 && <AttemptPicker attempts={attempts} shown={run.attempt} />}
1341 {jobs.some((job) => job.startedAt) && (
1342 <Hint label={latest ? "Every job's log, as a zip" : `Attempt #${run.attempt}'s logs, as a zip`}>
1343 <Button asChild variant="outline">
1344 <a href={logsUrl}>
1345 <Download size={13} />
1346 Download logs
1347 </a>
1348 </Button>
1349 </Hint>
1350 )}
1351 {canCancel && (
1352 <Form method="post" className="flex gap-2">
1353 {cancelling ? (
1354 <Hint label="Stop its jobs now, without waiting for their cleanup steps">
1355 <SubmitButton name="intent" value="force-cancel" variant="destructive" disabled={busy} pending="Stopping…">
1356 <Square size={13} />
1357 Force cancel
1358 </SubmitButton>
1359 </Hint>
1360 ) : (
1361 <SubmitButton name="intent" value="cancel" variant="outline" disabled={busy} pending="Cancelling…">
1362 <Square size={13} />
1363 Cancel run
1364 </SubmitButton>
1365 )}
1366 </Form>
1367 )}
1368 {canRerun && anyFailed && (
1369 <RerunDialog
1370 intent="rerun-failed"
1371 title="Re-run failed jobs"
1372 description="The jobs that did not succeed run again as a new attempt, with every job that needs them. The others keep how they ended."
1373 busy={busy}
1374 >
1375 {(open) => (
1376 <Button type="button" variant="outline" disabled={busy} onClick={open}>
1377 <RotateCw size={13} />
1378 Re-run failed jobs
1379 </Button>
1380 )}
1381 </RerunDialog>
1382 )}
1383 {canRerun && (
1384 <RerunDialog
1385 intent="rerun"
1386 title="Re-run all jobs"
1387 description="Every job runs again as a new attempt. This attempt stays here, with its logs."
1388 busy={busy}
1389 >
1390 {(open) => (
1391 <Button type="button" variant="outline" disabled={busy} onClick={open}>
1392 <RotateCw size={13} />
1393 Re-run all jobs
1394 </Button>
1395 )}
1396 </RerunDialog>
1397 )}
1398 </div>
1399 </div>
1400 {selected && (
1401 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
1402 <span>{standing}</span>
1403 {/* A pull request's ref is its number, which the link beside it already shows. */}
1404 {!(run.pull != null && /^refs\/pull\//.test(run.ref)) && (
1405 <span className="inline-flex items-center gap-1 font-mono text-xs">
1406 <GitBranch size={12} />
1407 {shortRef(run.ref)}
1408 </span>
1409 )}
1410 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
1411 <GitCommitHorizontal size={12} />
1412 {run.sha.slice(0, 7)}
1413 </Link>
1414 {run.pull != null && (
1415 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
1416 #{run.pull}
1417 </Link>
1418 )}
1419 <span>
1420 {run.event}
1421 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
1422 </span>
1423 {run.startedAt && <Duration className="font-mono text-xs" start={run.startedAt} end={run.finishedAt} />}
1424 {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>}
1425 {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>}
1426 {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>}
1427 </p>
1428 )}
1429 </header>
1430
1431 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
1432 {!latest && (
1433 <Card asChild className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-3 text-sm text-muted">
1434 <p>
1435 <History size={15} className="shrink-0" />
1436 <span>
1437 This is attempt #{run.attempt} of {attempts.length}, as it ended.
1438 </span>
1439 <Link to="?" className="text-accent hover:underline">
1440 See the latest attempt
1441 </Link>
1442 </p>
1443 </Card>
1444 )}
1445 {detail.approval?.state === "required" && <ApprovalPanel approval={detail.approval} member={member} busy={busy} />}
1446 {live && held(deployments) && <DeploymentsPanel deployments={deployments} busy={busy} />}
1447 {run.error && (
1448 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
1449 <p className="font-medium text-danger">The workflow file could not be used</p>
1450 <p className="mt-1 whitespace-pre-wrap wrap-break-word text-fg/85">{run.error}</p>
1451 </div>
1452 )}
1453 <Notes notes={notes} />
1454
1455 {jobs.length === 0 ? (
1456 <RunSummary
1457 detail={detail}
1458 artifacts={artifacts}
1459 summaries={summaries}
1460 standing={standing}
1461 cancelling={cancelling}
1462 member={member}
1463 busy={busy}
1464 base={base}
1465 jobHref={jobHref}
1466 />
1467 ) : (
1468 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
1469 <DetailsDisclosure label="Jobs" summary={selected ? `${jobs.length} · ${selected.name}` : `${jobs.length} · Summary`} bodyClassName="space-y-0.5">
1470 <RunNav run={run} jobs={jobs} selected={selected} base={base} jobHref={jobHref} summaryHref={summaryHref} />
1471 </DetailsDisclosure>
1472 {/* Run again, a job keeps its id but its log starts afresh. */}
1473 {selected ? (
1474 <JobView
1475 key={`${selected.id}:${run.attempt}`}
1476 job={selected}
1477 base={base}
1478 rerun={
1479 canRerun && selected.status === "completed" ? (
1480 <RerunDialog
1481 intent="rerun-job"
1482 job={selected.id}
1483 title="Re-run this job"
1484 description={`${selected.name} runs again as a new attempt, with every job that needs it. A job of a matrix runs again with the rest of its matrix.`}
1485 busy={busy}
1486 >
1487 {(open) => (
1488 <Hint label="Re-run this job">
1489 <Button
1490 type="button"
1491 aria-label={`Re-run ${selected.name}`}
1492 disabled={busy}
1493 onClick={open}
1494 variant="ghost"
1495 size="inline"
1496 className="p-1.5 ring-1 ring-line"
1497 >
1498 <RotateCw size={13} />
1499 </Button>
1500 </Hint>
1501 )}
1502 </RerunDialog>
1503 ) : null
1504 }
1505 />
1506 ) : (
1507 <RunSummary
1508 detail={detail}
1509 artifacts={artifacts}
1510 summaries={summaries}
1511 standing={standing}
1512 cancelling={cancelling}
1513 member={member}
1514 busy={busy}
1515 base={base}
1516 jobHref={jobHref}
1517 />
1518 )}
1519 </div>
1520 )}
1521 </div>
1522 );
1523}