diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index 69062ff6e7..61f20a3ef4 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -214,6 +214,75 @@ func MockActionsRunsJobs(ctx *context.Context) {
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
}
+ // Keep devtest mock runs minimal: use run 10 as a "complex graph" repro.
+ // This combines long durations, parallel roots, and a multi-dependency downstream job
+ // to validate the workflow graph rendering.
+ if runID == 10 {
+ resp.State.Run.WorkflowID = "workflow-devtest-complex"
+ resp.State.Run.Duration = "7h 12m 34s"
+
+ type mj struct {
+ jobID string
+ name string
+ status actions_model.Status
+ duration string
+ needs []string
+ }
+ mockJobs := []mj{
+ {jobID: "job-100", name: "job-100", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
+ {jobID: "job-101", name: "job-101", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"job-100"}},
+ {jobID: "job-102", name: "job-102", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"job-100", "job-101"}},
+ {jobID: "job-103", name: "job-103", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"job-100"}},
+
+ {jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
+ {jobID: "code-analysis", name: "code-analysis", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
+
+ // Matrix expansion (the " (...)" suffix is the heuristic the frontend uses to group rows)
+ {jobID: "matrix-e2e-1-chromium", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e-1-firefox", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e-2-chromium", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e-3-chromium", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e-3-firefox", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e-99-webkit", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+
+ {jobID: "unit-test", name: "unit-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
+ {jobID: "arch-test", name: "arch-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
+ {jobID: "integration-test", name: "integration-test", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
+
+ {jobID: "build-image", name: "build-image", status: actions_model.StatusSuccess, duration: "3s", needs: []string{
+ "unit-test",
+ "arch-test",
+ "integration-test",
+ "code-analysis",
+ "matrix-e2e-1-chromium",
+ "matrix-e2e-1-firefox",
+ "matrix-e2e-2-chromium",
+ "matrix-e2e-3-chromium",
+ "matrix-e2e-3-firefox",
+ "matrix-e2e-99-webkit",
+ }},
+ }
+
+ resp.State.Run.Jobs = nil
+ for i, j := range mockJobs {
+ id := runID*1000 + int64(i)
+ resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
+ ID: id,
+ Link: jobLink(id),
+ JobID: j.jobID,
+ Name: j.name,
+ Status: j.status.String(),
+ CanRerun: j.jobID == "job-100",
+ Duration: j.duration,
+ Needs: j.needs,
+ })
+ }
+
+ fillViewRunResponseCurrentJob(ctx, resp)
+ ctx.JSON(http.StatusOK, resp)
+ return
+ }
+
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID * 10,
Link: jobLink(runID * 10),
@@ -240,7 +309,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
Status: actions_model.StatusFailure.String(),
CanRerun: false,
- Duration: "3h",
+ Duration: "3h35m10s",
Needs: []string{"job-100", "job-101"},
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl
index 4e06f72cdd..12da63a7e9 100644
--- a/templates/devtest/repo-action-view.tmpl
+++ b/templates/devtest/repo-action-view.tmpl
@@ -1,11 +1,11 @@
{{template "base/head" .}}
{{template "repo/actions/view_component" (dict
"JobID" (or .JobID 0)
diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue
index 835518ecb8..d150e5899b 100644
--- a/web_src/js/components/ActionRunJobView.vue
+++ b/web_src/js/components/ActionRunJobView.vue
@@ -2,7 +2,6 @@
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
-import WorkflowGraph from './WorkflowGraph.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts';
@@ -13,7 +12,6 @@ import {localUserSettings} from '../modules/user-settings.ts';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import {
type ActionRunViewStore,
- collectCallerChildJobs,
createLogLineMessage,
type LogLine,
type LogLineCommand,
@@ -118,14 +116,11 @@ const currentJob = ref
({
const stepsContainer = ref(null);
const jobStepLogs = ref>([]);
-// Reusable workflow caller view: when the selected job is a caller node, the right pane
-// shows the children list rather than step logs (callers don't run on a runner).
+// Reusable workflow caller view: the right pane shows just the header (name + uses path +
+// status). Callers don't run on a runner, and the dependency graph for their children lives
+// in the run summary's WorkflowGraph, not here — matching GitHub Actions.
const selectedJob = computed(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
-const callerChildJobs = computed(() => {
- if (!isCallerJob.value) return [];
- return collectCallerChildJobs(run.value.jobs || [], props.jobId);
-});
watch(optionAlwaysAutoScroll, () => {
saveLocaleStorageOptions();
@@ -477,20 +472,6 @@ async function hashChangeListener() {
-
-
-
-
-
@@ -578,8 +559,7 @@ async function hashChangeListener() {
border-radius: 3px;
}
-.job-info-header:has(+ .job-step-container),
-.job-info-header:has(+ .caller-children-container) {
+.job-info-header:has(+ .job-step-container) {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
@@ -613,14 +593,6 @@ async function hashChangeListener() {
min-width: 0;
}
-.caller-children-container {
- flex: 1;
- display: flex;
- flex-direction: column;
- border-top: 1px solid var(--color-console-border);
- color: var(--color-console-fg);
-}
-
.job-step-container {
max-height: 100%;
border-radius: 0 0 var(--border-radius) var(--border-radius);
diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts
index e9b929444f..0d6ae9a61b 100644
--- a/web_src/js/components/ActionRunView.ts
+++ b/web_src/js/components/ActionRunView.ts
@@ -104,12 +104,6 @@ export function buildJobsByParentJobID(jobs: ActionsJob[]): Map
(() => {
while (stack.length > 0) {
const {job, depth} = stack.pop()!;
const children = childrenByParent.get(job.id) || [];
- const hasChildren = children.length > 0;
- result.push({job, depth, hasChildren});
- if (hasChildren && isJobCollapsed(job.id)) continue;
+ result.push({job, depth});
+ if (children.length > 0 && isJobCollapsed(job.id)) continue;
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
}
return result;
@@ -216,24 +214,28 @@ async function deleteArtifact(name: string) {
v-for="item in visibleJobListItems"
:key="item.job.id"
>
-
-
- {{ item.job.name }}
-
- {{ item.job.duration }}
-
+
-
+
+ {{ item.job.name }}
+ {{ item.job.duration }}
+
+
+
+ {{ item.job.name }}
+
+ {{ item.job.duration }}
+
@@ -258,7 +260,7 @@ async function deleteArtifact(name: string) {
-
+
{{ artifact.name }}
{{ locale.artifactExpired }}
@@ -406,23 +408,23 @@ async function deleteArtifact(name: string) {
background-color: var(--color-active);
}
-.job-brief-toggle {
+.caller-row-toggle {
border: none;
padding: 0;
background: transparent;
- cursor: pointer;
color: inherit;
- display: inline-flex;
- align-items: center;
- justify-content: center;
+ cursor: pointer;
+ text-align: inherit;
+}
+
+.job-brief-toggle-icon {
flex-shrink: 0;
- /* the icon is always chevron-down; flip to chevron-up when expanded */
transition: transform 0.15s ease;
- /* sit right after the job name; rerun/duration float to the right via auto-margin */
+ /* sit between name and duration; duration uses order:2 with margin-left:auto to float right */
order: 1;
}
-.job-brief-toggle:not(.collapsed) {
+.job-brief-toggle-icon:not(.collapsed) {
transform: rotate(180deg);
}
diff --git a/web_src/js/components/WorkflowGraph.utils.test.ts b/web_src/js/components/WorkflowGraph.utils.test.ts
new file mode 100644
index 0000000000..9d79766d83
--- /dev/null
+++ b/web_src/js/components/WorkflowGraph.utils.test.ts
@@ -0,0 +1,197 @@
+import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel, matrixKeyFromJobName} from './WorkflowGraph.utils.ts';
+import type {ActionsJob} from '../modules/gitea-actions.ts';
+
+const mockJobs: ActionsJob[] = [
+ {id: 1, link: '', jobId: 'job-100', name: 'job-100', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
+ {id: 2, link: '', jobId: 'job-101', name: 'job-101', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['job-100']},
+ {id: 3, link: '', jobId: 'job-102', name: 'job-102', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-101']},
+ {id: 4, link: '', jobId: 'job-103', name: 'job-103', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100']},
+ {id: 5, link: '', jobId: 'prep-jdk', name: 'prep-jdk', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
+ {id: 6, link: '', jobId: 'code-analysis', name: 'code-analysis', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
+ {id: 7, link: '', jobId: 'matrix-e2e-1-chromium', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
+ {id: 8, link: '', jobId: 'matrix-e2e-1-firefox', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
+ {id: 9, link: '', jobId: 'matrix-e2e-2-chromium', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
+ {id: 10, link: '', jobId: 'matrix-e2e-3-chromium', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
+ {id: 11, link: '', jobId: 'matrix-e2e-3-firefox', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
+ {id: 12, link: '', jobId: 'matrix-e2e-99-webkit', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
+ {id: 13, link: '', jobId: 'unit-test', name: 'unit-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
+ {id: 14, link: '', jobId: 'arch-test', name: 'arch-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
+ {id: 15, link: '', jobId: 'integration-test', name: 'integration-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['prep-jdk', 'code-analysis']},
+ {id: 16, link: '', jobId: 'build-image', name: 'build-image', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: [
+ 'unit-test',
+ 'arch-test',
+ 'integration-test',
+ 'matrix-e2e-1-chromium',
+ 'matrix-e2e-1-firefox',
+ 'matrix-e2e-2-chromium',
+ 'matrix-e2e-3-chromium',
+ 'matrix-e2e-3-firefox',
+ 'matrix-e2e-99-webkit',
+ ]},
+];
+
+const verifyDeployJobs: ActionsJob[] = [
+ {id: 101, link: '', jobId: 'seed-dev', name: 'seed-dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s'},
+ {id: 102, link: '', jobId: 'seed-qa', name: 'seed-qa', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
+ {id: 103, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['seed-dev']},
+ {id: 104, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['seed-qa']},
+ {id: 105, link: '', jobId: 'deploy', name: 'Deploy', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '', needs: ['verify-dev', 'verify-qa']},
+];
+
+// Multi-level pipeline with two matrices and a leaf with two parents.
+const wfTest1Jobs: ActionsJob[] = [
+ {id: 1, link: '', jobId: 'init', name: 'Initialize Pipeline', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
+ {id: 2, link: '', jobId: 'lint-frontend', name: 'Lint Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
+ {id: 3, link: '', jobId: 'lint-backend', name: 'Lint Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
+ {id: 4, link: '', jobId: 'build-frontend', name: 'Build Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['lint-frontend']},
+ {id: 5, link: '', jobId: 'build-backend', name: 'Build Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['lint-backend']},
+ {id: 6, link: '', jobId: 'tu-api-t', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
+ {id: 7, link: '', jobId: 'tu-api-f', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
+ {id: 8, link: '', jobId: 'tu-svc-t', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
+ {id: 9, link: '', jobId: 'test-integration', name: 'Integration Tests', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '6s', needs: ['build-backend']},
+ {id: 10, link: '', jobId: 'te-c-d', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
+ {id: 11, link: '', jobId: 'te-c-m', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
+ {id: 12, link: '', jobId: 'te-f-d', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
+ {id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['tu-api-t', 'tu-api-f', 'tu-svc-t', 'test-integration', 'te-c-d', 'te-c-m', 'te-f-d']},
+ {id: 14, link: '', jobId: 'deploy-dev', name: 'Deploy to Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
+ {id: 15, link: '', jobId: 'deploy-qa', name: 'Deploy to QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
+ {id: 16, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-dev']},
+ {id: 17, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-qa']},
+ {id: 18, link: '', jobId: 'deploy-prod', name: 'Deploy to Production', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['verify-dev', 'verify-qa']},
+ {id: 19, link: '', jobId: 'post-deploy-checks', name: 'Post-Deploy Checks', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-prod']},
+];
+
+test('matrix key heuristic strips trailing parameter list', () => {
+ expect(matrixKeyFromJobName('matrix-e2e (1, chromium)')).toBe('matrix-e2e');
+ expect(matrixKeyFromJobName('plain-job')).toBeNull();
+});
+
+test('computeJobLevels keeps stable topological levels', () => {
+ const levels = computeJobLevels(mockJobs);
+ expect(levels.get('job-100')).toBe(0);
+ expect(levels.get('job-101')).toBe(1);
+ expect(levels.get('job-102')).toBe(2);
+ expect(levels.get('build-image')).toBe(2);
+});
+
+test('graph model collapses matrix and groups jobs that share parents and children', () => {
+ const graph = createWorkflowGraphModel(mockJobs);
+
+ expect(graph.nodes.find((n) => n.type === 'matrix')?.jobs).toHaveLength(6);
+ const groupJobIds = graph.nodes.filter((n) => n.type === 'group').map((g) => g.jobs.map((j) => j.jobId));
+ expect(groupJobIds).toEqual(expect.arrayContaining([
+ ['prep-jdk', 'code-analysis'],
+ ['unit-test', 'arch-test', 'integration-test'],
+ ]));
+});
+
+test('expanded matrix height includes summary and toggle rows', () => {
+ const collapsed = createWorkflowGraphModel(mockJobs);
+ const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix-e2e']));
+ const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
+ const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
+
+ expect(collapsedMatrix?.displayHeight).toBeLessThan(expandedMatrix?.displayHeight ?? 0);
+ // 6 jobs * 26 row height + 24 header + 6 pad * 2 = 192
+ expect(expandedMatrix?.displayHeight).toBe(192);
+});
+
+test('every dependency is rendered as one routed edge', () => {
+ const graph = createWorkflowGraphModel(mockJobs);
+ const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
+ const testGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'unit-test'))!;
+ const expectedKeys = [
+ `${rootGroup.id}->matrix:matrix-e2e`,
+ `${rootGroup.id}->${testGroup.id}`,
+ ];
+ const keys = new Set(graph.routedEdges.map((e) => e.key));
+ for (const k of expectedKeys) expect(keys.has(k)).toBe(true);
+});
+
+test('same-row edge collapses to a single horizontal line', () => {
+ const graph = createWorkflowGraphModel(verifyDeployJobs);
+ const verifyDevEdge = graph.routedEdges.find((e) => e.fromId === 'job:101' && e.toId === 'job:103');
+ const verifyQaEdge = graph.routedEdges.find((e) => e.fromId === 'job:102' && e.toId === 'job:104');
+ expect(verifyDevEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
+ expect(verifyQaEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
+});
+
+test('different-row edge uses cubic bezier curve', () => {
+ const graph = createWorkflowGraphModel(verifyDeployJobs);
+ const deployLowerEdge = graph.routedEdges.find((e) => e.fromId === 'job:104' && e.toId === 'job:105');
+ expect(deployLowerEdge?.path).toContain(' C ');
+});
+
+test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
+ const graph = createWorkflowGraphModel(wfTest1Jobs);
+ const matrices = graph.nodes.filter((n) => n.type === 'matrix');
+ expect(matrices.map((n) => n.matrixKey).sort()).toEqual(['E2E Tests', 'Unit Tests']);
+
+ const deployProd = graph.nodes.find((n) => n.id === 'job:18');
+ const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
+ const verifyQa = graph.nodes.find((n) => n.id === 'job:17');
+ expect(verifyDev?.level).toBe(verifyQa?.level);
+ expect(deployProd?.level).toBe((verifyDev?.level ?? 0) + 1);
+
+ for (const node of graph.nodes) {
+ expect(Number.isFinite(node.x)).toBe(true);
+ expect(Number.isFinite(node.y)).toBe(true);
+ expect(node.x).toBeGreaterThanOrEqual(0);
+ expect(node.y).toBeGreaterThanOrEqual(0);
+ }
+ for (const edge of graph.routedEdges) {
+ expect(edge.path).not.toMatch(/NaN|undefined|Infinity/);
+ }
+});
+
+test('reusable callers with identical dependency signature are kept as separate nodes', () => {
+ const jobs: ActionsJob[] = [
+ {id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
+ {id: 2, link: '', jobId: 'local_caller', name: 'local caller', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '5m', needs: ['prepare'], callUses: './.gitea/workflows/lib.yml'},
+ {id: 3, link: '', jobId: 'cross_caller', name: 'cross-repo caller', status: 'waiting', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '0s', needs: ['prepare'], callUses: 'user2/lib/.gitea/workflows/ext.yml@main'},
+ {id: 4, link: '', jobId: 'final', name: 'final', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
+ ];
+ const graph = createWorkflowGraphModel(jobs);
+ expect(graph.nodes.find((n) => n.type === 'group')).toBeUndefined();
+ expect(graph.nodes.find((n) => n.id === 'job:2')?.name).toBe('local caller');
+ expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
+});
+
+test('reusable caller with matrix-pattern name does not get absorbed into a sibling matrix node', () => {
+ const jobs: ActionsJob[] = [
+ {id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
+ {id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
+ {id: 3, link: '', jobId: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.yml'},
+ ];
+ const graph = createWorkflowGraphModel(jobs);
+ expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)');
+ const matrixNode = graph.nodes.find((n) => n.type === 'matrix');
+ expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]);
+});
+
+test('directed highlight state covers ancestors and descendants of the hovered node', () => {
+ const graph = createWorkflowGraphModel(mockJobs);
+ const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
+
+ const highlight = computeGraphHighlightState(rootGroup.id, graph.adjacency);
+ expect(highlight.nodeIds.has('matrix:matrix-e2e')).toBe(true);
+ expect(highlight.nodeIds.has('job:16')).toBe(true);
+ expect(highlight.edgeKeys.has(`${rootGroup.id}->matrix:matrix-e2e`)).toBe(true);
+});
+
+test('directed highlight state for converging graph excludes sibling branch when hovering parent', () => {
+ const graph = createWorkflowGraphModel(verifyDeployJobs);
+
+ const parentHighlight = computeGraphHighlightState('job:103', graph.adjacency);
+ expect(parentHighlight.nodeIds.has('job:101')).toBe(true);
+ expect(parentHighlight.nodeIds.has('job:105')).toBe(true);
+ expect(parentHighlight.nodeIds.has('job:104')).toBe(false);
+ expect(parentHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
+ expect(parentHighlight.edgeKeys.has('job:104->job:105')).toBe(false);
+
+ const sinkHighlight = computeGraphHighlightState('job:105', graph.adjacency);
+ expect(sinkHighlight.nodeIds.has('job:103')).toBe(true);
+ expect(sinkHighlight.nodeIds.has('job:104')).toBe(true);
+ expect(sinkHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
+ expect(sinkHighlight.edgeKeys.has('job:104->job:105')).toBe(true);
+});
diff --git a/web_src/js/components/WorkflowGraph.utils.ts b/web_src/js/components/WorkflowGraph.utils.ts
new file mode 100644
index 0000000000..71ef7e6fc9
--- /dev/null
+++ b/web_src/js/components/WorkflowGraph.utils.ts
@@ -0,0 +1,559 @@
+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;
+ edgeKeys: Set;
+};
+
+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;
+ outgoingByNodeId: Map;
+};
+
+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 {
+ 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 {
+ const directNeedsByJobId = new Map();
+ const dependentsByJobId = new Map>();
+
+ 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();
+ function canReach(fromJobId: string, toJobId: string): boolean {
+ const cacheKey = `${fromJobId}->${toJobId}`;
+ if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
+ const visited = new Set();
+ 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();
+ 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 {
+ const jobMap = new Map();
+ for (const job of jobs) {
+ jobMap.set(job.name, job);
+ if (job.jobId) jobMap.set(job.jobId, job);
+ }
+
+ const levels = new Map();
+ const visited = new Set();
+ const recursionStack = new Set();
+
+ 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();
+ const collect = (startId: string, adj: Map, edgeKeyForward: boolean): Set => {
+ const seen = new Set();
+ 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,
+ options: WorkflowGraphLayoutOptions,
+): VisualGraphBuild {
+ const jobsByJobId = new Map();
+ const jobIndexById = new Map();
+ 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();
+ 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();
+ 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();
+ const groupsById = new Map();
+ const groupCandidateBuckets = new Map();
+ 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();
+ 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();
+ 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();
+ 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();
+ const outgoingByNodeId = new Map();
+ 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();
+ function levelFor(id: string, visiting = new Set()): 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, 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();
+ 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,
+ edges: Edge[],
+ options: WorkflowGraphLayoutOptions,
+): Pick {
+ 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 = new Set(),
+ partialOptions: Partial = {},
+): 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 {
+ return {...defaultLayoutOptions, ...partialOptions};
+}
diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue
index 27ba960feb..4c8762dc39 100644
--- a/web_src/js/components/WorkflowGraph.vue
+++ b/web_src/js/components/WorkflowGraph.vue
@@ -6,31 +6,17 @@ import {localUserSettings} from '../modules/user-settings.ts';
import {isPlainClick} from '../utils/dom.ts';
import {trN} from '../modules/i18n.ts';
import {debounce} from 'throttle-debounce';
-import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
+import type {ActionsJob} from '../modules/gitea-actions.ts';
import type {ActionRunViewStore} from './ActionRunView.ts';
-
-interface JobNode {
- id: number;
- name: string;
- status: ActionsStatus;
- duration: string;
-
- x: number;
- y: number;
- level: number;
-}
-
-interface Edge {
- fromId: number;
- toId: number;
- key: string;
-}
-
-interface RoutedEdge extends Edge {
- path: string;
- fromNode: JobNode;
- toNode: JobNode;
-}
+import {
+ boxBottom,
+ boxCenterY,
+ computeGraphHighlightState,
+ createWorkflowGraphModel,
+ getWorkflowGraphLayoutOptions,
+ type GraphNode,
+ type RoutedEdge,
+} from './WorkflowGraph.utils.ts';
interface StoredState {
scale: number;
@@ -45,10 +31,11 @@ const props = defineProps<{
runLink: string;
workflowId: string;
locale: Record;
-}>()
+}>();
const settingKeyStates = 'actions-graph-states';
const maxStoredStates = 10;
+const layout = getWorkflowGraphLayoutOptions();
const scale = ref(1);
const translateX = ref(0);
@@ -56,9 +43,21 @@ const translateY = ref(0);
const isDragging = ref(false);
const lastMousePos = ref({x: 0, y: 0});
const graphContainer = ref(null);
-const hoveredJobId = ref(null);
+const hoveredGraphId = ref(null);
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
+const expandedMatrixKeys = ref>(new Set());
+
+function isMatrixExpanded(key: string): boolean {
+ return expandedMatrixKeys.value.has(key);
+}
+
+function toggleMatrixExpanded(key: string) {
+ const next = new Set(expandedMatrixKeys.value);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ expandedMatrixKeys.value = next;
+}
const loadSavedState = () => {
const allStates = localUserSettings.getJsonObject>(settingKeyStates, {});
@@ -85,289 +84,35 @@ const saveState = () => {
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
};
-const minNodeWidth = 168;
-const maxNodeWidth = 232;
-const nodeWidth = computed(() => {
- const maxNameLength = Math.max(...props.jobs.map(j => j.name.length), 0);
- return Math.min(Math.max(minNodeWidth, maxNameLength * 8), maxNodeWidth);
-});
+const graphModel = computed(() => createWorkflowGraphModel(props.jobs, expandedMatrixKeys.value));
+const jobsWithLayout = computed(() => graphModel.value.nodes);
+const edges = computed(() => graphModel.value.edges);
+const routedEdges = computed(() => graphModel.value.routedEdges);
-const horizontalSpacing = computed(() => nodeWidth.value + 84);
+const nodeWidth = layout.nodeWidth;
const graphWidth = computed(() => {
if (jobsWithLayout.value.length === 0) return 800;
- const maxX = Math.max(...jobsWithLayout.value.map(j => j.x + nodeWidth.value));
- return maxX + margin * 2;
+ const maxX = Math.max(...jobsWithLayout.value.map((job) => job.x + nodeWidth));
+ return maxX + layout.margin * 2;
});
const graphHeight = computed(() => {
if (jobsWithLayout.value.length === 0) return 400;
- const maxY = Math.max(...jobsWithLayout.value.map(j => j.y + nodeHeight));
- return maxY + margin * 2;
+ const maxY = Math.max(...jobsWithLayout.value.map((job) => boxBottom(job)));
+ return maxY + layout.margin * 2;
});
-
-const jobsWithLayout = computed(() => {
- try {
- const levels = computeJobLevels(props.jobs);
- const currentHorizontalSpacing = horizontalSpacing.value;
-
- const jobsByLevel: ActionsJob[][] = [];
- let maxJobsPerLevel = 0;
-
- props.jobs.forEach(job => {
- // `?? 0`, not `|| 0`: a root job's level is 0, which `||` would wrongly discard.
- const level = levels.get(scopedKey(job)) ?? 0;
-
- if (!jobsByLevel[level]) {
- jobsByLevel[level] = [];
- }
- jobsByLevel[level].push(job);
-
- if (jobsByLevel[level].length > maxJobsPerLevel) {
- maxJobsPerLevel = jobsByLevel[level].length;
- }
- });
-
- const result: JobNode[] = [];
- jobsByLevel.forEach((levelJobs, levelIndex) => {
- if (!levelJobs || levelJobs.length === 0) {
- return;
- }
-
- const startY = margin;
-
- levelJobs.forEach((job, jobIndex) => {
- result.push({
- id: job.id,
- name: job.name,
- status: job.status,
- duration: job.duration,
-
- x: margin + levelIndex * currentHorizontalSpacing,
- y: startY + jobIndex * verticalSpacing,
- level: levelIndex,
- });
- });
- });
-
- return result;
- } catch (error) {
- return props.jobs.map((job, index) => ({
- id: job.id,
- name: job.name,
- status: job.status,
- duration: job.duration,
-
- x: margin + index * horizontalSpacing.value,
- y: margin,
- level: 0,
- }));
- }
+const successRateLabel = computed(() => {
+ if (props.jobs.length === 0) return '0%';
+ const successCount = props.jobs.filter((job) => job.status === 'success').length;
+ return `${((successCount / props.jobs.length) * 100).toFixed(0)}%`;
});
-// scopedKey identifies a job within its reusable-workflow call scope so that the same
-// JobID in different reusable calls does not collide.
-function scopedKey(job: {parentJobID: number; jobId: string}): string {
- return `${job.parentJobID || 0}:${job.jobId}`;
-}
-
-function buildDirectNeedsMap(jobs: ActionsJob[]): Map {
- // The map keys/values are scoped keys, not bare jobIds, so we keep edge construction
- // accurate when reusable workflows reuse common job names like "build" / "test".
- const directNeedsByScopedKey = new Map();
- const dependentsByScopedKey = new Map>();
-
- for (const job of jobs) {
- const fromKey = scopedKey(job);
- const needKeys = (job.needs || []).map((n) => `${job.parentJobID || 0}:${n}`);
- directNeedsByScopedKey.set(fromKey, needKeys);
-
- for (const needKey of needKeys) {
- if (!dependentsByScopedKey.has(needKey)) {
- dependentsByScopedKey.set(needKey, new Set());
- }
- dependentsByScopedKey.get(needKey)!.add(fromKey);
- }
- }
-
- const reachabilityCache = new Map();
-
- function canReach(fromKey: string, toKey: string): boolean {
- const cacheKey = `${fromKey}->${toKey}`;
- if (reachabilityCache.has(cacheKey)) {
- return reachabilityCache.get(cacheKey)!;
- }
-
- const visited = new Set();
- const stack = [...(dependentsByScopedKey.get(fromKey) || [])];
-
- while (stack.length > 0) {
- const current = stack.pop()!;
- if (current === toKey) {
- reachabilityCache.set(cacheKey, true);
- return true;
- }
- if (visited.has(current)) continue;
- visited.add(current);
- stack.push(...(dependentsByScopedKey.get(current) || []));
- }
-
- reachabilityCache.set(cacheKey, false);
- return false;
- }
-
- const reducedNeedsByScopedKey = new Map();
- for (const [fromKey, needs] of directNeedsByScopedKey.entries()) {
- reducedNeedsByScopedKey.set(fromKey, needs.filter((need) => {
- return !needs.some((otherNeed) => otherNeed !== need && canReach(need, otherNeed));
- }));
- }
-
- return reducedNeedsByScopedKey;
-}
-
-const directNeedsByScopedKey = computed(() => buildDirectNeedsMap(props.jobs));
-
-const edges = computed(() => {
- const edgesList: Edge[] = [];
- // Store every job per scoped key, not just one: matrix-expanded jobs share same jobId
- const jobsByScopedKey = new Map();
-
- for (const job of props.jobs) {
- const key = scopedKey(job);
- const existing = jobsByScopedKey.get(key);
- if (existing) {
- existing.push(job);
- } else {
- jobsByScopedKey.set(key, [job]);
- }
- }
-
- for (const job of props.jobs) {
- for (const needKey of directNeedsByScopedKey.value.get(scopedKey(job)) || []) {
- for (const upstreamJob of jobsByScopedKey.get(needKey) || []) {
- edgesList.push({
- fromId: upstreamJob.id,
- toId: job.id,
- key: `${upstreamJob.id}-${job.id}`,
- });
- }
- }
- }
-
- return edgesList;
-});
-
-function buildRoundedConnectorPath(startX: number, startY: number, endX: number, endY: number, turnX: number): string {
- const deltaY = endY - startY;
- if (Math.abs(deltaY) < 1) {
- return `M ${startX} ${startY} H ${endX}`;
- }
-
- const direction = deltaY > 0 ? 1 : -1;
- const elbowSize = Math.max(8, Math.min(24, Math.abs(deltaY) / 2, Math.abs(endX - startX) / 2));
- const controlOffset = elbowSize / 2;
- const clampedTurnX = Math.min(Math.max(turnX, startX + elbowSize), endX - elbowSize);
-
- return [
- `M ${startX} ${startY}`,
- `H ${clampedTurnX - elbowSize}`,
- `C ${clampedTurnX - controlOffset} ${startY} ${clampedTurnX} ${startY + direction * controlOffset} ${clampedTurnX} ${startY + direction * elbowSize}`,
- `V ${endY - direction * elbowSize}`,
- `C ${clampedTurnX} ${endY - direction * controlOffset} ${clampedTurnX + controlOffset} ${endY} ${clampedTurnX + elbowSize} ${endY}`,
- `H ${endX}`,
- ].join(' ');
-}
-
-const routedEdges = computed(() => {
- const nodesById = new Map(jobsWithLayout.value.map((job) => [job.id, job]));
- const outgoingEdges = new Map();
- const incomingEdges = new Map();
-
- for (const edge of edges.value) {
- if (!outgoingEdges.has(edge.fromId)) {
- outgoingEdges.set(edge.fromId, []);
- }
- outgoingEdges.get(edge.fromId)!.push(edge);
-
- if (!incomingEdges.has(edge.toId)) {
- incomingEdges.set(edge.toId, []);
- }
- incomingEdges.get(edge.toId)!.push(edge);
- }
-
- for (const sourceEdges of outgoingEdges.values()) {
- sourceEdges.sort((a, b) => {
- const targetA = nodesById.get(a.toId);
- const targetB = nodesById.get(b.toId);
- if (!targetA || !targetB) return 0;
- return targetA.y - targetB.y || a.toId - b.toId;
- });
- }
-
- const edgePaths: RoutedEdge[] = [];
-
- for (const edge of edges.value) {
- const fromNode = nodesById.get(edge.fromId);
- const toNode = nodesById.get(edge.toId);
- if (!fromNode || !toNode) continue;
-
- const startX = fromNode.x + nodeWidth.value;
- const startY = fromNode.y + nodeHeight / 2;
- const endX = toNode.x;
- const endY = toNode.y + nodeHeight / 2;
- const sourceEdges = outgoingEdges.get(edge.fromId) || [];
- const targetEdges = incomingEdges.get(edge.toId) || [];
- const horizontalGap = endX - startX;
- const turnOffset = Math.min(28, Math.max(16, horizontalGap * 0.14));
- const sourceTurnX = startX + turnOffset;
- const targetTurnX = endX - turnOffset;
-
- let turnX = startX + horizontalGap / 2;
- if (sourceEdges.length > 1) {
- turnX = sourceTurnX;
- } else if (targetEdges.length > 1) {
- turnX = targetTurnX;
- }
-
- const path = buildRoundedConnectorPath(startX, startY, endX, endY, turnX);
-
- edgePaths.push({
- ...edge,
- path,
- fromNode,
- toNode,
- });
- }
-
- return edgePaths;
-});
-
-const graphMetrics = computed(() => {
- const successCount = jobsWithLayout.value.filter(job => job.status === 'success').length;
-
- const levels = new Map();
- jobsWithLayout.value.forEach(job => {
- const count = levels.get(job.level) || 0;
- levels.set(job.level, count + 1);
- })
- const parallelism = Math.max(...Array.from(levels.values()), 0);
-
- return {
- successRate: `${((successCount / jobsWithLayout.value.length) * 100).toFixed(0)}%`,
- parallelism,
- };
-})
-
const graphStats = computed(() => [
trN(props.jobs.length, props.locale.graphJobsCount1, props.locale.graphJobsCountN),
trN(edges.value.length, props.locale.graphDependenciesCount1, props.locale.graphDependenciesCountN),
- props.locale.graphSuccessRate.replace('%s', graphMetrics.value.successRate),
-].join(' • '))
-
-const nodeHeight = 52;
-const verticalSpacing = 90;
-const margin = 40;
+ props.locale.graphSuccessRate.replace('%s', successRateLabel.value),
+].join(' • '));
const minScale = 0.3;
const maxScale = 1;
@@ -398,42 +143,32 @@ function resetView() {
function handleMouseDown(e: MouseEvent) {
if (!isPlainClick(e)) return;
-
- // don't start drag on interactive/text elements inside the SVG
const target = e.target as Element;
const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group');
if (interactive?.closest('svg')) return;
e.preventDefault();
-
isDragging.value = true;
lastMousePos.value = {x: e.clientX, y: e.clientY};
- graphContainer.value!.style.cursor = 'grabbing';
+ if (graphContainer.value) graphContainer.value.style.cursor = 'grabbing';
}
function handleMouseMoveOnDocument(event: MouseEvent) {
if (!isDragging.value) return;
- const dx = event.clientX - lastMousePos.value.x;
- const dy = event.clientY - lastMousePos.value.y;
-
- translateX.value += dx;
- translateY.value += dy;
-
+ translateX.value += event.clientX - lastMousePos.value.x;
+ translateY.value += event.clientY - lastMousePos.value.y;
lastMousePos.value = {x: event.clientX, y: event.clientY};
}
function handleMouseUpOnDocument() {
if (!isDragging.value) return;
isDragging.value = false;
- graphContainer.value!.style.cursor = 'grab';
+ if (graphContainer.value) graphContainer.value.style.cursor = 'grab';
}
function handleWheel(event: WheelEvent) {
- // Without a modifier, let the wheel scroll the page
- if (!event.ctrlKey && !event.metaKey) {
- return;
- }
+ if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const zoomFactor = Math.exp(-event.deltaY * 0.0015);
zoomTo(scale.value * zoomFactor);
@@ -442,8 +177,6 @@ function handleWheel(event: WheelEvent) {
onMounted(() => {
loadSavedState();
watch([translateX, translateY, scale], debounce(500, saveState));
- watch([scale], debounce(100, saveState));
-
document.addEventListener('mousemove', handleMouseMoveOnDocument);
document.addEventListener('mouseup', handleMouseUpOnDocument);
});
@@ -453,106 +186,40 @@ onUnmounted(() => {
document.removeEventListener('mouseup', handleMouseUpOnDocument);
});
-function handleNodeMouseEnter(job: JobNode) {
- hoveredJobId.value = job.id;
+function handleNodeMouseEnter(id: string) {
+ hoveredGraphId.value = id;
}
function handleNodeMouseLeave() {
- hoveredJobId.value = null;
+ hoveredGraphId.value = null;
+}
+
+const highlightState = computed(() => computeGraphHighlightState(hoveredGraphId.value, graphModel.value.adjacency));
+
+function isNodeHighlighted(nodeId: string): boolean {
+ return highlightState.value.nodeIds.has(nodeId);
}
function isEdgeHighlighted(edge: RoutedEdge): boolean {
- if (!hoveredJobId.value) {
- return false;
- }
- return edge.fromId === hoveredJobId.value || edge.toId === hoveredJobId.value;
+ return highlightState.value.edgeKeys.has(edge.key);
}
-const nodesWithIncomingEdge = computed(() => {
- const set = new Set();
- for (const edge of routedEdges.value) set.add(edge.toId);
- return set;
+const splitRoutedEdges = computed(() => {
+ const highlighted: RoutedEdge[] = [];
+ const dimmed: RoutedEdge[] = [];
+ for (const edge of routedEdges.value) (isEdgeHighlighted(edge) ? highlighted : dimmed).push(edge);
+ return {highlighted, dimmed};
});
-const nodesWithOutgoingEdge = computed(() => {
- const set = new Set();
- for (const edge of routedEdges.value) set.add(edge.fromId);
- return set;
-});
+const nodesWithIncomingEdge = computed(() => new Set(graphModel.value.adjacency.incomingByNodeId.keys()));
+const nodesWithOutgoingEdge = computed(() => new Set(graphModel.value.adjacency.outgoingByNodeId.keys()));
-
-function computeJobLevels(jobs: ActionsJob[]): Map {
- // Scope-aware: each job is keyed by `${parentJobID}:${jobId}` so the same JobID
- // in different reusable workflow calls does not cross-link in the level graph.
- const jobMap = new Map();
- jobs.forEach(job => {
- jobMap.set(scopedKey(job), job);
- });
-
- const levels = new Map();
- const visited = new Set();
- const recursionStack = new Set();
- const MAX_DEPTH = 100;
-
- function dfs(scoped: string, depth: number = 0): number {
- if (depth > MAX_DEPTH) {
- console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${scoped}`);
- return 0;
- }
-
- if (recursionStack.has(scoped)) {
- console.error(`Cycle detected involving: ${scoped}`);
- return 0;
- }
-
- if (visited.has(scoped)) {
- return levels.get(scoped) || 0;
- }
-
- recursionStack.add(scoped);
- visited.add(scoped);
-
- const job = jobMap.get(scoped);
- if (!job) {
- recursionStack.delete(scoped);
- return 0;
- }
-
- if (!job.needs?.length) {
- levels.set(scoped, 0);
- recursionStack.delete(scoped);
- return 0;
- }
-
- let maxLevel = -1;
- for (const need of job.needs) {
- const needScoped = `${job.parentJobID || 0}:${need}`;
- const needJob = jobMap.get(needScoped);
- if (!needJob) continue;
-
- const needLevel = dfs(needScoped, depth + 1);
- maxLevel = Math.max(maxLevel, needLevel);
- }
-
- const level = maxLevel + 1;
- levels.set(scoped, level);
-
- recursionStack.delete(scoped);
- return level;
- }
-
- jobs.forEach(job => {
- const sk = scopedKey(job);
- if (!visited.has(sk)) {
- dfs(sk);
- }
- });
-
- return levels;
-}
-
-function onNodeClick(job: JobNode, event: MouseEvent) {
- const link = `${props.runLink}/jobs/${job.id}`;
+function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
+ const target = 'jobs' in job ? job.jobs[0]! : job;
+ // Reusable callers have no per-job detail page; clicking them is a no-op so the graph
+ // doesn't lead users to a dead destination.
+ if (target.isReusableCaller) return;
+ const link = `${props.runLink}/jobs/${target.id}`;
if (event.ctrlKey || event.metaKey) {
window.open(link, '_blank');
return;
@@ -562,7 +229,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
-
+
-
+
+
+
+
+
+
-
- {{ job.name }}
-
-
+
+
-
-
-
-
-
+
-
-
+ Matrix: {{ job.matrixKey }}
+
+
+
+
Matrix: {{ job.matrixKey }}
+
+
+
+
{{ job.jobs.length }} jobs completed
+
+
Show all jobs
+
+
+
+
+
+
+
-
-
- {{ job.name }}
- {{ job.duration }}
-
-
+ {{ job.name }}
+
+
+
+
+
+
+
+
+ {{ job.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -688,6 +419,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
display: flex;
flex-direction: column;
}
+
.graph-header {
display: flex;
justify-content: space-between;
@@ -719,7 +451,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
.graph-container {
flex: 1;
- overflow: hidden;
+ overflow: auto;
padding: 10px 14px 18px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
cursor: grab;
@@ -737,86 +469,210 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
}
.graph-svg path {
- transition: all 0.2s ease;
+ transition: stroke-width 0.2s ease, opacity 0.2s ease;
stroke-linecap: round;
stroke-linejoin: round;
}
+.node-edge {
+ stroke: var(--color-secondary-dark-2);
+ stroke-width: 1.5;
+ opacity: 0.9;
+}
+
.highlighted-edge {
- stroke-width: 2 !important;
- stroke: var(--color-workflow-edge-hover) !important;
+ stroke: var(--color-primary);
+ stroke-width: 2;
}
.job-node-group {
cursor: pointer;
- transition: all 0.2s ease;
+ transition: opacity 0.15s ease;
}
-.job-node-group:hover .job-rect {
- /* due to SVG rendering limitation, only one of fill and drop-shadow can work */
- fill: var(--color-hover);
- /* filter: drop-shadow(0 1px 3px var(--color-shadow-opaque)); */
+.job-node-group.caller-node {
+ cursor: default;
}
-.job-text-wrap {
+.job-node-group:hover .job-rect,
+.job-node-group.related-node .job-rect {
+ stroke: var(--color-primary);
+ stroke-width: 1.5;
+ fill: var(--color-primary-alpha-10);
+}
+
+.graph-svg.has-hover .job-node-group:not(.related-node) {
+ opacity: 0.2;
+}
+
+.graph-svg.has-hover .node-edge:not(.highlighted-edge) {
+ opacity: 0.15;
+}
+
+.highlighted-edge-layer {
+ pointer-events: none;
+}
+
+.highlighted-port {
+ fill: var(--color-primary);
+ stroke: var(--color-primary);
+}
+
+.job-rect {
+ fill: var(--color-box-body);
+ stroke: var(--color-secondary);
+ stroke-width: 1;
+}
+
+.matrix-foreign-object {
+ pointer-events: auto;
+ overflow: visible;
+}
+
+.matrix-panel,
+.grouped-panel {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ border-radius: 6px;
+ background: transparent;
+ pointer-events: auto;
+ user-select: none;
+}
+
+.matrix-panel {
+ display: flex;
+ flex-direction: column;
+ padding: 6px 10px 8px;
+}
+
+.matrix-panel-label {
+ font-size: 10px;
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-light-2);
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.matrix-panel-collapsed {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 2px 0 0 2px;
+ cursor: pointer;
+}
+
+.matrix-panel-summary-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.matrix-panel-summary {
+ font-size: 12px;
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.3;
+ color: var(--color-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.matrix-panel-toggle {
+ font-size: 11px;
+ color: var(--color-text-light-2);
+ padding-left: 24px;
+ cursor: pointer;
+}
+
+.matrix-panel-toggle:hover {
+ color: var(--color-primary);
+ text-decoration: underline;
+}
+
+.matrix-panel-jobs {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 4px 0 0 2px;
+ overflow-y: auto;
+}
+
+.grouped-panel {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 6px;
+ gap: 2px;
+}
+
+.graph-list-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 24px;
+ padding: 1px 6px;
+ border-radius: 5px;
+}
+
+.graph-list-row:hover {
+ background: var(--color-hover);
+}
+
+.graph-list-row-main,
+.job-row-main {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.graph-list-row-name,
+.job-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text);
+}
+
+.graph-list-row-duration,
+.job-duration {
+ flex: 0 0 auto;
+ font-size: 10px;
+ color: var(--color-text-light-2);
+ white-space: nowrap;
+}
+
+.job-row {
width: 100%;
height: 100%;
display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- gap: 1px;
- padding: 4px 8px 4px 0;
- overflow: hidden;
-}
-
-.job-name {
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 12px;
- font-weight: var(--font-weight-semibold);
- color: var(--color-text);
- user-select: none;
- pointer-events: none;
-}
-
-.job-duration {
- font-size: 10px;
- line-height: 1.2;
- color: var(--color-text-light-2);
- white-space: nowrap;
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- user-select: none;
- pointer-events: none;
-}
-
-.job-status-fg-obj,
-.job-status-icon-wrap {
- pointer-events: none;
-}
-
-.job-status-icon-wrap {
- width: 20px;
- height: 20px;
- display: flex;
align-items: center;
- justify-content: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.job-card {
+ border-radius: 6px;
+ padding: 0 2px;
}
.node-port {
- fill: var(--color-box-body);
- stroke: var(--color-light-border);
+ fill: var(--color-secondary-dark-2);
+ stroke: var(--color-box-body);
stroke-width: 1.25;
- opacity: 0.85;
+ opacity: 0.9;
pointer-events: none;
}
-.node-edge {
- transition: stroke-width 0.2s ease, opacity 0.2s ease;
- opacity: 0.75;
+.job-node-group.related-node .node-port {
+ fill: var(--color-primary);
}