Skip to content
909 linesCodeBlameRaw
1import {
2 AlertTriangle,
3 Bug,
4 Check,
5 ChevronDown,
6 ChevronRight,
7 Cloud,
8 Download,
9 FileText,
10 GitBranch,
11 GitCommitHorizontal,
12 History,
13 Hourglass,
14 Info,
15 Package,
16 Play,
17 RotateCw,
18 Search,
19 ServerCog,
20 ShieldAlert,
21 Square,
22 Trash2,
23 Users,
24 X,
25 XCircle,
26} from "lucide-react";
27import { type ReactNode, useEffect, useRef, useState } from "react";
28import { Form, Link, useLoaderData, useSearchParams } from "react-router";
29
30import type { Annotation, EnvironmentReviewer, Job, JobSummary, PendingDeployment, RunApproval, RunAttempt, StepState } from "@g1t/contracts";
31
32import type { Route } from "./+types/actions-run";
33import { page } from "../../lib/meta";
34import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
35import { Markdown } from "../../components/markdown";
36import { Button, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
37import { CheckboxOption } from "../../components/ui/checkbox";
38import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
39import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
40import { Hint } from "../../components/ui/hint";
41import { searchLog } from "../../lib/log-lines";
42import { listArtifacts } from "../../lib/artifacts.server";
43import { expiresIn, formatBytes } from "../../lib/artifacts";
44import { actions } from "../../lib/services.server";
45import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
46import { accessTo, refusal } from "../../lib/access.server";
47import { useRefreshWhile } from "../../lib/refresh";
48
49export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
50 const run = loaderData?.detail.run;
51 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
52}
53
54export async function loader({ params, context, request }: Route.LoaderArgs) {
55 const viewer = getViewer(context);
56 const repo = { namespace: params.owner, name: params.repo };
57 // An earlier attempt, when one is asked for.
58 const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
59 const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined;
60 const [detail, artifacts, summaries] = await Promise.all([
61 actions.run(repo, viewer, params.id, attempt).then(unwrap),
62 listArtifacts(repo, viewer, params.id).catch(() => []),
63 actions
64 .summaries(repo, viewer, params.id, attempt)
65 .then((found) => (found.ok ? found.value : []))
66 .catch((): JobSummary[] => []),
67 ]);
68 // Cancelling and re-running need Write.
69 return {
70 detail,
71 artifacts,
72 summaries,
73 member: (await accessTo(context, params)).can.run,
74 // The runners page is the workspace owners'.
75 runnersPage: roleIn(viewer, params.owner) === "owner",
76 };
77}
78
79export async function action({ request, params, context }: Route.ActionArgs) {
80 assertSameOrigin(request);
81 const user = requireUser(context, request);
82 const repo = { namespace: params.owner, name: params.repo };
83 const form = await request.formData();
84 const intent = String(form.get("intent"));
85 // Reviewing a deployment is the environment's reviewers' to do: the
86 // service says who may.
87 if (intent === "approve-deployment" || intent === "reject-deployment") {
88 const environment = String(form.get("environment") ?? "");
89 const comment = String(form.get("comment") ?? "").trim();
90 const done = await actions.reviewDeployments(
91 user,
92 repo,
93 params.id,
94 intent === "approve-deployment" ? "approved" : "rejected",
95 environment ? [environment] : [],
96 comment || undefined,
97 );
98 return done.ok ? {} : { error: done.error.message };
99 }
100 // Everything else, approving a run from outside included, needs Write.
101 const refused = await refusal(context, params, "run");
102 if (refused) return { error: refused };
103 if (intent === "delete-artifact") {
104 const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact")));
105 return deleted.ok ? {} : { error: deleted.error.message };
106 }
107 // Debug logging, for a re-run that asks for it.
108 const debug = form.get("debug") === "on";
109 const job = String(form.get("job") ?? "") || undefined;
110 const done =
111 intent === "approve-run"
112 ? await actions.approveRun(user, repo, params.id)
113 : intent === "cancel" || intent === "force-cancel"
114 ? await actions.cancel(user, repo, params.id, intent === "force-cancel")
115 : await actions.rerun(user, repo, params.id, intent === "rerun-failed", { job: intent === "rerun-job" ? job : undefined, debug });
116 return done.ok ? {} : { error: done.error.message };
117}
118
119const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
120 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
121 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
122 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
123};
124
125function StepRow({
126 step,
127 text,
128 defaultOpen,
129 query = "",
130 matches = 0,
131}: {
132 step: StepState;
133 text: string | undefined;
134 defaultOpen: boolean;
135 /** A search of the job's log: only its matching lines are shown. */
136 query?: string;
137 matches?: number;
138}) {
139 return (
140 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
141 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
142 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
143 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
144 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
145 {query && (
146 <span className="shrink-0 rounded-full bg-accent/15 px-1.5 text-xs text-accent">
147 {matches} {matches === 1 ? "line" : "lines"}
148 </span>
149 )}
150 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
151 </summary>
152 <div className="border-t border-line bg-bg/60">
153 {text === undefined ? (
154 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
155 ) : (
156 <LogText text={text} query={query} />
157 )}
158 </div>
159 </details>
160 );
161}
162
163/**
164 * Runs again, after asking whether with debug logging: the whole run, its
165 * failed jobs, or one job (with the jobs that need it).
166 */
167function RerunDialog({
168 intent,
169 job,
170 title,
171 description,
172 children,
173 busy,
174}: {
175 intent: "rerun" | "rerun-failed" | "rerun-job";
176 job?: string;
177 title: string;
178 description: string;
179 /** What opens it. */
180 children: (open: () => void) => ReactNode;
181 busy: boolean;
182}) {
183 const [open, setOpen] = useState(false);
184 // Open, saying it is starting, until the new attempt shows or the error does.
185 const running = usePending({ intent });
186 const was = useRef(false);
187 useEffect(() => {
188 if (was.current && !running) setOpen(false);
189 was.current = running;
190 }, [running]);
191 return (
192 <>
193 {children(() => setOpen(true))}
194 <Dialog open={open} onOpenChange={setOpen}>
195 <DialogContent>
196 <Form method="post" preventScrollReset className="grid gap-4">
197 <DialogHeader>
198 <DialogTitle>{title}</DialogTitle>
199 <DialogDescription>{description}</DialogDescription>
200 </DialogHeader>
201 {job && <input type="hidden" name="job" value={job} />}
202 <CheckboxOption
203 name="debug"
204 label="Enable debug logging"
205 description="Sets RUNNER_DEBUG=1 and ACTIONS_STEP_DEBUG: ::debug:: lines are shown, and how each step's if: read."
206 />
207 <DialogFooter>
208 <Button type="button" variant="quiet" onClick={() => setOpen(false)}>
209 Cancel
210 </Button>
211 <SubmitButton name="intent" value={intent} variant="accent" disabled={busy} pending="Re-running…">
212 <RotateCw size={13} />
213 {title}
214 </SubmitButton>
215 </DialogFooter>
216 </Form>
217 </DialogContent>
218 </Dialog>
219 </>
220 );
221}
222
223/** A link or menu button that sits beside the run's quiet buttons, at their size. */
224const QUIET_LINK =
225 "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";
226
227/** The run's attempts, newest first, each a link to how it went. */
228function AttemptPicker({ attempts, shown }: { attempts: RunAttempt[]; shown: number }) {
229 const latest = attempts[attempts.length - 1]?.attempt ?? shown;
230 return (
231 <DropdownMenu>
232 <DropdownMenuTrigger asChild>
233 <button type="button" className={QUIET_LINK}>
234 <History size={13} />
235 Attempt #{shown}
236 <ChevronDown size={13} />
237 </button>
238 </DropdownMenuTrigger>
239 <DropdownMenuContent align="end" className="min-w-64">
240 {[...attempts].reverse().map((attempt) => (
241 <DropdownMenuItem key={attempt.attempt} asChild>
242 <Link
243 to={attempt.attempt === latest ? "?" : `?attempt=${attempt.attempt}`}
244 preventScrollReset
245 className={`flex items-center gap-2 ${attempt.attempt === shown ? "text-fg" : ""}`}
246 >
247 <StatusIcon status={attempt.status} conclusion={attempt.conclusion} size={14} />
248 <span className="font-medium">Attempt #{attempt.attempt}</span>
249 {attempt.debug && <Bug size={12} className="text-faint" aria-label="Debug logging" />}
250 <span className="ml-auto truncate text-xs text-faint">
251 {attempt.actor && `${attempt.actor} · `}
252 {attempt.startedAt ? <TimeAgo at={attempt.startedAt} /> : "not started"}
253 </span>
254 </Link>
255 </DropdownMenuItem>
256 ))}
257 </DropdownMenuContent>
258 </DropdownMenu>
259 );
260}
261
262/** What the jobs' steps wrote to $GITHUB_STEP_SUMMARY, a card per job. */
263function Summaries({ summaries, repo }: { summaries: JobSummary[]; repo: { namespace: string; name: string } }) {
264 if (summaries.length === 0) return null;
265 return (
266 <section aria-label="Job summaries" className="space-y-4">
267 {summaries.map((summary) => (
268 <article key={summary.jobId} className="overflow-hidden rounded-xl border border-line bg-surface">
269 <header className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-sm">
270 <FileText size={14} className="text-muted" />
271 <span className="font-medium">{summary.name}</span>
272 <span className="text-faint">summary</span>
273 </header>
274 <div className="space-y-4 px-5 py-4">
275 {summary.steps.map((step) => (
276 <Markdown key={step.step} source={step.markdown} repo={repo} />
277 ))}
278 </div>
279 </article>
280 ))}
281 </section>
282 );
283}
284
285/** Why a job did not run as asked, with "Integrations" linked where it says so. */
286function Reason({ text, workspace }: { text: string; workspace: string }) {
287 const [before, after] = text.split("Integrations");
288 return (
289 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">
290 {after === undefined ? (
291 text
292 ) : (
293 <>
294 {before}
295 <Link to={`/${workspace}/-/integrations`} className="text-accent hover:underline">
296 Integrations
297 </Link>
298 {after}
299 </>
300 )}
301 </p>
302 );
303}
304
305/** Where the job ran: g1t's own runners, or a self-hosted one by name. */
306function RanOn({ job, workspace }: { job: Job; workspace: string }) {
307 const runnersPage = useLoaderData<typeof loader>().runnersPage;
308 if (job.selfHosted) {
309 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";
310 const label = (
311 <>
312 <ServerCog size={13} />
313 {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"}
314 </>
315 );
316 // A link to the runners for those who manage them, a label for everyone else.
317 return runnersPage ? (
318 <Hint label="Self-hosted runner">
319 <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}>
320 {label}
321 </Link>
322 </Hint>
323 ) : (
324 <Hint label="Self-hosted runner">
325 <span className={badge}>{label}</span>
326 </Hint>
327 );
328 }
329 if (!job.startedAt) return null;
330 return (
331 <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">
332 <Cloud size={13} />
333 g1t
334 </span>
335 );
336}
337
338function JobView({ job, base, rerun }: { job: Job; base: string; rerun: ReactNode }) {
339 const live = job.status !== "completed";
340 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
341 const [query, setQuery] = useState("");
342 const failed = job.steps.find((s) => s.conclusion === "failure");
343 const running = job.steps.find((s) => s.status === "in_progress");
344 const searching = query.trim().length > 0;
345 const setUp: StepState = {
346 number: 0,
347 name: "Set up job",
348 status: job.startedAt ? "completed" : "queued",
349 conclusion: job.startedAt ? "success" : null,
350 startedAt: job.startedAt,
351 finishedAt: job.startedAt,
352 };
353 // While searching, the steps with a match, each with how many.
354 const steps = [setUp, ...job.steps]
355 .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 }))
356 .filter(({ matches }) => !searching || matches > 0);
357 const total = steps.reduce((sum, { matches }) => sum + matches, 0);
358 return (
359 <section className="min-w-0 space-y-4">
360 <div className="flex flex-wrap items-center gap-3">
361 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={18} />
362 <h3 className="text-base font-semibold">{job.name}</h3>
363 <span className="text-sm text-muted">
364 {job.cancelling ? "Cancelling: running its cleanup steps" : standingWord({ ...job, of: "job" })}
365 {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
366 </span>
367 <span className="ml-auto flex items-center gap-2">
368 {rerun}
369 {job.startedAt && (
370 <Hint label="Download this job's log">
371 <a
372 href={`${base}/actions/jobs/${job.id}/log.txt`}
373 aria-label={`Download the log of ${job.name}`}
374 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg"
375 >
376 <Download size={13} />
377 </a>
378 </Hint>
379 )}
380 <RanOn job={job} workspace={base.split("/")[1]!} />
381 </span>
382 </div>
383 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
384 {job.annotations.length > 0 && (
385 <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
386 {job.annotations.map((note, index) => (
387 <li key={index} className="flex gap-2">
388 {ANNOTATION_ICON[note.level]}
389 <span className="min-w-0">
390 {note.title && <span className="font-medium">{note.title}: </span>}
391 <span className="whitespace-pre-wrap">{note.message}</span>
392 {note.file && (
393 <span className="block font-mono text-xs text-faint">
394 {note.file}
395 {note.line != null && `:${note.line}`}
396 </span>
397 )}
398 </span>
399 </li>
400 ))}
401 </ul>
402 )}
403 {job.startedAt && (
404 <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">
405 <Search size={14} className="shrink-0 text-faint" />
406 <input
407 type="search"
408 value={query}
409 onChange={(event) => setQuery(event.target.value)}
410 placeholder="Search logs"
411 aria-label={`Search the log of ${job.name}`}
412 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
413 />
414 {searching && (
415 <span className="shrink-0 text-xs text-muted" aria-live="polite">
416 {total} {total === 1 ? "line" : "lines"}
417 </span>
418 )}
419 </label>
420 )}
421 <div className="overflow-hidden rounded-xl border border-line bg-surface">
422 {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>}
423 {steps.map(({ step, matches }) => (
424 <StepRow
425 // Searching opens every step with a match; clearing it puts them back.
426 key={`${step.number}:${searching}`}
427 step={step}
428 text={log.get(step.number)}
429 defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)}
430 query={searching ? query : ""}
431 matches={matches}
432 />
433 ))}
434 </div>
435 </section>
436 );
437}
438
439/** A run held by its environments, in words that say what for: a review, or only a wait timer. */
440function runWord(status: string, deployments: PendingDeployment[]): string | null {
441 if (status !== "waiting") return null;
442 const waiting = deployments.filter((deployment) => deployment.state === "waiting");
443 return waiting.length > 0 && waiting.every((deployment) => !deployment.needsReview) ? "Waiting for a wait timer" : null;
444}
445
446/** Whether a wait timer has yet to run out. */
447function timerRunning(deployment: PendingDeployment): boolean {
448 return Boolean(deployment.waitUntil && new Date(deployment.waitUntil).getTime() > Date.now());
449}
450
451/** Whether environments still hold some of the run's jobs. */
452function held(deployments: PendingDeployment[]): boolean {
453 return deployments.some(
454 (deployment) => deployment.state === "waiting" || (deployment.state === "approved" && timerRunning(deployment)),
455 );
456}
457
458/** `in 12m`, `in 2h 5m`: how long until a time. */
459function untilWords(at: string): string {
460 const minutes = Math.max(1, Math.ceil((new Date(at).getTime() - Date.now()) / 60_000));
461 if (minutes < 60) return `in ${minutes}m`;
462 const hours = Math.floor(minutes / 60);
463 if (hours < 48) return `in ${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
464 return `in ${Math.round(hours / 24)}d`;
465}
466
467/** A user by name, a team as @team. */
468function ReviewerChip({ reviewer }: { reviewer: EnvironmentReviewer }) {
469 return (
470 <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">
471 {reviewer.type === "team" && <Users size={11} className="text-muted" />}
472 {reviewer.type === "team" ? `@${reviewer.name}` : reviewer.name}
473 </span>
474 );
475}
476
477/** A pull request's run from outside, waiting for someone with Write to let it start. */
478function ApprovalPanel({ approval, member, busy }: { approval: RunApproval; member: boolean; busy: boolean }) {
479 return (
480 <section className="flex flex-col gap-4 rounded-xl border border-warn/30 bg-warn/5 p-4 sm:flex-row sm:items-start">
481 <ShieldAlert size={18} className="mt-0.5 hidden shrink-0 text-warn sm:block" />
482 <div className="min-w-0 grow space-y-1.5 text-sm">
483 <h3 className="flex items-center gap-2 font-medium">
484 <ShieldAlert size={16} className="shrink-0 text-warn sm:hidden" />
485 Approval required
486 </h3>
487 <p className="text-fg/85">{approval.reason}</p>
488 <p className="text-muted">
489 Nothing in this run starts, and it gets no secrets and no token, until someone with the Write role approves it.
490 {!member && " Ask someone with the Write role on this repository to approve it."}
491 </p>
492 </div>
493 {member && (
494 <Form method="post" className="shrink-0">
495 <SubmitButton name="intent" value="approve-run" variant="accent" disabled={busy} pending="Approving…">
496 <Play size={13} />
497 Approve and run
498 </SubmitButton>
499 </Form>
500 )}
501 </section>
502 );
503}
504
505const DEPLOYMENT_ICON: Record<PendingDeployment["state"], ReactNode> = {
506 waiting: <Hourglass size={15} className="shrink-0 text-warn" />,
507 approved: (
508 <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success">
509 <Check size={11} strokeWidth={3} />
510 </span>
511 ),
512 rejected: (
513 <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger">
514 <X size={11} strokeWidth={3} />
515 </span>
516 ),
517};
518
519/** Where one environment's rules stand for this run, and a way to review it for those who may. */
520function DeploymentRow({ deployment, busy }: { deployment: PendingDeployment; busy: boolean }) {
521 const waiting = deployment.state === "waiting";
522 const timer = timerRunning(deployment);
523 const words =
524 deployment.state === "approved"
525 ? timer
526 ? "Approved · waiting for its wait timer"
527 : "Approved"
528 : deployment.state === "rejected"
529 ? "Rejected"
530 : deployment.needsReview
531 ? "Waiting for review"
532 : "Waiting for its wait timer";
533 return (
534 <li className="space-y-3 px-4 py-4">
535 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
536 {DEPLOYMENT_ICON[deployment.state]}
537 <span className="font-mono text-sm font-medium">{deployment.environment}</span>
538 <span className="text-sm text-muted">{words}</span>
539 </div>
540 <dl className="grid gap-x-4 gap-y-1.5 text-sm sm:grid-cols-[7rem_1fr]">
541 {deployment.jobs.length > 0 && (
542 <>
543 <dt className="text-muted">{deployment.jobs.length === 1 ? "Job" : "Jobs"}</dt>
544 <dd className="flex min-w-0 flex-wrap gap-1.5">
545 {deployment.jobs.map((job) => (
546 <span key={job} className="rounded-md bg-raised px-1.5 py-0.5 text-xs">
547 {job}
548 </span>
549 ))}
550 </dd>
551 </>
552 )}
553 {deployment.reviewers.length > 0 && (
554 <>
555 <dt className="text-muted">Reviewers</dt>
556 <dd className="flex min-w-0 flex-wrap gap-1.5">
557 {deployment.reviewers.map((reviewer) => (
558 <ReviewerChip key={`${reviewer.type}:${reviewer.name}`} reviewer={reviewer} />
559 ))}
560 </dd>
561 </>
562 )}
563 {deployment.waitUntil && (waiting || timer) && (
564 <>
565 <dt className="text-muted">Wait timer</dt>
566 <dd>
567 {timer ? (
568 <>
569 Starts at{" "}
570 <time dateTime={deployment.waitUntil} suppressHydrationWarning>
571 {new Date(deployment.waitUntil).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
572 </time>{" "}
573 <span className="text-muted" suppressHydrationWarning>
574 ({untilWords(deployment.waitUntil)})
575 </span>
576 </>
577 ) : (
578 "Done"
579 )}
580 </dd>
581 </>
582 )}
583 {deployment.reviewedBy && (
584 <>
585 <dt className="text-muted">{deployment.state === "rejected" ? "Rejected by" : "Approved by"}</dt>
586 <dd className="min-w-0">
587 <span className="font-mono text-xs">{deployment.reviewedBy}</span>
588 {deployment.reviewedAt && (
589 <span className="text-muted">
590 {" "}
591 · <TimeAgo at={deployment.reviewedAt} />
592 </span>
593 )}
594 {deployment.comment && <p className="mt-1 whitespace-pre-wrap break-words text-fg/85">{deployment.comment}</p>}
595 </dd>
596 </>
597 )}
598 </dl>
599 {waiting && deployment.needsReview && deployment.canReview && (
600 <Form method="post" className="space-y-3">
601 <input type="hidden" name="environment" value={deployment.environment} />
602 <label className="block">
603 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
604 <textarea
605 name="comment"
606 rows={2}
607 maxLength={1000}
608 placeholder="Why you approve or reject it"
609 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"
610 />
611 </label>
612 <div className="flex flex-wrap gap-2">
613 <SubmitButton name="intent" value="approve-deployment" variant="accent" disabled={busy} pending="Approving…">
614 <Check size={13} />
615 Approve and deploy
616 </SubmitButton>
617 <SubmitButton name="intent" value="reject-deployment" variant="danger" disabled={busy} pending="Rejecting…">
618 <X size={13} />
619 Reject
620 </SubmitButton>
621 </div>
622 </Form>
623 )}
624 {waiting && deployment.needsReview && !deployment.canReview && (
625 <p className="text-sm text-muted">
626 {deployment.reviewers.length > 0
627 ? "Only its reviewers can approve or reject it."
628 : "Only someone this environment's rules name can approve or reject it."}
629 </p>
630 )}
631 </li>
632 );
633}
634
635/** The environments holding the run's jobs, each with where its rules stand. */
636function DeploymentsPanel({ deployments, busy }: { deployments: PendingDeployment[]; busy: boolean }) {
637 const review = deployments.some((deployment) => deployment.state === "waiting" && deployment.needsReview);
638 return (
639 <section className="overflow-hidden rounded-xl border border-line bg-surface">
640 <header className="flex items-start gap-2.5 border-b border-line px-4 py-3">
641 <Hourglass size={16} className="mt-0.5 shrink-0 text-warn" />
642 <div className="min-w-0">
643 <h3 className="text-sm font-medium">{review ? "Waiting for review" : "Waiting to deploy"}</h3>
644 <p className="mt-0.5 text-sm text-muted">
645 Jobs that deploy to these environments wait until each environment's protection rules let them through, and
646 only then get its secrets.
647 </p>
648 </div>
649 </header>
650 <ul className="divide-y divide-line">
651 {deployments.map((deployment) => (
652 <DeploymentRow key={deployment.environment} deployment={deployment} busy={busy} />
653 ))}
654 </ul>
655 </section>
656 );
657}
658
659export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
660 const { detail, artifacts, member, summaries } = loaderData;
661 const { run, jobs, notes } = detail;
662 const deployments = detail.pendingDeployments ?? [];
663 const attempts = detail.attempts ?? [];
664 const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt;
665 const base = `/${params.owner}/${params.repo}`;
666 const [search] = useSearchParams();
667 // One of the run's buttons is working: the others wait for it.
668 const busy = usePending();
669 const live = run.status !== "completed";
670 // Cancelled, while its jobs run their cleanup steps.
671 const cancelling = live && run.conclusion === "cancelled";
672 // Re-running is for the latest attempt of a finished run.
673 const canRerun = member && !run.error && !live && latest;
674 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
675 // Waiting on a person needs no quick refresh; a running job does.
676 useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500);
677
678 // The job asked for, else one that failed, is running, or the first.
679 const selected =
680 jobs.find((job) => job.id === search.get("job")) ??
681 jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ??
682 jobs.find((job) => job.status === "in_progress") ??
683 jobs[0];
684 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
685
686 return (
687 <div className="max-w-6xl space-y-6">
688 <header className="space-y-3">
689 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
690 {run.name}
691 </Link>
692 <div className="flex flex-wrap items-start justify-between gap-4">
693 <h2 className="flex min-w-0 items-center gap-2.5 text-xl font-semibold tracking-tight">
694 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
695 <span className="min-w-0 truncate">{run.title || run.name}</span>
696 <span className="font-normal text-muted">#{run.number}</span>
697 </h2>
698 <div className="flex flex-wrap items-center gap-2">
699 {attempts.length > 1 && <AttemptPicker attempts={attempts} shown={run.attempt} />}
700 {jobs.some((job) => job.startedAt) && (
701 <Hint label={latest ? "Every job's log, as a zip" : `Attempt #${run.attempt}'s logs, as a zip`}>
702 <a href={logsUrl} className={QUIET_LINK}>
703 <Download size={13} />
704 Download logs
705 </a>
706 </Hint>
707 )}
708 {member && !run.error && live && (
709 <Form method="post" className="flex gap-2">
710 {cancelling ? (
711 <Hint label="Stop its jobs now, without waiting for their cleanup steps">
712 <SubmitButton name="intent" value="force-cancel" variant="danger" disabled={busy} pending="Stopping…">
713 <Square size={13} />
714 Force cancel
715 </SubmitButton>
716 </Hint>
717 ) : (
718 <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
719 <Square size={13} />
720 Cancel run
721 </SubmitButton>
722 )}
723 </Form>
724 )}
725 {canRerun && anyFailed && (
726 <RerunDialog
727 intent="rerun-failed"
728 title="Re-run failed jobs"
729 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."
730 busy={busy}
731 >
732 {(open) => (
733 <Button type="button" variant="quiet" disabled={busy} onClick={open}>
734 <RotateCw size={13} />
735 Re-run failed jobs
736 </Button>
737 )}
738 </RerunDialog>
739 )}
740 {canRerun && (
741 <RerunDialog
742 intent="rerun"
743 title="Re-run all jobs"
744 description="Every job runs again as a new attempt. This attempt stays here, with its logs."
745 busy={busy}
746 >
747 {(open) => (
748 <Button type="button" variant="quiet" disabled={busy} onClick={open}>
749 <RotateCw size={13} />
750 Re-run all jobs
751 </Button>
752 )}
753 </RerunDialog>
754 )}
755 </div>
756 </div>
757 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
758 <span>{runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run))}</span>
759 <span className="inline-flex items-center gap-1 font-mono text-xs">
760 <GitBranch size={12} />
761 {shortRef(run.ref)}
762 </span>
763 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
764 <GitCommitHorizontal size={12} />
765 {run.sha.slice(0, 7)}
766 </Link>
767 {run.pull != null && (
768 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
769 #{run.pull}
770 </Link>
771 )}
772 <span>
773 {run.event}
774 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
775 </span>
776 {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
777 {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>}
778 {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>}
779 {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>}
780 </p>
781 </header>
782
783 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
784 {!latest && (
785 <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">
786 <History size={15} className="shrink-0" />
787 <span>
788 This is attempt #{run.attempt} of {attempts.length}, as it ended.
789 </span>
790 <Link to="?" className="text-accent hover:underline">
791 See the latest attempt
792 </Link>
793 </p>
794 )}
795 {detail.approval?.state === "required" && <ApprovalPanel approval={detail.approval} member={member} busy={busy} />}
796 {live && held(deployments) && <DeploymentsPanel deployments={deployments} busy={busy} />}
797 {run.error && (
798 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
799 <p className="font-medium text-danger">The workflow file could not be used</p>
800 <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
801 </div>
802 )}
803 <Notes notes={notes} />
804 <Summaries summaries={summaries} repo={{ namespace: params.owner, name: params.repo }} />
805 {artifacts.length > 0 && (
806 <section className="rounded-xl border border-line bg-surface p-4">
807 <h3 className="flex items-center gap-2 text-sm font-medium">
808 <Package size={14} className="text-muted" />
809 Artifacts
810 <span className="font-normal text-faint">
811 · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))}
812 </span>
813 </h3>
814 <ul className="mt-3 divide-y divide-line text-sm">
815 {artifacts.map((artifact) => (
816 <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
817 <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span>
818 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
819 <span>{formatBytes(artifact.size)}</span>
820 {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>}
821 </span>
822 <span className="ml-auto flex shrink-0 items-center gap-1.5">
823 <a
824 href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(artifact.name)}`}
825 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg"
826 >
827 <Download size={12} />
828 Download
829 </a>
830 {member && artifact.id != null && (
831 <Form method="post" preventScrollReset>
832 <input type="hidden" name="artifact" value={artifact.id} />
833 <Hint label={`Delete ${artifact.name} now`}>
834 <SubmitButton
835 name="intent"
836 value="delete-artifact"
837 icon
838 disabled={busy}
839 aria-label={`Delete ${artifact.name}`}
840 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-danger disabled:opacity-50"
841 >
842 <Trash2 size={12} />
843 </SubmitButton>
844 </Hint>
845 </Form>
846 )}
847 </span>
848 </li>
849 ))}
850 </ul>
851 </section>
852 )}
853
854 {jobs.length > 0 && (
855 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
856 <nav aria-label="Jobs" className="space-y-0.5 text-sm">
857 {jobs.map((job) => (
858 <Link
859 key={job.id}
860 to={latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`}
861 preventScrollReset
862 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
863 job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
864 }`}
865 >
866 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={14} />
867 <span className="min-w-0 truncate">{job.name}</span>
868 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(job.startedAt, job.finishedAt)}</span>
869 </Link>
870 ))}
871 </nav>
872 {/* Run again, a job keeps its id but its log starts afresh. */}
873 {selected && (
874 <JobView
875 key={`${selected.id}:${run.attempt}`}
876 job={selected}
877 base={base}
878 rerun={
879 canRerun && selected.status === "completed" ? (
880 <RerunDialog
881 intent="rerun-job"
882 job={selected.id}
883 title="Re-run this job"
884 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.`}
885 busy={busy}
886 >
887 {(open) => (
888 <Hint label="Re-run this job">
889 <button
890 type="button"
891 aria-label={`Re-run ${selected.name}`}
892 disabled={busy}
893 onClick={open}
894 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg disabled:opacity-50"
895 >
896 <RotateCw size={13} />
897 </button>
898 </Hint>
899 )}
900 </RerunDialog>
901 ) : null
902 }
903 />
904 )}
905 </div>
906 )}
907 </div>
908 );
909}