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