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" .}}
- Run:CanCancel - Run:CanApprove - Run:CanRerunLatest - Run:PreviousAttempt - Run:ReusableCaller + Run:CanCancel + Run:CanApprove + Run:CanRerunLatest + Run:PreviousAttempt + Run:ReusableCaller
{{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 }} +
@@ -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) { + + + + + @@ -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); }