Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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