mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-10 05:20:28 +00:00
enhance: Adjust Workflow Graph styling (#37497)
- Fix workflow dependency graph overflow by making the graph container scrollable (no more clipped DAGs; addresses #37493). - Improve Actions job list readability by keeping durations fixed-width/right-aligned so long times don’t squeeze job names. - Make workflow graph layout more intuitive by vertically centering shorter columns to reduce misleading “looks like it depends on” alignments (addresses #37395). ### Screenshot <img width="966" height="439" src="https://github.com/user-attachments/assets/c180c5a2-4f56-4287-bcaa-f2735ba72949" /> <img width="949" height="559" src="https://github.com/user-attachments/assets/a383511d-a962-4920-b792-69f556847eff" /> Fixes #37493 Fixes #37395 --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content">
|
||||
<div class="flex-text-block tw-justify-center tw-gap-5">
|
||||
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||
<a href="/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
|
||||
<a href="/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
|
||||
<a href="/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
|
||||
</div>
|
||||
{{template "repo/actions/view_component" (dict
|
||||
"JobID" (or .JobID 0)
|
||||
|
||||
@@ -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<CurrentJob>({
|
||||
const stepsContainer = ref<HTMLElement | null>(null);
|
||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||
|
||||
// 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<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
|
||||
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
||||
const callerChildJobs = computed<ActionsJob[]>(() => {
|
||||
if (!isCallerJob.value) return [];
|
||||
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
|
||||
});
|
||||
|
||||
watch(optionAlwaysAutoScroll, () => {
|
||||
saveLocaleStorageOptions();
|
||||
@@ -477,20 +472,6 @@ async function hashChangeListener() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Caller (reusable workflow) view: render the direct children's dependency graph,
|
||||
mirroring the run summary's WorkflowGraph but scoped to this caller's subtree.
|
||||
The caller's name + uses path + status all live in job-info-header above. -->
|
||||
<div class="caller-children-container" v-if="isCallerJob">
|
||||
<WorkflowGraph
|
||||
v-if="callerChildJobs.length > 0"
|
||||
:store="store"
|
||||
:jobs="callerChildJobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="`${run.workflowID}#caller-${props.jobId}`"
|
||||
:locale="locale"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
|
||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||
@@ -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);
|
||||
|
||||
@@ -104,12 +104,6 @@ export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJ
|
||||
return childrenByParent;
|
||||
}
|
||||
|
||||
// collectCallerChildJobs returns the direct children of a caller job.
|
||||
export function collectCallerChildJobs(jobs: ActionsJob[], callerJobID: number): ActionsJob[] {
|
||||
if (!callerJobID) return [];
|
||||
return buildJobsByParentJobID(jobs).get(callerJobID) || [];
|
||||
}
|
||||
|
||||
export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
repoId: 0,
|
||||
|
||||
@@ -26,7 +26,6 @@ const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
type JobListItem = {
|
||||
job: ActionsJob;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
};
|
||||
|
||||
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
|
||||
@@ -71,9 +70,8 @@ const visibleJobListItems = computed<JobListItem[]>(() => {
|
||||
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"
|
||||
>
|
||||
<a class="tw-contents silenced" :href="item.job.link">
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
<!-- Callers have no log page of their own; the whole row toggles expansion
|
||||
(matches GitHub Actions, where caller rows are not navigation targets). -->
|
||||
<button
|
||||
v-if="item.hasChildren"
|
||||
v-if="item.job.isReusableCaller"
|
||||
type="button"
|
||||
class="job-brief-toggle"
|
||||
:class="{'collapsed': isJobCollapsed(item.job.id)}"
|
||||
class="tw-contents caller-row-toggle"
|
||||
@click="toggleExpandedJob(item.job.id)"
|
||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-expanded="!isJobCollapsed(item.job.id)"
|
||||
>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14"/>
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
|
||||
</button>
|
||||
<a v-else class="tw-contents silenced" :href="item.job.link">
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +260,7 @@ async function deleteArtifact(name: string) {
|
||||
<SvgIcon name="octicon-trash"/>
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2">
|
||||
<SvgIcon name="octicon-file-removed"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
197
web_src/js/components/WorkflowGraph.utils.test.ts
Normal file
197
web_src/js/components/WorkflowGraph.utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
559
web_src/js/components/WorkflowGraph.utils.ts
Normal file
559
web_src/js/components/WorkflowGraph.utils.ts
Normal file
@@ -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<string>;
|
||||
edgeKeys: Set<string>;
|
||||
};
|
||||
|
||||
export type WorkflowGraphLayoutOptions = {
|
||||
margin: number;
|
||||
nodeWidth: number;
|
||||
nodeHeight: number;
|
||||
columnGap: number;
|
||||
laneGap: number;
|
||||
groupRowHeight: number;
|
||||
groupPadY: number;
|
||||
matrixCollapsedHeight: number;
|
||||
matrixHeaderHeight: number;
|
||||
matrixRowHeight: number;
|
||||
matrixPadY: number;
|
||||
};
|
||||
|
||||
export type WorkflowGraphModel = {
|
||||
nodes: GraphNode[];
|
||||
edges: Edge[];
|
||||
routedEdges: RoutedEdge[];
|
||||
sharedSegments: SharedSegment[];
|
||||
adjacency: NodeAdjacency;
|
||||
};
|
||||
|
||||
export type NodeAdjacency = {
|
||||
incomingByNodeId: Map<string, string[]>;
|
||||
outgoingByNodeId: Map<string, string[]>;
|
||||
};
|
||||
|
||||
const defaultLayoutOptions: WorkflowGraphLayoutOptions = {
|
||||
margin: 24,
|
||||
nodeWidth: 220,
|
||||
nodeHeight: 40,
|
||||
columnGap: 96,
|
||||
laneGap: 32,
|
||||
groupRowHeight: 28,
|
||||
groupPadY: 8,
|
||||
matrixCollapsedHeight: 78,
|
||||
matrixHeaderHeight: 24,
|
||||
matrixRowHeight: 26,
|
||||
matrixPadY: 6,
|
||||
};
|
||||
|
||||
function canonicalKey(ids: Iterable<string>): string {
|
||||
return Array.from(ids).sort().join('');
|
||||
}
|
||||
|
||||
function graphIdForJob(job: ActionsJob): string {
|
||||
return `job:${job.id}`;
|
||||
}
|
||||
|
||||
export function matrixKeyFromJobName(name: string): string | null {
|
||||
const idx = name.indexOf(' (');
|
||||
if (idx === -1) return null;
|
||||
return name.slice(0, idx).trim() || null;
|
||||
}
|
||||
|
||||
export function boxBottom(node: GraphNode): number {
|
||||
return node.y + node.displayHeight;
|
||||
}
|
||||
|
||||
export function boxCenterY(node: GraphNode): number {
|
||||
return node.y + node.displayHeight / 2;
|
||||
}
|
||||
|
||||
function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number {
|
||||
if (rowCount <= 0) return options.nodeHeight;
|
||||
if (!expanded) return options.matrixCollapsedHeight;
|
||||
return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2;
|
||||
}
|
||||
|
||||
function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number {
|
||||
return rowCount * options.groupRowHeight + options.groupPadY * 2;
|
||||
}
|
||||
|
||||
function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
|
||||
const rank = (s: ActionsStatus) => {
|
||||
if (s === 'failure') return 0;
|
||||
if (s === 'cancelled') return 1;
|
||||
if (s === 'running') return 2;
|
||||
if (s === 'waiting') return 3;
|
||||
if (s === 'blocked') return 4;
|
||||
if (s === 'success') return 5;
|
||||
if (s === 'skipped') return 6;
|
||||
return 7;
|
||||
};
|
||||
return rank(a) - rank(b);
|
||||
}
|
||||
|
||||
function aggregateStatus(children: ActionsJob[]): ActionsStatus {
|
||||
return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown';
|
||||
}
|
||||
|
||||
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
|
||||
const directNeedsByJobId = new Map<string, string[]>();
|
||||
const dependentsByJobId = new Map<string, Set<string>>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const needs = job.needs || [];
|
||||
directNeedsByJobId.set(job.jobId, needs);
|
||||
for (const need of needs) {
|
||||
if (!dependentsByJobId.has(need)) dependentsByJobId.set(need, new Set());
|
||||
dependentsByJobId.get(need)!.add(job.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
const reachabilityCache = new Map<string, boolean>();
|
||||
function canReach(fromJobId: string, toJobId: string): boolean {
|
||||
const cacheKey = `${fromJobId}->${toJobId}`;
|
||||
if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
|
||||
const visited = new Set<string>();
|
||||
const stack = Array.from(dependentsByJobId.get(fromJobId) || []);
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
if (current === toJobId) {
|
||||
reachabilityCache.set(cacheKey, true);
|
||||
return true;
|
||||
}
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
stack.push(...(dependentsByJobId.get(current) || []));
|
||||
}
|
||||
reachabilityCache.set(cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const reducedNeedsByJobId = new Map<string, string[]>();
|
||||
for (const [jobId, needs] of directNeedsByJobId) {
|
||||
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
|
||||
return !needs.some((other) => other !== need && canReach(need, other));
|
||||
}));
|
||||
}
|
||||
return reducedNeedsByJobId;
|
||||
}
|
||||
|
||||
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
const jobMap = new Map<string, ActionsJob>();
|
||||
for (const job of jobs) {
|
||||
jobMap.set(job.name, job);
|
||||
if (job.jobId) jobMap.set(job.jobId, job);
|
||||
}
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
function dfs(jobNameOrId: string): number {
|
||||
if (recursionStack.has(jobNameOrId)) return 0;
|
||||
if (visited.has(jobNameOrId)) return levels.get(jobNameOrId) ?? 0;
|
||||
recursionStack.add(jobNameOrId);
|
||||
visited.add(jobNameOrId);
|
||||
|
||||
const job = jobMap.get(jobNameOrId);
|
||||
if (!job) {
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
if (!job.needs?.length) {
|
||||
levels.set(job.jobId, 0);
|
||||
if (job.jobId !== job.name) levels.set(job.name, 0);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxLevel = -1;
|
||||
for (const need of job.needs) {
|
||||
if (!jobMap.has(need)) continue;
|
||||
maxLevel = Math.max(maxLevel, dfs(need));
|
||||
}
|
||||
const level = maxLevel + 1;
|
||||
levels.set(job.name, level);
|
||||
levels.set(job.jobId, level);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return level;
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!visited.has(job.jobId)) dfs(job.jobId);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState {
|
||||
if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()};
|
||||
const {incomingByNodeId, outgoingByNodeId} = adjacency;
|
||||
|
||||
const edgeKeys = new Set<string>();
|
||||
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
|
||||
const seen = new Set<string>();
|
||||
const queue = [startId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const next of adj.get(current) || []) {
|
||||
edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`);
|
||||
if (!seen.has(next)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
const ancestors = collect(hoveredId, incomingByNodeId, false);
|
||||
const descendants = collect(hoveredId, outgoingByNodeId, true);
|
||||
return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys};
|
||||
}
|
||||
|
||||
type VisualGraphBuild = {
|
||||
nodes: GraphNode[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
function buildVisualGraph(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixKeys: ReadonlySet<string>,
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): VisualGraphBuild {
|
||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||
const jobIndexById = new Map<number, number>();
|
||||
for (const [index, job] of jobs.entries()) {
|
||||
jobIndexById.set(job.id, index);
|
||||
if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []);
|
||||
jobsByJobId.get(job.jobId)!.push(job);
|
||||
}
|
||||
|
||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
|
||||
// even if their display name happens to look like "name (variant)".
|
||||
if (job.isReusableCaller) continue;
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (!matrixKey) continue;
|
||||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||||
matrixJobsByKey.get(matrixKey)!.push(job);
|
||||
}
|
||||
for (const list of matrixJobsByKey.values()) {
|
||||
list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
||||
const rawLevels = computeJobLevels(jobs);
|
||||
const dependentsByJobId = new Map<string, string[]>();
|
||||
const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
for (const need of directNeedsByJobId.get(job.jobId) || []) {
|
||||
for (const upstream of jobsByJobId.get(need) || []) {
|
||||
rawEdges.push({from: upstream, to: job});
|
||||
if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []);
|
||||
dependentsByJobId.get(upstream.jobId)!.push(job.jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const list of dependentsByJobId.values()) list.sort();
|
||||
|
||||
// Group sibling jobs that share an identical (parents, children) signature into a single
|
||||
// collapsed "group" node. This is a visual aggregation only - the underlying jobs are
|
||||
// preserved on the node so the panel can list them.
|
||||
const groupedJobIds = new Map<number, string>();
|
||||
const groupsById = new Map<string, ActionsJob[]>();
|
||||
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
if (matrixKeyFromJobName(job.name)) continue;
|
||||
// Reusable callers represent distinct workflow files — keep each as its own node so the
|
||||
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
|
||||
// siblings share an identical (parents, children) dependency signature.
|
||||
if (job.isReusableCaller) continue;
|
||||
const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []);
|
||||
const childrenKey = (dependentsByJobId.get(job.jobId) || []).join('');
|
||||
if (!needsKey && !childrenKey) continue;
|
||||
const level = rawLevels.get(job.jobId) ?? 0;
|
||||
const key = `group:${level}:${needsKey}:${childrenKey}`;
|
||||
if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []);
|
||||
groupCandidateBuckets.get(key)!.push(job);
|
||||
}
|
||||
for (const [groupId, groupJobs] of groupCandidateBuckets) {
|
||||
if (groupJobs.length < 2) continue;
|
||||
groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||||
groupsById.set(groupId, groupJobs);
|
||||
for (const job of groupJobs) groupedJobIds.set(job.id, groupId);
|
||||
}
|
||||
|
||||
const visualIdByJobId = new Map<number, string>();
|
||||
for (const job of jobs) {
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
|
||||
// happens to look like "name (variant)" must never be folded into the matrix node, or it
|
||||
// would silently vanish (its visualId would point at a matrix node it isn't part of).
|
||||
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||||
continue;
|
||||
}
|
||||
visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job));
|
||||
}
|
||||
|
||||
const emittedNodeIds = new Set<string>();
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const job of jobs) {
|
||||
const visualId = visualIdByJobId.get(job.id);
|
||||
if (!visualId || emittedNodeIds.has(visualId)) continue;
|
||||
emittedNodeIds.add(visualId);
|
||||
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (matrixKey && visualId.startsWith('matrix:')) {
|
||||
const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'matrix',
|
||||
name: matrixKey,
|
||||
status: aggregateStatus(matrixJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
|
||||
jobs: matrixJobs,
|
||||
matrixKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupJobs = groupsById.get(visualId);
|
||||
if (groupJobs) {
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'group',
|
||||
name: groupJobs.map((g) => g.name).join(', '),
|
||||
status: aggregateStatus(groupJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: groupPanelHeight(groupJobs.length, options),
|
||||
jobs: groupJobs,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'job',
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
duration: job.duration,
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: options.nodeHeight,
|
||||
jobs: [job],
|
||||
});
|
||||
}
|
||||
|
||||
const seenEdges = new Set<string>();
|
||||
const edges: Edge[] = [];
|
||||
for (const {from, to} of rawEdges) {
|
||||
const fromId = visualIdByJobId.get(from.id);
|
||||
const toId = visualIdByJobId.get(to.id);
|
||||
if (!fromId || !toId || fromId === toId) continue;
|
||||
const key = `${fromId}->${toId}`;
|
||||
if (seenEdges.has(key)) continue;
|
||||
seenEdges.add(key);
|
||||
edges.push({fromId, toId, key});
|
||||
}
|
||||
|
||||
return {nodes, edges};
|
||||
}
|
||||
|
||||
function buildNodeAdjacency(edges: Edge[]): NodeAdjacency {
|
||||
const incomingByNodeId = new Map<string, string[]>();
|
||||
const outgoingByNodeId = new Map<string, string[]>();
|
||||
for (const edge of edges) {
|
||||
if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []);
|
||||
incomingByNodeId.get(edge.toId)!.push(edge.fromId);
|
||||
if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []);
|
||||
outgoingByNodeId.get(edge.fromId)!.push(edge.toId);
|
||||
}
|
||||
return {incomingByNodeId, outgoingByNodeId};
|
||||
}
|
||||
|
||||
function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void {
|
||||
const cache = new Map<string, number>();
|
||||
function levelFor(id: string, visiting = new Set<string>()): number {
|
||||
if (cache.has(id)) return cache.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const incoming = incomingByNodeId.get(id) || [];
|
||||
const level = incoming.length > 0 ?
|
||||
Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 :
|
||||
0;
|
||||
visiting.delete(id);
|
||||
cache.set(id, level);
|
||||
return level;
|
||||
}
|
||||
for (const node of nodes) node.level = levelFor(node.id);
|
||||
}
|
||||
|
||||
// Roots stay in input order; later levels are sorted by the mean parent Y so that simple
|
||||
// chains stay on a straight horizontal line.
|
||||
function assignNodeCoordinates(nodesById: Map<string, GraphNode>, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void {
|
||||
const {incomingByNodeId} = adjacency;
|
||||
const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id));
|
||||
|
||||
const nodesByLevel = new Map<number, GraphNode[]>();
|
||||
for (const node of nodes) {
|
||||
if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []);
|
||||
nodesByLevel.get(node.level)!.push(node);
|
||||
}
|
||||
const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
|
||||
|
||||
// Initial X assignment and a default Y so barycenters can use a finite value.
|
||||
for (const level of orderedLevels) {
|
||||
const list = nodesByLevel.get(level)!;
|
||||
list.sort((a, b) => inputRank(a) - inputRank(b));
|
||||
let yCursor = options.margin;
|
||||
for (const node of list) {
|
||||
node.x = options.margin + level * (options.nodeWidth + options.columnGap);
|
||||
node.y = yCursor;
|
||||
yCursor += node.displayHeight + options.laneGap;
|
||||
}
|
||||
}
|
||||
|
||||
function packLevel(level: number, anchorOf: (n: GraphNode) => number): void {
|
||||
const list = nodesByLevel.get(level)!;
|
||||
const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b));
|
||||
// Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal
|
||||
// nodes like build-image close to the top of their column instead of being pulled down to
|
||||
// the mean Y of their parents — matching GitHub Actions' compact layout.
|
||||
let prevBottom = options.margin - options.laneGap;
|
||||
for (const node of sorted) {
|
||||
node.y = prevBottom + options.laneGap;
|
||||
prevBottom = boxBottom(node);
|
||||
}
|
||||
nodesByLevel.set(level, sorted);
|
||||
}
|
||||
|
||||
function meanCenterOf(ids: string[]): number | null {
|
||||
if (ids.length === 0) return null;
|
||||
let sum = 0;
|
||||
for (const id of ids) sum += boxCenterY(nodesById.get(id)!);
|
||||
return sum / ids.length;
|
||||
}
|
||||
|
||||
// Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots
|
||||
// keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions
|
||||
// arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 →
|
||||
// job-102 stays on a straight horizontal line.
|
||||
for (const level of orderedLevels) {
|
||||
if (level === 0) continue;
|
||||
packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint →
|
||||
// vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is
|
||||
// fixed (not clamped to the row delta) so any two edges sharing the same source produce the
|
||||
// same source-side path and overlap into a single visual line until they diverge at the V.
|
||||
const cornerRadius = 12;
|
||||
|
||||
function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string {
|
||||
if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`;
|
||||
// Anchor the V segment in the column gap immediately before the target instead of the
|
||||
// horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style
|
||||
// — a multi-column edge runs along the source row across intermediate columns, then turns
|
||||
// up/down only when it reaches the target column.
|
||||
const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2);
|
||||
const dy = ey > sy ? 1 : -1;
|
||||
// Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a
|
||||
// source overlap visually until they fork. When there isn't 2*cornerRadius of vertical
|
||||
// room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey)
|
||||
// instead of a backward V kink.
|
||||
if (Math.abs(ey - sy) < cornerRadius * 2) {
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`H ${midX - cornerRadius}`,
|
||||
`C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`,
|
||||
`H ${ex}`,
|
||||
].join(' ');
|
||||
}
|
||||
const half = cornerRadius / 2;
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`H ${midX - cornerRadius}`,
|
||||
`C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`,
|
||||
`V ${ey - cornerRadius * dy}`,
|
||||
`C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`,
|
||||
`H ${ex}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildRoutedEdges(
|
||||
nodesById: Map<string, GraphNode>,
|
||||
edges: Edge[],
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
|
||||
const routedEdges: RoutedEdge[] = [];
|
||||
for (const edge of edges) {
|
||||
const fromNode = nodesById.get(edge.fromId);
|
||||
const toNode = nodesById.get(edge.toId);
|
||||
if (!fromNode || !toNode) continue;
|
||||
const startX = fromNode.x + options.nodeWidth;
|
||||
const endX = toNode.x;
|
||||
const startY = boxCenterY(fromNode);
|
||||
const endY = boxCenterY(toNode);
|
||||
routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)});
|
||||
}
|
||||
|
||||
return {routedEdges, sharedSegments: []};
|
||||
}
|
||||
|
||||
export function createWorkflowGraphModel(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixKeys: ReadonlySet<string> = new Set(),
|
||||
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
||||
): WorkflowGraphModel {
|
||||
const options = {...defaultLayoutOptions, ...partialOptions};
|
||||
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options);
|
||||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adjacency = buildNodeAdjacency(edges);
|
||||
assignNodeLevels(nodes, adjacency);
|
||||
assignNodeCoordinates(nodesById, nodes, adjacency, options);
|
||||
return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency};
|
||||
}
|
||||
|
||||
export function getWorkflowGraphLayoutOptions(partialOptions: Partial<WorkflowGraphLayoutOptions> = {}): WorkflowGraphLayoutOptions {
|
||||
return {...defaultLayoutOptions, ...partialOptions};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user