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