Files
Gitea/web_src/js/components/WorkflowGraph.utils.ts
bircni 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
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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};
}