Skip to content

g1t/apps/web/app/routes/repo/actions-run.tsx

596 lines26,758 bytesCodeBlameRaw
1import { AlertTriangle, Check, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Hourglass, Info, Package, Play, RotateCw, ServerCog, ShieldAlert, Square, Trash2, Users, X, XCircle } from "lucide-react";
2import { type ReactNode } from "react";
3import { Form, Link, useLoaderData, useSearchParams } from "react-router";
4
5import type { Annotation, EnvironmentReviewer, Job, PendingDeployment, RunApproval, StepState } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions-run";
8import { page } from "../../lib/meta";
9import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
10import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
11import { Hint } from "../../components/ui/hint";
12import { listArtifacts } from "../../lib/artifacts.server";
13import { expiresIn, formatBytes } from "../../lib/artifacts";
14import { actions } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
16import { accessTo, refusal } from "../../lib/access.server";
17import { useRefreshWhile } from "../../lib/refresh";
18
19export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
20 const run = loaderData?.detail.run;
21 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
22}
23
24export async function loader({ params, context }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 const repo = { namespace: params.owner, name: params.repo };
27 const [detail, artifacts] = await Promise.all([
28 actions.run(repo, viewer, params.id).then(unwrap),
29 listArtifacts(repo, viewer, params.id).catch(() => []),
30 ]);
31 // Cancelling and re-running need Write.
32 return {
33 detail,
34 artifacts,
35 member: (await accessTo(context, params)).can.run,
36 // The runners page is the workspace owners'.
37 runnersPage: roleIn(viewer, params.owner) === "owner",
38 };
39}
40
41export async function action({ request, params, context }: Route.ActionArgs) {
42 assertSameOrigin(request);
43 const user = requireUser(context, request);
44 const repo = { namespace: params.owner, name: params.repo };
45 const form = await request.formData();
46 const intent = String(form.get("intent"));
47 // Reviewing a deployment is the environment's reviewers' to do: the
48 // service says who may.
49 if (intent === "approve-deployment" || intent === "reject-deployment") {
50 const environment = String(form.get("environment") ?? "");
51 const comment = String(form.get("comment") ?? "").trim();
52 const done = await actions.reviewDeployments(
53 user,
54 repo,
55 params.id,
56 intent === "approve-deployment" ? "approved" : "rejected",
57 environment ? [environment] : [],
58 comment || undefined,
59 );
60 return done.ok ? {} : { error: done.error.message };
61 }
62 // Everything else, approving a run from outside included, needs Write.
63 const refused = await refusal(context, params, "run");
64 if (refused) return { error: refused };
65 if (intent === "delete-artifact") {
66 const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact")));
67 return deleted.ok ? {} : { error: deleted.error.message };
68 }
69 const done =
70 intent === "approve-run"
71 ? await actions.approveRun(user, repo, params.id)
72 : intent === "cancel"
73 ? await actions.cancel(user, repo, params.id)
74 : await actions.rerun(user, repo, params.id, intent === "rerun-failed");
75 return done.ok ? {} : { error: done.error.message };
76}
77
78const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
79 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
80 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
81 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
82};
83
84function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) {
85 return (
86 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
87 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
88 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
89 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
90 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
91 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
92 </summary>
93 <div className="border-t border-line bg-bg/60">
94 {text === undefined ? (
95 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
96 ) : (
97 <LogText text={text} />
98 )}
99 </div>
100 </details>
101 );
102}
103
104/** Why a job did not run as asked, with "Integrations" linked where it says so. */
105function Reason({ text, workspace }: { text: string; workspace: string }) {
106 const [before, after] = text.split("Integrations");
107 return (
108 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">
109 {after === undefined ? (
110 text
111 ) : (
112 <>
113 {before}
114 <Link to={`/${workspace}/-/integrations`} className="text-accent hover:underline">
115 Integrations
116 </Link>
117 {after}
118 </>
119 )}
120 </p>
121 );
122}
123
124/** Where the job ran: g1t's own runners, or a self-hosted one by name. */
125function RanOn({ job, workspace }: { job: Job; workspace: string }) {
126 const runnersPage = useLoaderData<typeof loader>().runnersPage;
127 if (job.selfHosted) {
128 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";
129 const label = (
130 <>
131 <ServerCog size={13} />
132 {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"}
133 </>
134 );
135 // A link to the runners for those who manage them, a label for everyone else.
136 return runnersPage ? (
137 <Hint label="Self-hosted runner">
138 <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}>
139 {label}
140 </Link>
141 </Hint>
142 ) : (
143 <Hint label="Self-hosted runner">
144 <span className={badge}>{label}</span>
145 </Hint>
146 );
147 }
148 if (!job.startedAt) return null;
149 return (
150 <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">
151 <Cloud size={13} />
152 g1t
153 </span>
154 );
155}
156
157function JobView({ job, base }: { job: Job; base: string }) {
158 const live = job.status !== "completed";
159 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
160 const failed = job.steps.find((s) => s.conclusion === "failure");
161 const running = job.steps.find((s) => s.status === "in_progress");
162 return (
163 <section className="min-w-0 space-y-4">
164 <div className="flex flex-wrap items-center gap-3">
165 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={18} />
166 <h3 className="text-base font-semibold">{job.name}</h3>
167 <span className="text-sm text-muted">
168 {standingWord({ ...job, of: "job" })}
169 {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
170 </span>
171 <RanOn job={job} workspace={base.split("/")[1]!} />
172 </div>
173 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
174 {job.annotations.length > 0 && (
175 <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
176 {job.annotations.map((note, index) => (
177 <li key={index} className="flex gap-2">
178 {ANNOTATION_ICON[note.level]}
179 <span className="min-w-0">
180 {note.title && <span className="font-medium">{note.title}: </span>}
181 <span className="whitespace-pre-wrap">{note.message}</span>
182 {note.file && (
183 <span className="block font-mono text-xs text-faint">
184 {note.file}
185 {note.line != null && `:${note.line}`}
186 </span>
187 )}
188 </span>
189 </li>
190 ))}
191 </ul>
192 )}
193 <div className="overflow-hidden rounded-xl border border-line bg-surface">
194 <StepRow
195 step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }}
196 text={log.get(0)}
197 defaultOpen={false}
198 />
199 {job.steps.map((step) => (
200 <StepRow
201 key={step.number}
202 step={step}
203 text={log.get(step.number)}
204 defaultOpen={step.number === (failed ?? running)?.number}
205 />
206 ))}
207 </div>
208 </section>
209 );
210}
211
212/** A run held by its environments, in words that say what for: a review, or only a wait timer. */
213function runWord(status: string, deployments: PendingDeployment[]): string | null {
214 if (status !== "waiting") return null;
215 const waiting = deployments.filter((deployment) => deployment.state === "waiting");
216 return waiting.length > 0 && waiting.every((deployment) => !deployment.needsReview) ? "Waiting for a wait timer" : null;
217}
218
219/** Whether a wait timer has yet to run out. */
220function timerRunning(deployment: PendingDeployment): boolean {
221 return Boolean(deployment.waitUntil && new Date(deployment.waitUntil).getTime() > Date.now());
222}
223
224/** Whether environments still hold some of the run's jobs. */
225function held(deployments: PendingDeployment[]): boolean {
226 return deployments.some(
227 (deployment) => deployment.state === "waiting" || (deployment.state === "approved" && timerRunning(deployment)),
228 );
229}
230
231/** `in 12m`, `in 2h 5m`: how long until a time. */
232function untilWords(at: string): string {
233 const minutes = Math.max(1, Math.ceil((new Date(at).getTime() - Date.now()) / 60_000));
234 if (minutes < 60) return `in ${minutes}m`;
235 const hours = Math.floor(minutes / 60);
236 if (hours < 48) return `in ${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
237 return `in ${Math.round(hours / 24)}d`;
238}
239
240/** A user by name, a team as @team. */
241function ReviewerChip({ reviewer }: { reviewer: EnvironmentReviewer }) {
242 return (
243 <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">
244 {reviewer.type === "team" && <Users size={11} className="text-muted" />}
245 {reviewer.type === "team" ? `@${reviewer.name}` : reviewer.name}
246 </span>
247 );
248}
249
250/** A pull request's run from outside, waiting for someone with Write to let it start. */
251function ApprovalPanel({ approval, member, busy }: { approval: RunApproval; member: boolean; busy: boolean }) {
252 return (
253 <section className="flex flex-col gap-4 rounded-xl border border-warn/30 bg-warn/5 p-4 sm:flex-row sm:items-start">
254 <ShieldAlert size={18} className="mt-0.5 hidden shrink-0 text-warn sm:block" />
255 <div className="min-w-0 grow space-y-1.5 text-sm">
256 <h3 className="flex items-center gap-2 font-medium">
257 <ShieldAlert size={16} className="shrink-0 text-warn sm:hidden" />
258 Approval required
259 </h3>
260 <p className="text-fg/85">{approval.reason}</p>
261 <p className="text-muted">
262 Nothing in this run starts, and it gets no secrets and no token, until someone with the Write role approves it.
263 {!member && " Ask someone with the Write role on this repository to approve it."}
264 </p>
265 </div>
266 {member && (
267 <Form method="post" className="shrink-0">
268 <SubmitButton name="intent" value="approve-run" variant="accent" disabled={busy} pending="Approving…">
269 <Play size={13} />
270 Approve and run
271 </SubmitButton>
272 </Form>
273 )}
274 </section>
275 );
276}
277
278const DEPLOYMENT_ICON: Record<PendingDeployment["state"], ReactNode> = {
279 waiting: <Hourglass size={15} className="shrink-0 text-warn" />,
280 approved: (
281 <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success">
282 <Check size={11} strokeWidth={3} />
283 </span>
284 ),
285 rejected: (
286 <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger">
287 <X size={11} strokeWidth={3} />
288 </span>
289 ),
290};
291
292/** Where one environment's rules stand for this run, and a way to review it for those who may. */
293function DeploymentRow({ deployment, busy }: { deployment: PendingDeployment; busy: boolean }) {
294 const waiting = deployment.state === "waiting";
295 const timer = timerRunning(deployment);
296 const words =
297 deployment.state === "approved"
298 ? timer
299 ? "Approved · waiting for its wait timer"
300 : "Approved"
301 : deployment.state === "rejected"
302 ? "Rejected"
303 : deployment.needsReview
304 ? "Waiting for review"
305 : "Waiting for its wait timer";
306 return (
307 <li className="space-y-3 px-4 py-4">
308 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
309 {DEPLOYMENT_ICON[deployment.state]}
310 <span className="font-mono text-sm font-medium">{deployment.environment}</span>
311 <span className="text-sm text-muted">{words}</span>
312 </div>
313 <dl className="grid gap-x-4 gap-y-1.5 text-sm sm:grid-cols-[7rem_1fr]">
314 {deployment.jobs.length > 0 && (
315 <>
316 <dt className="text-muted">{deployment.jobs.length === 1 ? "Job" : "Jobs"}</dt>
317 <dd className="flex min-w-0 flex-wrap gap-1.5">
318 {deployment.jobs.map((job) => (
319 <span key={job} className="rounded-md bg-raised px-1.5 py-0.5 text-xs">
320 {job}
321 </span>
322 ))}
323 </dd>
324 </>
325 )}
326 {deployment.reviewers.length > 0 && (
327 <>
328 <dt className="text-muted">Reviewers</dt>
329 <dd className="flex min-w-0 flex-wrap gap-1.5">
330 {deployment.reviewers.map((reviewer) => (
331 <ReviewerChip key={`${reviewer.type}:${reviewer.name}`} reviewer={reviewer} />
332 ))}
333 </dd>
334 </>
335 )}
336 {deployment.waitUntil && (waiting || timer) && (
337 <>
338 <dt className="text-muted">Wait timer</dt>
339 <dd>
340 {timer ? (
341 <>
342 Starts at{" "}
343 <time dateTime={deployment.waitUntil} suppressHydrationWarning>
344 {new Date(deployment.waitUntil).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
345 </time>{" "}
346 <span className="text-muted" suppressHydrationWarning>
347 ({untilWords(deployment.waitUntil)})
348 </span>
349 </>
350 ) : (
351 "Done"
352 )}
353 </dd>
354 </>
355 )}
356 {deployment.reviewedBy && (
357 <>
358 <dt className="text-muted">{deployment.state === "rejected" ? "Rejected by" : "Approved by"}</dt>
359 <dd className="min-w-0">
360 <span className="font-mono text-xs">{deployment.reviewedBy}</span>
361 {deployment.reviewedAt && (
362 <span className="text-muted">
363 {" "}
364 · <TimeAgo at={deployment.reviewedAt} />
365 </span>
366 )}
367 {deployment.comment && <p className="mt-1 whitespace-pre-wrap break-words text-fg/85">{deployment.comment}</p>}
368 </dd>
369 </>
370 )}
371 </dl>
372 {waiting && deployment.needsReview && deployment.canReview && (
373 <Form method="post" className="space-y-3">
374 <input type="hidden" name="environment" value={deployment.environment} />
375 <label className="block">
376 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
377 <textarea
378 name="comment"
379 rows={2}
380 maxLength={1000}
381 placeholder="Why you approve or reject it"
382 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"
383 />
384 </label>
385 <div className="flex flex-wrap gap-2">
386 <SubmitButton name="intent" value="approve-deployment" variant="accent" disabled={busy} pending="Approving…">
387 <Check size={13} />
388 Approve and deploy
389 </SubmitButton>
390 <SubmitButton name="intent" value="reject-deployment" variant="danger" disabled={busy} pending="Rejecting…">
391 <X size={13} />
392 Reject
393 </SubmitButton>
394 </div>
395 </Form>
396 )}
397 {waiting && deployment.needsReview && !deployment.canReview && (
398 <p className="text-sm text-muted">
399 {deployment.reviewers.length > 0
400 ? "Only its reviewers can approve or reject it."
401 : "Only someone this environment's rules name can approve or reject it."}
402 </p>
403 )}
404 </li>
405 );
406}
407
408/** The environments holding the run's jobs, each with where its rules stand. */
409function DeploymentsPanel({ deployments, busy }: { deployments: PendingDeployment[]; busy: boolean }) {
410 const review = deployments.some((deployment) => deployment.state === "waiting" && deployment.needsReview);
411 return (
412 <section className="overflow-hidden rounded-xl border border-line bg-surface">
413 <header className="flex items-start gap-2.5 border-b border-line px-4 py-3">
414 <Hourglass size={16} className="mt-0.5 shrink-0 text-warn" />
415 <div className="min-w-0">
416 <h3 className="text-sm font-medium">{review ? "Waiting for review" : "Waiting to deploy"}</h3>
417 <p className="mt-0.5 text-sm text-muted">
418 Jobs that deploy to these environments wait until each environment's protection rules let them through, and
419 only then get its secrets.
420 </p>
421 </div>
422 </header>
423 <ul className="divide-y divide-line">
424 {deployments.map((deployment) => (
425 <DeploymentRow key={deployment.environment} deployment={deployment} busy={busy} />
426 ))}
427 </ul>
428 </section>
429 );
430}
431
432export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
433 const { detail, artifacts, member } = loaderData;
434 const { run, jobs, notes } = detail;
435 const deployments = detail.pendingDeployments ?? [];
436 const base = `/${params.owner}/${params.repo}`;
437 const [search] = useSearchParams();
438 // One of the run's buttons is working: the others wait for it.
439 const busy = usePending();
440 const live = run.status !== "completed";
441 // Waiting on a person needs no quick refresh; a running job does.
442 useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500);
443
444 // The job asked for, else one that failed, is running, or the first.
445 const selected =
446 jobs.find((job) => job.id === search.get("job")) ??
447 jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ??
448 jobs.find((job) => job.status === "in_progress") ??
449 jobs[0];
450 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
451
452 return (
453 <div className="max-w-6xl space-y-6">
454 <header className="space-y-3">
455 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
456 {run.name}
457 </Link>
458 <div className="flex flex-wrap items-start justify-between gap-4">
459 <h2 className="flex min-w-0 items-center gap-2.5 text-xl font-semibold tracking-tight">
460 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
461 <span className="min-w-0 truncate">{run.title || run.name}</span>
462 <span className="font-normal text-muted">#{run.number}</span>
463 </h2>
464 {member && !run.error && (
465 <Form method="post" className="flex gap-2">
466 {live ? (
467 <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
468 <Square size={13} />
469 Cancel run
470 </SubmitButton>
471 ) : (
472 <>
473 {anyFailed && (
474 <SubmitButton name="intent" value="rerun-failed" variant="quiet" disabled={busy} pending="Re-running…">
475 <RotateCw size={13} />
476 Re-run failed jobs
477 </SubmitButton>
478 )}
479 <SubmitButton name="intent" value="rerun" variant="quiet" disabled={busy} pending="Re-running…">
480 <RotateCw size={13} />
481 Re-run all jobs
482 </SubmitButton>
483 </>
484 )}
485 </Form>
486 )}
487 </div>
488 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
489 <span>{runWord(run.status, deployments) ?? standingWord(run)}</span>
490 <span className="inline-flex items-center gap-1 font-mono text-xs">
491 <GitBranch size={12} />
492 {shortRef(run.ref)}
493 </span>
494 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
495 <GitCommitHorizontal size={12} />
496 {run.sha.slice(0, 7)}
497 </Link>
498 {run.pull != null && (
499 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
500 #{run.pull}
501 </Link>
502 )}
503 <span>
504 {run.event}
505 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
506 </span>
507 {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
508 {run.attempt > 1 && <span>Attempt {run.attempt}</span>}
509 {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>}
510 </p>
511 </header>
512
513 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
514 {detail.approval?.state === "required" && <ApprovalPanel approval={detail.approval} member={member} busy={busy} />}
515 {live && held(deployments) && <DeploymentsPanel deployments={deployments} busy={busy} />}
516 {run.error && (
517 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
518 <p className="font-medium text-danger">The workflow file could not be used</p>
519 <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
520 </div>
521 )}
522 <Notes notes={notes} />
523 {artifacts.length > 0 && (
524 <section className="rounded-xl border border-line bg-surface p-4">
525 <h3 className="flex items-center gap-2 text-sm font-medium">
526 <Package size={14} className="text-muted" />
527 Artifacts
528 <span className="font-normal text-faint">
529 · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))}
530 </span>
531 </h3>
532 <ul className="mt-3 divide-y divide-line text-sm">
533 {artifacts.map((artifact) => (
534 <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
535 <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span>
536 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
537 <span>{formatBytes(artifact.size)}</span>
538 {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>}
539 </span>
540 <span className="ml-auto flex shrink-0 items-center gap-1.5">
541 <a
542 href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(artifact.name)}`}
543 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg"
544 >
545 <Download size={12} />
546 Download
547 </a>
548 {member && artifact.id != null && (
549 <Form method="post" preventScrollReset>
550 <input type="hidden" name="artifact" value={artifact.id} />
551 <Hint label={`Delete ${artifact.name} now`}>
552 <SubmitButton
553 name="intent"
554 value="delete-artifact"
555 icon
556 disabled={busy}
557 aria-label={`Delete ${artifact.name}`}
558 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-danger disabled:opacity-50"
559 >
560 <Trash2 size={12} />
561 </SubmitButton>
562 </Hint>
563 </Form>
564 )}
565 </span>
566 </li>
567 ))}
568 </ul>
569 </section>
570 )}
571
572 {jobs.length > 0 && (
573 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
574 <nav aria-label="Jobs" className="space-y-0.5 text-sm">
575 {jobs.map((job) => (
576 <Link
577 key={job.id}
578 to={`?job=${job.id}`}
579 preventScrollReset
580 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
581 job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
582 }`}
583 >
584 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={14} />
585 <span className="min-w-0 truncate">{job.name}</span>
586 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(job.startedAt, job.finishedAt)}</span>
587 </Link>
588 ))}
589 </nav>
590 {/* Run again, a job keeps its id but its log starts afresh. */}
591 {selected && <JobView key={`${selected.id}:${run.attempt}`} job={selected} base={base} />}
592 </div>
593 )}
594 </div>
595 );
596}