Skip to content

Commit 1c289df

Browse files
bircnisilverwindclaudewxiaoguang
authored
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>
1 parent ea35af1 commit 1c289df

8 files changed

Lines changed: 1230 additions & 581 deletions

File tree

routers/web/devtest/mock_actions.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,75 @@ func MockActionsRunsJobs(ctx *context.Context) {
214214
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
215215
}
216216

217+
// Keep devtest mock runs minimal: use run 10 as a "complex graph" repro.
218+
// This combines long durations, parallel roots, and a multi-dependency downstream job
219+
// to validate the workflow graph rendering.
220+
if runID == 10 {
221+
resp.State.Run.WorkflowID = "workflow-devtest-complex"
222+
resp.State.Run.Duration = "7h 12m 34s"
223+
224+
type mj struct {
225+
jobID string
226+
name string
227+
status actions_model.Status
228+
duration string
229+
needs []string
230+
}
231+
mockJobs := []mj{
232+
{jobID: "job-100", name: "job-100", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
233+
{jobID: "job-101", name: "job-101", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"job-100"}},
234+
{jobID: "job-102", name: "job-102", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"job-100", "job-101"}},
235+
{jobID: "job-103", name: "job-103", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"job-100"}},
236+
237+
{jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
238+
{jobID: "code-analysis", name: "code-analysis", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
239+
240+
// Matrix expansion (the " (...)" suffix is the heuristic the frontend uses to group rows)
241+
{jobID: "matrix-e2e-1-chromium", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
242+
{jobID: "matrix-e2e-1-firefox", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
243+
{jobID: "matrix-e2e-2-chromium", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
244+
{jobID: "matrix-e2e-3-chromium", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
245+
{jobID: "matrix-e2e-3-firefox", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
246+
{jobID: "matrix-e2e-99-webkit", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
247+
248+
{jobID: "unit-test", name: "unit-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
249+
{jobID: "arch-test", name: "arch-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
250+
{jobID: "integration-test", name: "integration-test", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
251+
252+
{jobID: "build-image", name: "build-image", status: actions_model.StatusSuccess, duration: "3s", needs: []string{
253+
"unit-test",
254+
"arch-test",
255+
"integration-test",
256+
"code-analysis",
257+
"matrix-e2e-1-chromium",
258+
"matrix-e2e-1-firefox",
259+
"matrix-e2e-2-chromium",
260+
"matrix-e2e-3-chromium",
261+
"matrix-e2e-3-firefox",
262+
"matrix-e2e-99-webkit",
263+
}},
264+
}
265+
266+
resp.State.Run.Jobs = nil
267+
for i, j := range mockJobs {
268+
id := runID*1000 + int64(i)
269+
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
270+
ID: id,
271+
Link: jobLink(id),
272+
JobID: j.jobID,
273+
Name: j.name,
274+
Status: j.status.String(),
275+
CanRerun: j.jobID == "job-100",
276+
Duration: j.duration,
277+
Needs: j.needs,
278+
})
279+
}
280+
281+
fillViewRunResponseCurrentJob(ctx, resp)
282+
ctx.JSON(http.StatusOK, resp)
283+
return
284+
}
285+
217286
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
218287
ID: runID * 10,
219288
Link: jobLink(runID * 10),
@@ -240,7 +309,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
240309
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
241310
Status: actions_model.StatusFailure.String(),
242311
CanRerun: false,
243-
Duration: "3h",
312+
Duration: "3h35m10s",
244313
Needs: []string{"job-100", "job-101"},
245314
})
246315
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{

templates/devtest/repo-action-view.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{{template "base/head" .}}
22
<div class="page-content">
33
<div class="flex-text-block tw-justify-center tw-gap-5">
4-
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
5-
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
6-
<a href="/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
7-
<a href="/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
8-
<a href="/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
4+
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10">Run:CanCancel</a>
5+
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/20">Run:CanApprove</a>
6+
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
7+
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
8+
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
99
</div>
1010
{{template "repo/actions/view_component" (dict
1111
"JobID" (or .JobID 0)

web_src/js/components/ActionRunJobView.vue

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
33
import {SvgIcon} from '../svg.ts';
44
import ActionStatusIcon from './ActionStatusIcon.vue';
5-
import WorkflowGraph from './WorkflowGraph.vue';
65
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
76
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
87
import {POST} from '../modules/fetch.ts';
@@ -13,7 +12,6 @@ import {localUserSettings} from '../modules/user-settings.ts';
1312
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
1413
import {
1514
type ActionRunViewStore,
16-
collectCallerChildJobs,
1715
createLogLineMessage,
1816
type LogLine,
1917
type LogLineCommand,
@@ -118,14 +116,11 @@ const currentJob = ref<CurrentJob>({
118116
const stepsContainer = ref<HTMLElement | null>(null);
119117
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
120118
121-
// Reusable workflow caller view: when the selected job is a caller node, the right pane
122-
// shows the children list rather than step logs (callers don't run on a runner).
119+
// Reusable workflow caller view: the right pane shows just the header (name + uses path +
120+
// status). Callers don't run on a runner, and the dependency graph for their children lives
121+
// in the run summary's WorkflowGraph, not here — matching GitHub Actions.
123122
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
124123
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
125-
const callerChildJobs = computed<ActionsJob[]>(() => {
126-
if (!isCallerJob.value) return [];
127-
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
128-
});
129124
130125
watch(optionAlwaysAutoScroll, () => {
131126
saveLocaleStorageOptions();
@@ -477,20 +472,6 @@ async function hashChangeListener() {
477472
</div>
478473
</div>
479474
</div>
480-
<!-- Caller (reusable workflow) view: render the direct children's dependency graph,
481-
mirroring the run summary's WorkflowGraph but scoped to this caller's subtree.
482-
The caller's name + uses path + status all live in job-info-header above. -->
483-
<div class="caller-children-container" v-if="isCallerJob">
484-
<WorkflowGraph
485-
v-if="callerChildJobs.length > 0"
486-
:store="store"
487-
:jobs="callerChildJobs"
488-
:run-link="run.link"
489-
:workflow-id="`${run.workflowID}#caller-${props.jobId}`"
490-
:locale="locale"
491-
/>
492-
</div>
493-
494475
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
495476
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
496477
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
@@ -578,8 +559,7 @@ async function hashChangeListener() {
578559
border-radius: 3px;
579560
}
580561
581-
.job-info-header:has(+ .job-step-container),
582-
.job-info-header:has(+ .caller-children-container) {
562+
.job-info-header:has(+ .job-step-container) {
583563
border-radius: var(--border-radius) var(--border-radius) 0 0;
584564
}
585565
@@ -613,14 +593,6 @@ async function hashChangeListener() {
613593
min-width: 0;
614594
}
615595
616-
.caller-children-container {
617-
flex: 1;
618-
display: flex;
619-
flex-direction: column;
620-
border-top: 1px solid var(--color-console-border);
621-
color: var(--color-console-fg);
622-
}
623-
624596
.job-step-container {
625597
max-height: 100%;
626598
border-radius: 0 0 var(--border-radius) var(--border-radius);

web_src/js/components/ActionRunView.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,6 @@ export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJ
104104
return childrenByParent;
105105
}
106106

107-
// collectCallerChildJobs returns the direct children of a caller job.
108-
export function collectCallerChildJobs(jobs: ActionsJob[], callerJobID: number): ActionsJob[] {
109-
if (!callerJobID) return [];
110-
return buildJobsByParentJobID(jobs).get(callerJobID) || [];
111-
}
112-
113107
export function createEmptyActionsRun(): ActionsRun {
114108
return {
115109
repoId: 0,

web_src/js/components/RepoActionView.vue

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
2626
type JobListItem = {
2727
job: ActionsJob;
2828
depth: number;
29-
hasChildren: boolean;
3029
};
3130
3231
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
@@ -71,9 +70,8 @@ const visibleJobListItems = computed<JobListItem[]>(() => {
7170
while (stack.length > 0) {
7271
const {job, depth} = stack.pop()!;
7372
const children = childrenByParent.get(job.id) || [];
74-
const hasChildren = children.length > 0;
75-
result.push({job, depth, hasChildren});
76-
if (hasChildren && isJobCollapsed(job.id)) continue;
73+
result.push({job, depth});
74+
if (children.length > 0 && isJobCollapsed(job.id)) continue;
7775
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
7876
}
7977
return result;
@@ -216,24 +214,28 @@ async function deleteArtifact(name: string) {
216214
v-for="item in visibleJobListItems"
217215
:key="item.job.id"
218216
>
219-
<a class="tw-contents silenced" :href="item.job.link">
220-
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
221-
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
222-
<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"/>
223-
<span class="job-duration">{{ item.job.duration }}</span>
224-
</a>
217+
<!-- Callers have no log page of their own; the whole row toggles expansion
218+
(matches GitHub Actions, where caller rows are not navigation targets). -->
225219
<button
226-
v-if="item.hasChildren"
220+
v-if="item.job.isReusableCaller"
227221
type="button"
228-
class="job-brief-toggle"
229-
:class="{'collapsed': isJobCollapsed(item.job.id)}"
222+
class="tw-contents caller-row-toggle"
230223
@click="toggleExpandedJob(item.job.id)"
231224
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
232225
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
233226
:aria-expanded="!isJobCollapsed(item.job.id)"
234227
>
235-
<SvgIcon name="octicon-chevron-down" :size="14"/>
228+
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
229+
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
230+
<span class="job-duration">{{ item.job.duration }}</span>
231+
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
236232
</button>
233+
<a v-else class="tw-contents silenced" :href="item.job.link">
234+
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
235+
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
236+
<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"/>
237+
<span class="job-duration">{{ item.job.duration }}</span>
238+
</a>
237239
</div>
238240
</div>
239241

@@ -258,7 +260,7 @@ async function deleteArtifact(name: string) {
258260
<SvgIcon name="octicon-trash"/>
259261
</a>
260262
</template>
261-
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
263+
<span v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2">
262264
<SvgIcon name="octicon-file-removed"/>
263265
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
264266
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
@@ -406,23 +408,23 @@ async function deleteArtifact(name: string) {
406408
background-color: var(--color-active);
407409
}
408410
409-
.job-brief-toggle {
411+
.caller-row-toggle {
410412
border: none;
411413
padding: 0;
412414
background: transparent;
413-
cursor: pointer;
414415
color: inherit;
415-
display: inline-flex;
416-
align-items: center;
417-
justify-content: center;
416+
cursor: pointer;
417+
text-align: inherit;
418+
}
419+
420+
.job-brief-toggle-icon {
418421
flex-shrink: 0;
419-
/* the icon is always chevron-down; flip to chevron-up when expanded */
420422
transition: transform 0.15s ease;
421-
/* sit right after the job name; rerun/duration float to the right via auto-margin */
423+
/* sit between name and duration; duration uses order:2 with margin-left:auto to float right */
422424
order: 1;
423425
}
424426
425-
.job-brief-toggle:not(.collapsed) {
427+
.job-brief-toggle-icon:not(.collapsed) {
426428
transform: rotate(180deg);
427429
}
428430

0 commit comments

Comments
 (0)