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