Skip to content
228 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1import { ChevronDown, ChevronRight, ExternalLink, Workflow } from "lucide-react";
2import { useMemo, useState } from "react";
3import { Link } from "react-router";
4
5import { Duration, StatusIcon } from "./actions";
6import { GRAPH, type GraphJob, type EdgeState, type PlacedGroup, type PlacedNode, type Standing, layoutRun, sharedUrl, standingOf, unitStanding } from "../lib/run-graph";
7
8/** A job calling a workflow shows as running while its jobs do. */
9function Icon({ standing, environment, size = 14 }: { standing: Standing; environment?: string | null; size?: number }) {
10 const status = standing.status === "calling" ? "in_progress" : standing.status;
11 return <StatusIcon status={status} conclusion={standing.conclusion} of="job" environment={environment} size={size} />;
12}
13
14const EDGE: Record<EdgeState, string> = {
15 idle: "stroke-line-strong",
16 failed: "stroke-danger/70",
17 active: "stroke-warn animate-dash",
18};
19
20const NODE = "absolute overflow-hidden rounded-lg border bg-surface text-sm shadow-sm transition-colors";
21
22function nodeBorder(selected: boolean): string {
23 return selected ? "border-accent ring-1 ring-accent/40" : "border-line hover:border-line-strong";
24}
25
26function JobNode({ node, href, selected }: { node: PlacedNode & { unit: { kind: "job" } }; href: (job: GraphJob) => string; selected: string | null }) {
27 const { job, label } = node.unit;
28 return (
29 <div className={`${NODE} ${nodeBorder(job.id === selected)}`} style={{ left: node.x, top: node.y, width: node.w, height: node.h }}>
30 <Link
31 to={href(job)}
32 preventScrollReset
33 className="flex items-center gap-2 px-3 hover:bg-raised/40"
34 style={{ height: GRAPH.nodeHeight }}
35 aria-current={job.id === selected ? "page" : undefined}
36 >
37 <Icon standing={job} environment={job.environment} />
38 <span className="min-w-0 grow truncate font-medium">{label}</span>
39 <Duration className="shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} />
40 </Link>
41 {job.environmentUrl && <Address url={job.environmentUrl} />}
42 </div>
43 );
44}
45
46/** Where a deployment is, under its node's name. */
47function Address({ url }: { url: string }) {
48 return (
49 <a
50 href={url}
51 target="_blank"
52 rel="noreferrer"
53 className="-mt-2 flex items-center gap-1 truncate pr-3 pl-9 font-mono text-xs text-accent hover:underline"
54 style={{ height: GRAPH.urlHeight }}
55 >
56 <span className="truncate">{url.replace(/^https?:\/\//, "")}</span>
57 <ExternalLink size={11} className="shrink-0" />
58 </a>
59 );
60}
61
62function MatrixNode({
63 node,
64 href,
65 selected,
66 toggle,
67}: {
68 node: PlacedNode & { unit: { kind: "matrix" } };
69 href: (job: GraphJob) => string;
70 selected: string | null;
71 toggle: (key: string) => void;
72}) {
73 const { jobs, label, key } = node.unit;
74 const holdsSelected = jobs.some((job) => job.id === selected);
75 const url = sharedUrl(jobs);
76 return (
77 <div className={`${NODE} ${nodeBorder(holdsSelected && !node.expanded)}`} style={{ left: node.x, top: node.y, width: node.w, height: node.h }}>
78 <button
79 type="button"
80 onClick={() => toggle(key)}
81 aria-expanded={node.expanded}
82 className="flex w-full items-center gap-2 px-3 text-left hover:bg-raised/40"
83 style={{ height: GRAPH.nodeHeight }}
84 >
85 <Icon standing={standingOf(jobs)} />
86 <span className="min-w-0 grow truncate font-medium">{label}</span>
87 <span className="shrink-0 text-xs text-faint">{jobs.length} jobs</span>
88 {node.expanded ? <ChevronDown size={14} className="shrink-0 text-faint" /> : <ChevronRight size={14} className="shrink-0 text-faint" />}
89 </button>
90 {url && <Address url={url} />}
91 {node.expanded && (
92 <ul className="border-t border-line py-0.5">
93 {jobs.map((job) => (
94 <li key={job.id}>
95 <Link
96 to={href(job)}
97 preventScrollReset
98 aria-current={job.id === selected ? "page" : undefined}
99 className={`flex items-center gap-2 px-3 text-[0.8125rem] ${job.id === selected ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
100 style={{ height: GRAPH.rowHeight }}
101 >
102 <Icon standing={job} environment={job.environment} size={13} />
103 <span className="min-w-0 grow truncate">{matrixValues(job.name, label)}</span>
104 <Duration className="shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} />
105 </Link>
106 </li>
107 ))}
108 </ul>
109 )}
110 </div>
111 );
112}
113
114/** `ubuntu-latest, 20` from `test (ubuntu-latest, 20)`; the whole name when it is not that. */
115function matrixValues(name: string, label: string): string {
116 const own = name.split(" / ").pop() ?? name;
117 const match = /\(([^()]*)\)$/.exec(own);
118 return match && own.startsWith(label) ? match[1]! : own;
119}
120
121function GroupBox({ group, href, selected }: { group: PlacedGroup; href: (job: GraphJob) => string; selected: string | null }) {
122 const { unit } = group;
123 const caller = unit.callers.length === 1 ? unit.callers[0]! : null;
124 const title = (
125 <>
126 <Icon standing={unitStanding(unit)} size={13} />
127 <span className="truncate font-medium">{unit.label}</span>
128 </>
129 );
130 return (
131 <div
132 className="absolute rounded-xl border border-dashed border-line-strong bg-raised/20"
133 style={{ left: group.x, top: group.y, width: group.w, height: group.h }}
134 >
135 <div className="flex items-center gap-2 px-3 text-xs" style={{ height: GRAPH.groupHead }}>
136 {caller ? (
137 <Link
138 to={href(caller)}
139 preventScrollReset
140 className={`flex min-w-0 items-center gap-1.5 hover:text-fg ${caller.id === selected ? "text-fg" : "text-fg/85"}`}
141 >
142 {title}
143 </Link>
144 ) : (
145 <span className="flex min-w-0 items-center gap-1.5 text-fg/85">{title}</span>
146 )}
147 {unit.uses && (
148 <span className="ml-auto flex min-w-0 items-center gap-1 font-mono text-faint">
149 <Workflow size={11} className="shrink-0" />
150 <span className="truncate">{unit.uses.split("/").pop()}</span>
151 </span>
152 )}
153 </div>
154 </div>
155 );
156}
157
158/**
159 * A run's jobs as a graph: each job a node, joined to the jobs that need
160 * it. A matrix's jobs make one node that opens to list them; a called
161 * workflow's jobs sit in a box under the job that calls it. Scrolls
162 * sideways inside its card when wider than the page.
163 */
164export function RunGraph({
165 jobs,
166 title,
167 trigger,
168 href,
169 selected = null,
170}: {
171 jobs: GraphJob[];
172 /** The workflow file's name. */
173 title: string;
174 /** The event that started the run. */
175 trigger: string;
176 href: (job: GraphJob) => string;
177 selected?: string | null;
178}) {
179 const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
180 const layout = useMemo(() => layoutRun(jobs, expanded), [jobs, expanded]);
181 const toggle = (key: string) =>
182 setExpanded((was) => {
183 const now = new Set(was);
184 if (!now.delete(key)) now.add(key);
185 return now;
186 });
187 if (jobs.length === 0) return null;
188 return (
189 <section aria-label="Jobs graph" className="overflow-hidden rounded-xl border border-line bg-surface">
190 <header className="flex min-w-0 items-center gap-1.5 border-b border-line px-4 py-2.5 text-sm">
191 <span className="truncate font-mono font-medium">{title}</span>
192 <span className="shrink-0 text-faint">· on: {trigger}</span>
193 </header>
194 <div className="overflow-x-auto bg-bg/40">
195 <div className="relative" style={{ width: layout.width, height: layout.height }}>
196 {layout.groups.map((group) => (
197 <GroupBox key={group.unit.key} group={group} href={href} selected={selected} />
198 ))}
199 <svg className="pointer-events-none absolute inset-0" width={layout.width} height={layout.height} aria-hidden="true">
200 {layout.edges.map((edge) => (
201 <path
202 key={`${edge.from}>${edge.to}`}
203 d={edge.path}
204 fill="none"
205 strokeWidth={1.5}
206 strokeDasharray={edge.state === "active" ? "4 4" : undefined}
207 className={EDGE[edge.state]}
208 />
209 ))}
210 </svg>
211 {layout.nodes.map((node) =>
212 node.unit.kind === "job" ? (
213 <JobNode key={node.unit.key} node={node as PlacedNode & { unit: { kind: "job" } }} href={href} selected={selected} />
214 ) : (
215 <MatrixNode
216 key={node.unit.key}
217 node={node as PlacedNode & { unit: { kind: "matrix" } }}
218 href={href}
219 selected={selected}
220 toggle={toggle}
221 />
222 ),
223 )}
224 </div>
225 </div>
226 </section>
227 );
228}