mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-14 03:29:55 +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)
|
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{
|
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||||
ID: runID * 10,
|
ID: runID * 10,
|
||||||
Link: jobLink(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",
|
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
|
||||||
Status: actions_model.StatusFailure.String(),
|
Status: actions_model.StatusFailure.String(),
|
||||||
CanRerun: false,
|
CanRerun: false,
|
||||||
Duration: "3h",
|
Duration: "3h35m10s",
|
||||||
Needs: []string{"job-100", "job-101"},
|
Needs: []string{"job-100", "job-101"},
|
||||||
})
|
})
|
||||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{{template "base/head" .}}
|
{{template "base/head" .}}
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="flex-text-block tw-justify-center tw-gap-5">
|
<div class="flex-text-block tw-justify-center tw-gap-5">
|
||||||
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||||
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||||
<a href="/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
|
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
|
||||||
<a href="/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
|
<a href="{{AppSubUrl}}/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/40">Run:ReusableCaller</a>
|
||||||
</div>
|
</div>
|
||||||
{{template "repo/actions/view_component" (dict
|
{{template "repo/actions/view_component" (dict
|
||||||
"JobID" (or .JobID 0)
|
"JobID" (or .JobID 0)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||||
import WorkflowGraph from './WorkflowGraph.vue';
|
|
||||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||||
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||||
import {POST} from '../modules/fetch.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 {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||||
import {
|
import {
|
||||||
type ActionRunViewStore,
|
type ActionRunViewStore,
|
||||||
collectCallerChildJobs,
|
|
||||||
createLogLineMessage,
|
createLogLineMessage,
|
||||||
type LogLine,
|
type LogLine,
|
||||||
type LogLineCommand,
|
type LogLineCommand,
|
||||||
@@ -118,14 +116,11 @@ const currentJob = ref<CurrentJob>({
|
|||||||
const stepsContainer = ref<HTMLElement | null>(null);
|
const stepsContainer = ref<HTMLElement | null>(null);
|
||||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||||
|
|
||||||
// Reusable workflow caller view: when the selected job is a caller node, the right pane
|
// Reusable workflow caller view: the right pane shows just the header (name + uses path +
|
||||||
// shows the children list rather than step logs (callers don't run on a runner).
|
// 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 selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
|
||||||
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
||||||
const callerChildJobs = computed<ActionsJob[]>(() => {
|
|
||||||
if (!isCallerJob.value) return [];
|
|
||||||
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(optionAlwaysAutoScroll, () => {
|
watch(optionAlwaysAutoScroll, () => {
|
||||||
saveLocaleStorageOptions();
|
saveLocaleStorageOptions();
|
||||||
@@ -477,20 +472,6 @@ async function hashChangeListener() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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" -->
|
<!-- 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-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
|
||||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||||
@@ -578,8 +559,7 @@ async function hashChangeListener() {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-info-header:has(+ .job-step-container),
|
.job-info-header:has(+ .job-step-container) {
|
||||||
.job-info-header:has(+ .caller-children-container) {
|
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,14 +593,6 @@ async function hashChangeListener() {
|
|||||||
min-width: 0;
|
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 {
|
.job-step-container {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||||
|
|||||||
@@ -104,12 +104,6 @@ export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJ
|
|||||||
return childrenByParent;
|
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 {
|
export function createEmptyActionsRun(): ActionsRun {
|
||||||
return {
|
return {
|
||||||
repoId: 0,
|
repoId: 0,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
|||||||
type JobListItem = {
|
type JobListItem = {
|
||||||
job: ActionsJob;
|
job: ActionsJob;
|
||||||
depth: number;
|
depth: number;
|
||||||
hasChildren: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
|
// 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) {
|
while (stack.length > 0) {
|
||||||
const {job, depth} = stack.pop()!;
|
const {job, depth} = stack.pop()!;
|
||||||
const children = childrenByParent.get(job.id) || [];
|
const children = childrenByParent.get(job.id) || [];
|
||||||
const hasChildren = children.length > 0;
|
result.push({job, depth});
|
||||||
result.push({job, depth, hasChildren});
|
if (children.length > 0 && isJobCollapsed(job.id)) continue;
|
||||||
if (hasChildren && isJobCollapsed(job.id)) continue;
|
|
||||||
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
|
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -216,24 +214,28 @@ async function deleteArtifact(name: string) {
|
|||||||
v-for="item in visibleJobListItems"
|
v-for="item in visibleJobListItems"
|
||||||
:key="item.job.id"
|
:key="item.job.id"
|
||||||
>
|
>
|
||||||
<a class="tw-contents silenced" :href="item.job.link">
|
<!-- Callers have no log page of their own; the whole row toggles expansion
|
||||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
(matches GitHub Actions, where caller rows are not navigation targets). -->
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
v-if="item.hasChildren"
|
v-if="item.job.isReusableCaller"
|
||||||
type="button"
|
type="button"
|
||||||
class="job-brief-toggle"
|
class="tw-contents caller-row-toggle"
|
||||||
:class="{'collapsed': isJobCollapsed(item.job.id)}"
|
|
||||||
@click="toggleExpandedJob(item.job.id)"
|
@click="toggleExpandedJob(item.job.id)"
|
||||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||||
:aria-expanded="!isJobCollapsed(item.job.id)"
|
: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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -258,7 +260,7 @@ async function deleteArtifact(name: string) {
|
|||||||
<SvgIcon name="octicon-trash"/>
|
<SvgIcon name="octicon-trash"/>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</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"/>
|
<SvgIcon name="octicon-file-removed"/>
|
||||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</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);
|
background-color: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-brief-toggle {
|
.caller-row-toggle {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
display: inline-flex;
|
cursor: pointer;
|
||||||
align-items: center;
|
text-align: inherit;
|
||||||
justify-content: center;
|
}
|
||||||
|
|
||||||
|
.job-brief-toggle-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* the icon is always chevron-down; flip to chevron-up when expanded */
|
|
||||||
transition: transform 0.15s ease;
|
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;
|
order: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-brief-toggle:not(.collapsed) {
|
.job-brief-toggle-icon:not(.collapsed) {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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