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