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