Files
gitea/web_src/js/components/WorkflowGraph.utils.ts
1c289df6eb enhance: Adjust Workflow Graph styling (#37497)
- Fix workflow dependency graph overflow by making the graph container
scrollable (no more clipped DAGs; addresses #37493).
- Improve Actions job list readability by keeping durations
fixed-width/right-aligned so long times don’t squeeze job names.
- Make workflow graph layout more intuitive by vertically centering
shorter columns to reduce misleading “looks like it depends on”
alignments (addresses #37395).

### Screenshot
<img width="966" height="439"
src="https://github.com/user-attachments/assets/c180c5a2-4f56-4287-bcaa-f2735ba72949"
/>

<img width="949" height="559"
src="https://github.com/user-attachments/assets/a383511d-a962-4920-b792-69f556847eff"
/>



Fixes #37493
Fixes #37395

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-07 16:45:20 +00:00

560 lines
20 KiB
TypeScript

import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
export type GraphNodeType = 'job' | 'matrix' | 'group';
export type GraphNode = {
id: string;
type: GraphNodeType;
name: string;
status: ActionsStatus;
duration: string;
x: number;
y: number;
level: number;
displayHeight: number;
jobs: ActionsJob[];
matrixKey?: string;
};
export type Edge = {
fromId: string;
toId: string;
key: string;
};
export type RoutedEdge = Edge & {
path: string;
fromNode: GraphNode;
toNode: GraphNode;
};
export type SharedSegment = {
key: string;
edgeKeys: string[];
path: string;
};
export type GraphHighlightState = {
nodeIds: Set<string>;
edgeKeys: Set<string>;
};
export type WorkflowGraphLayoutOptions = {
margin: number;
nodeWidth: number;
nodeHeight: number;
columnGap: number;
laneGap: number;
groupRowHeight: number;
groupPadY: number;
matrixCollapsedHeight: number;
matrixHeaderHeight: number;
matrixRowHeight: number;
matrixPadY: number;
};
export type WorkflowGraphModel = {
nodes: GraphNode[];
edges: Edge[];
routedEdges: RoutedEdge[];
sharedSegments: SharedSegment[];
adjacency: NodeAdjacency;
};
export type NodeAdjacency = {
incomingByNodeId: Map<string, string[]>;
outgoingByNodeId: Map<string, string[]>;
};
const defaultLayoutOptions: WorkflowGraphLayoutOptions = {
margin: 24,
nodeWidth: 220,
nodeHeight: 40,
columnGap: 96,
laneGap: 32,
groupRowHeight: 28,
groupPadY: 8,
matrixCollapsedHeight: 78,
matrixHeaderHeight: 24,
matrixRowHeight: 26,
matrixPadY: 6,
};
function canonicalKey(ids: Iterable<string>): string {
return Array.from(ids).sort().join('');
}
function graphIdForJob(job: ActionsJob): string {
return `job:${job.id}`;
}
export function matrixKeyFromJobName(name: string): string | null {
const idx = name.indexOf(' (');
if (idx === -1) return null;
return name.slice(0, idx).trim() || null;
}
export function boxBottom(node: GraphNode): number {
return node.y + node.displayHeight;
}
export function boxCenterY(node: GraphNode): number {
return node.y + node.displayHeight / 2;
}
function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number {
if (rowCount <= 0) return options.nodeHeight;
if (!expanded) return options.matrixCollapsedHeight;
return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2;
}
function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number {
return rowCount * options.groupRowHeight + options.groupPadY * 2;
}
function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
const rank = (s: ActionsStatus) => {
if (s === 'failure') return 0;
if (s === 'cancelled') return 1;
if (s === 'running') return 2;
if (s === 'waiting') return 3;
if (s === 'blocked') return 4;
if (s === 'success') return 5;
if (s === 'skipped') return 6;
return 7;
};
return rank(a) - rank(b);
}
function aggregateStatus(children: ActionsJob[]): ActionsStatus {
return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown';
}
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
const directNeedsByJobId = new Map<string, string[]>();
const dependentsByJobId = new Map<string, Set<string>>();
for (const job of jobs) {
const needs = job.needs || [];
directNeedsByJobId.set(job.jobId, needs);
for (const need of needs) {
if (!dependentsByJobId.has(need)) dependentsByJobId.set(need, new Set());
dependentsByJobId.get(need)!.add(job.jobId);
}
}
const reachabilityCache = new Map<string, boolean>();
function canReach(fromJobId: string, toJobId: string): boolean {
const cacheKey = `${fromJobId}->${toJobId}`;
if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
const visited = new Set<string>();
const stack = Array.from(dependentsByJobId.get(fromJobId) || []);
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toJobId) {
reachabilityCache.set(cacheKey, true);
return true;
}
if (visited.has(current)) continue;
visited.add(current);
stack.push(...(dependentsByJobId.get(current) || []));
}
reachabilityCache.set(cacheKey, false);
return false;
}
const reducedNeedsByJobId = new Map<string, string[]>();
for (const [jobId, needs] of directNeedsByJobId) {
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
return !needs.some((other) => other !== need && canReach(need, other));
}));
}
return reducedNeedsByJobId;
}
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
const jobMap = new Map<string, ActionsJob>();
for (const job of jobs) {
jobMap.set(job.name, job);
if (job.jobId) jobMap.set(job.jobId, job);
}
const levels = new Map<string, number>();
const visited = new Set<string>();
const recursionStack = new Set<string>();
function dfs(jobNameOrId: string): number {
if (recursionStack.has(jobNameOrId)) return 0;
if (visited.has(jobNameOrId)) return levels.get(jobNameOrId) ?? 0;
recursionStack.add(jobNameOrId);
visited.add(jobNameOrId);
const job = jobMap.get(jobNameOrId);
if (!job) {
recursionStack.delete(jobNameOrId);
return 0;
}
if (!job.needs?.length) {
levels.set(job.jobId, 0);
if (job.jobId !== job.name) levels.set(job.name, 0);
recursionStack.delete(jobNameOrId);
return 0;
}
let maxLevel = -1;
for (const need of job.needs) {
if (!jobMap.has(need)) continue;
maxLevel = Math.max(maxLevel, dfs(need));
}
const level = maxLevel + 1;
levels.set(job.name, level);
levels.set(job.jobId, level);
recursionStack.delete(jobNameOrId);
return level;
}
for (const job of jobs) {
if (!visited.has(job.jobId)) dfs(job.jobId);
}
return levels;
}
export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState {
if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()};
const {incomingByNodeId, outgoingByNodeId} = adjacency;
const edgeKeys = new Set<string>();
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
const seen = new Set<string>();
const queue = [startId];
while (queue.length > 0) {
const current = queue.shift()!;
if (seen.has(current)) continue;
seen.add(current);
for (const next of adj.get(current) || []) {
edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`);
if (!seen.has(next)) queue.push(next);
}
}
return seen;
};
const ancestors = collect(hoveredId, incomingByNodeId, false);
const descendants = collect(hoveredId, outgoingByNodeId, true);
return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys};
}
type VisualGraphBuild = {
nodes: GraphNode[];
edges: Edge[];
};
function buildVisualGraph(
jobs: ActionsJob[],
expandedMatrixKeys: ReadonlySet<string>,
options: WorkflowGraphLayoutOptions,
): VisualGraphBuild {
const jobsByJobId = new Map<string, ActionsJob[]>();
const jobIndexById = new Map<number, number>();
for (const [index, job] of jobs.entries()) {
jobIndexById.set(job.id, index);
if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []);
jobsByJobId.get(job.jobId)!.push(job);
}
const matrixJobsByKey = new Map<string, ActionsJob[]>();
for (const job of jobs) {
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
// even if their display name happens to look like "name (variant)".
if (job.isReusableCaller) continue;
const matrixKey = matrixKeyFromJobName(job.name);
if (!matrixKey) continue;
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
matrixJobsByKey.get(matrixKey)!.push(job);
}
for (const list of matrixJobsByKey.values()) {
list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
}
const directNeedsByJobId = buildDirectNeedsMap(jobs);
const rawLevels = computeJobLevels(jobs);
const dependentsByJobId = new Map<string, string[]>();
const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = [];
for (const job of jobs) {
for (const need of directNeedsByJobId.get(job.jobId) || []) {
for (const upstream of jobsByJobId.get(need) || []) {
rawEdges.push({from: upstream, to: job});
if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []);
dependentsByJobId.get(upstream.jobId)!.push(job.jobId);
}
}
}
for (const list of dependentsByJobId.values()) list.sort();
// Group sibling jobs that share an identical (parents, children) signature into a single
// collapsed "group" node. This is a visual aggregation only - the underlying jobs are
// preserved on the node so the panel can list them.
const groupedJobIds = new Map<number, string>();
const groupsById = new Map<string, ActionsJob[]>();
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
for (const job of jobs) {
if (matrixKeyFromJobName(job.name)) continue;
// Reusable callers represent distinct workflow files — keep each as its own node so the
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
// siblings share an identical (parents, children) dependency signature.
if (job.isReusableCaller) continue;
const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []);
const childrenKey = (dependentsByJobId.get(job.jobId) || []).join('');
if (!needsKey && !childrenKey) continue;
const level = rawLevels.get(job.jobId) ?? 0;
const key = `group:${level}:${needsKey}:${childrenKey}`;
if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []);
groupCandidateBuckets.get(key)!.push(job);
}
for (const [groupId, groupJobs] of groupCandidateBuckets) {
if (groupJobs.length < 2) continue;
groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
groupsById.set(groupId, groupJobs);
for (const job of groupJobs) groupedJobIds.set(job.id, groupId);
}
const visualIdByJobId = new Map<number, string>();
for (const job of jobs) {
const matrixKey = matrixKeyFromJobName(job.name);
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
// happens to look like "name (variant)" must never be folded into the matrix node, or it
// would silently vanish (its visualId would point at a matrix node it isn't part of).
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
continue;
}
visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job));
}
const emittedNodeIds = new Set<string>();
const nodes: GraphNode[] = [];
for (const job of jobs) {
const visualId = visualIdByJobId.get(job.id);
if (!visualId || emittedNodeIds.has(visualId)) continue;
emittedNodeIds.add(visualId);
const matrixKey = matrixKeyFromJobName(job.name);
if (matrixKey && visualId.startsWith('matrix:')) {
const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
nodes.push({
id: visualId,
type: 'matrix',
name: matrixKey,
status: aggregateStatus(matrixJobs),
duration: '',
x: 0, y: 0, level: 0,
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
jobs: matrixJobs,
matrixKey,
});
continue;
}
const groupJobs = groupsById.get(visualId);
if (groupJobs) {
nodes.push({
id: visualId,
type: 'group',
name: groupJobs.map((g) => g.name).join(', '),
status: aggregateStatus(groupJobs),
duration: '',
x: 0, y: 0, level: 0,
displayHeight: groupPanelHeight(groupJobs.length, options),
jobs: groupJobs,
});
continue;
}
nodes.push({
id: visualId,
type: 'job',
name: job.name,
status: job.status,
duration: job.duration,
x: 0, y: 0, level: 0,
displayHeight: options.nodeHeight,
jobs: [job],
});
}
const seenEdges = new Set<string>();
const edges: Edge[] = [];
for (const {from, to} of rawEdges) {
const fromId = visualIdByJobId.get(from.id);
const toId = visualIdByJobId.get(to.id);
if (!fromId || !toId || fromId === toId) continue;
const key = `${fromId}->${toId}`;
if (seenEdges.has(key)) continue;
seenEdges.add(key);
edges.push({fromId, toId, key});
}
return {nodes, edges};
}
function buildNodeAdjacency(edges: Edge[]): NodeAdjacency {
const incomingByNodeId = new Map<string, string[]>();
const outgoingByNodeId = new Map<string, string[]>();
for (const edge of edges) {
if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []);
incomingByNodeId.get(edge.toId)!.push(edge.fromId);
if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []);
outgoingByNodeId.get(edge.fromId)!.push(edge.toId);
}
return {incomingByNodeId, outgoingByNodeId};
}
function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void {
const cache = new Map<string, number>();
function levelFor(id: string, visiting = new Set<string>()): number {
if (cache.has(id)) return cache.get(id)!;
if (visiting.has(id)) return 0;
visiting.add(id);
const incoming = incomingByNodeId.get(id) || [];
const level = incoming.length > 0 ?
Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 :
0;
visiting.delete(id);
cache.set(id, level);
return level;
}
for (const node of nodes) node.level = levelFor(node.id);
}
// Roots stay in input order; later levels are sorted by the mean parent Y so that simple
// chains stay on a straight horizontal line.
function assignNodeCoordinates(nodesById: Map<string, GraphNode>, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void {
const {incomingByNodeId} = adjacency;
const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id));
const nodesByLevel = new Map<number, GraphNode[]>();
for (const node of nodes) {
if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []);
nodesByLevel.get(node.level)!.push(node);
}
const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
// Initial X assignment and a default Y so barycenters can use a finite value.
for (const level of orderedLevels) {
const list = nodesByLevel.get(level)!;
list.sort((a, b) => inputRank(a) - inputRank(b));
let yCursor = options.margin;
for (const node of list) {
node.x = options.margin + level * (options.nodeWidth + options.columnGap);
node.y = yCursor;
yCursor += node.displayHeight + options.laneGap;
}
}
function packLevel(level: number, anchorOf: (n: GraphNode) => number): void {
const list = nodesByLevel.get(level)!;
const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b));
// Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal
// nodes like build-image close to the top of their column instead of being pulled down to
// the mean Y of their parents — matching GitHub Actions' compact layout.
let prevBottom = options.margin - options.laneGap;
for (const node of sorted) {
node.y = prevBottom + options.laneGap;
prevBottom = boxBottom(node);
}
nodesByLevel.set(level, sorted);
}
function meanCenterOf(ids: string[]): number | null {
if (ids.length === 0) return null;
let sum = 0;
for (const id of ids) sum += boxCenterY(nodesById.get(id)!);
return sum / ids.length;
}
// Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots
// keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions
// arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 →
// job-102 stays on a straight horizontal line.
for (const level of orderedLevels) {
if (level === 0) continue;
packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node));
}
}
// Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint →
// vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is
// fixed (not clamped to the row delta) so any two edges sharing the same source produce the
// same source-side path and overlap into a single visual line until they diverge at the V.
const cornerRadius = 12;
function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string {
if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`;
// Anchor the V segment in the column gap immediately before the target instead of the
// horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style
// — a multi-column edge runs along the source row across intermediate columns, then turns
// up/down only when it reaches the target column.
const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2);
const dy = ey > sy ? 1 : -1;
// Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a
// source overlap visually until they fork. When there isn't 2*cornerRadius of vertical
// room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey)
// instead of a backward V kink.
if (Math.abs(ey - sy) < cornerRadius * 2) {
return [
`M ${sx} ${sy}`,
`H ${midX - cornerRadius}`,
`C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`,
`H ${ex}`,
].join(' ');
}
const half = cornerRadius / 2;
return [
`M ${sx} ${sy}`,
`H ${midX - cornerRadius}`,
`C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`,
`V ${ey - cornerRadius * dy}`,
`C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`,
`H ${ex}`,
].join(' ');
}
function buildRoutedEdges(
nodesById: Map<string, GraphNode>,
edges: Edge[],
options: WorkflowGraphLayoutOptions,
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
const routedEdges: RoutedEdge[] = [];
for (const edge of edges) {
const fromNode = nodesById.get(edge.fromId);
const toNode = nodesById.get(edge.toId);
if (!fromNode || !toNode) continue;
const startX = fromNode.x + options.nodeWidth;
const endX = toNode.x;
const startY = boxCenterY(fromNode);
const endY = boxCenterY(toNode);
routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)});
}
return {routedEdges, sharedSegments: []};
}
export function createWorkflowGraphModel(
jobs: ActionsJob[],
expandedMatrixKeys: ReadonlySet<string> = new Set(),
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
): WorkflowGraphModel {
const options = {...defaultLayoutOptions, ...partialOptions};
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options);
const nodesById = new Map(nodes.map((n) => [n.id, n]));
const adjacency = buildNodeAdjacency(edges);
assignNodeLevels(nodes, adjacency);
assignNodeCoordinates(nodesById, nodes, adjacency, options);
return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency};
}
export function getWorkflowGraphLayoutOptions(partialOptions: Partial<WorkflowGraphLayoutOptions> = {}): WorkflowGraphLayoutOptions {
return {...defaultLayoutOptions, ...partialOptions};
}