feat: Add workflow dependencies visualization (#36248)
Add workflow dependencies visualization Related to #26062 This PR adds an interactive visualization component that displays job dependencies in Gitea Actions workflow runs. It helps users understand complex pipeline structures at a glance, addressing the difficulty of comprehending dependency chains in current Gitea UI. --------- Signed-off-by: Semenets V. Pavel <p.semenets@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
committed by
GitHub
parent
427954ba6e
commit
a8505269ca
@@ -3695,6 +3695,7 @@
|
|||||||
"actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.",
|
"actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.",
|
||||||
"actions.runs.not_done": "This workflow run is not done.",
|
"actions.runs.not_done": "This workflow run is not done.",
|
||||||
"actions.runs.view_workflow_file": "View workflow file",
|
"actions.runs.view_workflow_file": "View workflow file",
|
||||||
|
"actions.runs.workflow_graph": "Workflow Graph",
|
||||||
"actions.workflow.disable": "Disable Workflow",
|
"actions.workflow.disable": "Disable Workflow",
|
||||||
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
|
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
|
||||||
"actions.workflow.enable": "Enable Workflow",
|
"actions.workflow.enable": "Enable Workflow",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/routers/web/repo/actions"
|
"code.gitea.io/gitea/routers/web/repo/actions"
|
||||||
@@ -58,8 +59,8 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func MockActionsView(ctx *context.Context) {
|
func MockActionsView(ctx *context.Context) {
|
||||||
ctx.Data["RunID"] = ctx.PathParam("run")
|
ctx.Data["RunIndex"] = ctx.PathParam("run")
|
||||||
ctx.Data["JobID"] = ctx.PathParam("job")
|
ctx.Data["JobIndex"] = ctx.PathParam("job")
|
||||||
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
req := web.GetForm(ctx).(*actions.ViewRequest)
|
req := web.GetForm(ctx).(*actions.ViewRequest)
|
||||||
resp := &actions.ViewResponse{}
|
resp := &actions.ViewResponse{}
|
||||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||||
|
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
||||||
resp.State.Run.Status = actions_model.StatusRunning.String()
|
resp.State.Run.Status = actions_model.StatusRunning.String()
|
||||||
resp.State.Run.CanCancel = runID == 10
|
resp.State.Run.CanCancel = runID == 10
|
||||||
resp.State.Run.CanApprove = runID == 20
|
resp.State.Run.CanApprove = runID == 20
|
||||||
@@ -112,6 +114,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
|
|
||||||
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,
|
||||||
|
JobID: "job-100",
|
||||||
Name: "job 100",
|
Name: "job 100",
|
||||||
Status: actions_model.StatusRunning.String(),
|
Status: actions_model.StatusRunning.String(),
|
||||||
CanRerun: true,
|
CanRerun: true,
|
||||||
@@ -119,17 +122,21 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
})
|
})
|
||||||
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 + 1,
|
ID: runID*10 + 1,
|
||||||
|
JobID: "job-101",
|
||||||
Name: "job 101",
|
Name: "job 101",
|
||||||
Status: actions_model.StatusWaiting.String(),
|
Status: actions_model.StatusWaiting.String(),
|
||||||
CanRerun: false,
|
CanRerun: false,
|
||||||
Duration: "2h",
|
Duration: "2h",
|
||||||
|
Needs: []string{"job-100"},
|
||||||
})
|
})
|
||||||
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 + 2,
|
ID: runID*10 + 2,
|
||||||
|
JobID: "job-102",
|
||||||
Name: "job 102",
|
Name: "job 102",
|
||||||
Status: actions_model.StatusFailure.String(),
|
Status: actions_model.StatusFailure.String(),
|
||||||
CanRerun: false,
|
CanRerun: false,
|
||||||
Duration: "3h",
|
Duration: "3h",
|
||||||
|
Needs: []string{"job-100", "job-101"},
|
||||||
})
|
})
|
||||||
|
|
||||||
var mockLogOptions []generateMockStepsLogOptions
|
var mockLogOptions []generateMockStepsLogOptions
|
||||||
|
|||||||
@@ -144,10 +144,12 @@ type ViewResponse struct {
|
|||||||
|
|
||||||
type ViewJob struct {
|
type ViewJob struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
JobID string `json:"jobId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CanRerun bool `json:"canRerun"`
|
CanRerun bool `json:"canRerun"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
|
Needs []string `json:"needs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewCommit struct {
|
type ViewCommit struct {
|
||||||
@@ -248,10 +250,12 @@ func ViewPost(ctx *context_module.Context) {
|
|||||||
for _, v := range jobs {
|
for _, v := range jobs {
|
||||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{
|
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{
|
||||||
ID: v.ID,
|
ID: v.ID,
|
||||||
|
JobID: v.JobID,
|
||||||
Name: v.Name,
|
Name: v.Name,
|
||||||
Status: v.Status.String(),
|
Status: v.Status.String(),
|
||||||
CanRerun: resp.State.Run.CanRerun,
|
CanRerun: resp.State.Run.CanRerun,
|
||||||
Duration: v.Duration().String(),
|
Duration: v.Duration().String(),
|
||||||
|
Needs: v.Needs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1675,7 +1675,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Any("/mail-preview", devtest.MailPreview)
|
m.Any("/mail-preview", devtest.MailPreview)
|
||||||
m.Any("/mail-preview/*", devtest.MailPreviewRender)
|
m.Any("/mail-preview/*", devtest.MailPreviewRender)
|
||||||
m.Any("/{sub}", devtest.TmplCommon)
|
m.Any("/{sub}", devtest.TmplCommon)
|
||||||
m.Get("/repo-action-view/{run}/{job}", devtest.MockActionsView)
|
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
|
||||||
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{{template "base/head" .}}
|
{{template "base/head" .}}
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="tw-flex tw-justify-center tw-items-center tw-gap-5">
|
<div class="tw-flex tw-justify-center tw-items-center tw-gap-5">
|
||||||
<a href="/devtest/repo-action-view/10/100">Run:CanCancel</a>
|
<a href="/devtest/repo-action-view/runs/10/jobs/0">Run:CanCancel</a>
|
||||||
<a href="/devtest/repo-action-view/20/200">Run:CanApprove</a>
|
<a href="/devtest/repo-action-view/runs/20/jobs/1">Run:CanApprove</a>
|
||||||
<a href="/devtest/repo-action-view/30/300">Run:CanRerun</a>
|
<a href="/devtest/repo-action-view/runs/30/jobs/2">Run:CanRerun</a>
|
||||||
</div>
|
</div>
|
||||||
{{template "repo/actions/view_component" (dict
|
{{template "repo/actions/view_component" (dict
|
||||||
"RunIndex" (or .RunID 10)
|
"RunIndex" (or .RunIndex 10)
|
||||||
"JobIndex" (or .JobID 100)
|
"JobIndex" (or .JobIndex 0)
|
||||||
"ActionsURL" (print AppSubUrl "/devtest/actions-mock")
|
"ActionsURL" (print AppSubUrl "/devtest/actions-mock")
|
||||||
)}}
|
)}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
data-locale-runs-scheduled="{{ctx.Locale.Tr "actions.runs.scheduled"}}"
|
data-locale-runs-scheduled="{{ctx.Locale.Tr "actions.runs.scheduled"}}"
|
||||||
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
|
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
|
||||||
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
||||||
|
data-locale-runs-workflow-graph="{{ctx.Locale.Tr "actions.runs.workflow_graph"}}"
|
||||||
data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
|
data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
|
||||||
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
||||||
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ gitea-theme-meta-info {
|
|||||||
--color-secondary-hover: var(--color-secondary-dark-5);
|
--color-secondary-hover: var(--color-secondary-dark-5);
|
||||||
--color-secondary-active: var(--color-secondary-dark-6);
|
--color-secondary-active: var(--color-secondary-dark-6);
|
||||||
/* console colors - used for actions console and console files */
|
/* console colors - used for actions console and console files */
|
||||||
--color-console-fg: #f7f8f9;
|
--color-console-fg: #0d1117;
|
||||||
--color-console-fg-subtle: #bdc4cc;
|
--color-console-fg-subtle: #40474d;
|
||||||
--color-console-bg: #171b1e;
|
--color-console-bg: #ffffff;
|
||||||
--color-console-border: #2e353b;
|
--color-console-border: #d0d7de;
|
||||||
--color-console-hover-bg: #272d33;
|
--color-console-hover-bg: #f1f3f5;
|
||||||
--color-console-active-bg: #2e353b;
|
--color-console-active-bg: #d0d7de;
|
||||||
--color-console-menu-bg: #262b31;
|
--color-console-menu-bg: #f8f9fb;
|
||||||
--color-console-menu-border: #414b55;
|
--color-console-menu-border: #d0d7de;
|
||||||
/* named colors */
|
/* named colors */
|
||||||
--color-red: #db2828;
|
--color-red: #db2828;
|
||||||
--color-orange: #f2711c;
|
--color-orange: #f2711c;
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import {renderAnsi} from '../render/ansi.ts';
|
|||||||
import {POST, DELETE} from '../modules/fetch.ts';
|
import {POST, DELETE} from '../modules/fetch.ts';
|
||||||
import type {IntervalId} from '../types.ts';
|
import type {IntervalId} from '../types.ts';
|
||||||
import {toggleFullScreen} from '../utils.ts';
|
import {toggleFullScreen} from '../utils.ts';
|
||||||
|
import WorkflowGraph from './WorkflowGraph.vue'
|
||||||
import {localUserSettings} from '../modules/user-settings.ts';
|
import {localUserSettings} from '../modules/user-settings.ts';
|
||||||
|
import type {ActionsRunStatus, ActionsJob} from '../modules/gitea-actions.ts';
|
||||||
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
|
||||||
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
|
||||||
|
|
||||||
type StepContainerElement = HTMLElement & {
|
type StepContainerElement = HTMLElement & {
|
||||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||||
@@ -54,19 +53,10 @@ const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
|||||||
'::remove-matcher': 'hidden', // it has arguments
|
'::remove-matcher': 'hidden', // it has arguments
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
type Job = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
status: RunStatus;
|
|
||||||
canRerun: boolean;
|
|
||||||
duration: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = {
|
type Step = {
|
||||||
summary: string,
|
summary: string,
|
||||||
duration: string,
|
duration: string,
|
||||||
status: RunStatus,
|
status: ActionsRunStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobStepState = {
|
type JobStepState = {
|
||||||
@@ -107,6 +97,7 @@ function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPor
|
|||||||
type LocaleStorageOptions = {
|
type LocaleStorageOptions = {
|
||||||
autoScroll: boolean;
|
autoScroll: boolean;
|
||||||
expandRunning: boolean;
|
expandRunning: boolean;
|
||||||
|
showWorkflowGraph: boolean;
|
||||||
actionsLogShowSeconds: boolean;
|
actionsLogShowSeconds: boolean;
|
||||||
actionsLogShowTimestamps: boolean;
|
actionsLogShowTimestamps: boolean;
|
||||||
};
|
};
|
||||||
@@ -116,20 +107,12 @@ export default defineComponent({
|
|||||||
components: {
|
components: {
|
||||||
SvgIcon,
|
SvgIcon,
|
||||||
ActionRunStatus,
|
ActionRunStatus,
|
||||||
|
WorkflowGraph,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
runIndex: {
|
runIndex: {type: Number, required: true},
|
||||||
type: String,
|
jobIndex: {type: Number, required: true},
|
||||||
default: '',
|
actionsURL: {type: String, required: true},
|
||||||
},
|
|
||||||
jobIndex: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
actionsURL: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
locale: {
|
locale: {
|
||||||
type: Object as PropType<Record<string, any>>,
|
type: Object as PropType<Record<string, any>>,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -137,8 +120,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
const defaultViewOptions: LocaleStorageOptions = {autoScroll: true, expandRunning: false, actionsLogShowSeconds: false, actionsLogShowTimestamps: false};
|
const defaultViewOptions: LocaleStorageOptions = {autoScroll: true, expandRunning: false, showWorkflowGraph: false, actionsLogShowSeconds: false, actionsLogShowTimestamps: false};
|
||||||
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
const {autoScroll, expandRunning, showWorkflowGraph, actionsLogShowSeconds, actionsLogShowTimestamps} = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
||||||
return {
|
return {
|
||||||
// internal state
|
// internal state
|
||||||
loadingAbortController: null as AbortController | null,
|
loadingAbortController: null as AbortController | null,
|
||||||
@@ -147,6 +130,7 @@ export default defineComponent({
|
|||||||
artifacts: [] as Array<Record<string, any>>,
|
artifacts: [] as Array<Record<string, any>>,
|
||||||
menuVisible: false,
|
menuVisible: false,
|
||||||
isFullScreen: false,
|
isFullScreen: false,
|
||||||
|
showWorkflowGraph: showWorkflowGraph,
|
||||||
timeVisible: {
|
timeVisible: {
|
||||||
'log-time-stamp': actionsLogShowTimestamps,
|
'log-time-stamp': actionsLogShowTimestamps,
|
||||||
'log-time-seconds': actionsLogShowSeconds,
|
'log-time-seconds': actionsLogShowSeconds,
|
||||||
@@ -159,7 +143,7 @@ export default defineComponent({
|
|||||||
link: '',
|
link: '',
|
||||||
title: '',
|
title: '',
|
||||||
titleHTML: '',
|
titleHTML: '',
|
||||||
status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
canApprove: false,
|
canApprove: false,
|
||||||
canRerun: false,
|
canRerun: false,
|
||||||
@@ -176,7 +160,7 @@ export default defineComponent({
|
|||||||
// canRerun: false,
|
// canRerun: false,
|
||||||
// duration: '',
|
// duration: '',
|
||||||
// },
|
// },
|
||||||
] as Array<Job>,
|
] as Array<ActionsJob>,
|
||||||
commit: {
|
commit: {
|
||||||
localeCommit: '',
|
localeCommit: '',
|
||||||
localePushedBy: '',
|
localePushedBy: '',
|
||||||
@@ -214,6 +198,9 @@ export default defineComponent({
|
|||||||
optionAlwaysExpandRunning() {
|
optionAlwaysExpandRunning() {
|
||||||
this.saveLocaleStorageOptions();
|
this.saveLocaleStorageOptions();
|
||||||
},
|
},
|
||||||
|
showWorkflowGraph() {
|
||||||
|
this.saveLocaleStorageOptions();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
@@ -258,6 +245,7 @@ export default defineComponent({
|
|||||||
const opts: LocaleStorageOptions = {
|
const opts: LocaleStorageOptions = {
|
||||||
autoScroll: this.optionAlwaysAutoScroll,
|
autoScroll: this.optionAlwaysAutoScroll,
|
||||||
expandRunning: this.optionAlwaysExpandRunning,
|
expandRunning: this.optionAlwaysExpandRunning,
|
||||||
|
showWorkflowGraph: this.showWorkflowGraph,
|
||||||
actionsLogShowSeconds: this.timeVisible['log-time-seconds'],
|
actionsLogShowSeconds: this.timeVisible['log-time-seconds'],
|
||||||
actionsLogShowTimestamps: this.timeVisible['log-time-stamp'],
|
actionsLogShowTimestamps: this.timeVisible['log-time-stamp'],
|
||||||
};
|
};
|
||||||
@@ -456,11 +444,11 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
isDone(status: RunStatus) {
|
isDone(status: ActionsRunStatus) {
|
||||||
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||||
},
|
},
|
||||||
|
|
||||||
isExpandable(status: RunStatus) {
|
isExpandable(status: ActionsRunStatus) {
|
||||||
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -514,16 +502,21 @@ export default defineComponent({
|
|||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
|
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-text-block tw-shrink-0 tw-flex-wrap">
|
||||||
|
<button class="ui basic small compact button primary" @click="showWorkflowGraph = !showWorkflowGraph" :class="{ active: showWorkflowGraph }" v-if="run.jobs.length > 1">
|
||||||
|
{{ locale.workflowGraph }}
|
||||||
|
</button>
|
||||||
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
||||||
{{ locale.approve }}
|
{{ locale.approve }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
|
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
|
||||||
{{ locale.cancel }}
|
{{ locale.cancel }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ui basic small compact button link-action tw-shrink-0" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
|
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
|
||||||
{{ locale.rerun_all }}
|
{{ locale.rerun_all }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="action-commit-summary">
|
<div class="action-commit-summary">
|
||||||
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
|
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
|
||||||
<template v-if="run.isSchedule">
|
<template v-if="run.isSchedule">
|
||||||
@@ -545,7 +538,7 @@ export default defineComponent({
|
|||||||
<div class="action-view-left">
|
<div class="action-view-left">
|
||||||
<div class="job-group-section">
|
<div class="job-group-section">
|
||||||
<div class="job-brief-list">
|
<div class="job-brief-list">
|
||||||
<a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id">
|
<a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="jobIndex === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id">
|
||||||
<div class="job-brief-item-left">
|
<div class="job-brief-item-left">
|
||||||
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
||||||
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
|
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
|
||||||
@@ -585,6 +578,15 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-view-right">
|
<div class="action-view-right">
|
||||||
|
<WorkflowGraph
|
||||||
|
v-if="showWorkflowGraph && run.jobs.length > 1"
|
||||||
|
:jobs="run.jobs"
|
||||||
|
:current-job-index="jobIndex"
|
||||||
|
:run-link="run.link"
|
||||||
|
:workflow-id="run.workflowID"
|
||||||
|
class="workflow-graph-container"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="job-info-header">
|
<div class="job-info-header">
|
||||||
<div class="job-info-header-left gt-ellipsis">
|
<div class="job-info-header-left gt-ellipsis">
|
||||||
<h3 class="job-info-header-title gt-ellipsis">
|
<h3 class="job-info-header-title gt-ellipsis">
|
||||||
@@ -673,6 +675,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
.action-info-summary {
|
.action-info-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,971 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||||
|
import {SvgIcon} from '../svg.ts';
|
||||||
|
import {localUserSettings} from "../modules/user-settings.ts";
|
||||||
|
import {debounce} from "throttle-debounce";
|
||||||
|
import type {ActionsJob, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||||
|
|
||||||
|
interface JobNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: ActionsRunStatus;
|
||||||
|
needs: string[];
|
||||||
|
duration: string;
|
||||||
|
|
||||||
|
index: number;
|
||||||
|
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Edge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BezierEdge extends Edge {
|
||||||
|
path: string;
|
||||||
|
fromNode: JobNode;
|
||||||
|
toNode: JobNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredState {
|
||||||
|
scale: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
jobs: ActionsJob[];
|
||||||
|
currentJobIndex: number;
|
||||||
|
runLink: string;
|
||||||
|
workflowId: string;
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const settingKeyStates = 'actions-graph-states';
|
||||||
|
const maxStoredStates = 10;
|
||||||
|
|
||||||
|
const scale = ref(1);
|
||||||
|
const translateX = ref(0);
|
||||||
|
const translateY = ref(0);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragStart = ref({ x: 0, y: 0 });
|
||||||
|
const lastMousePos = ref({ x: 0, y: 0 });
|
||||||
|
const graphContainer = ref<HTMLElement | null>(null);
|
||||||
|
const hoveredJobId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const loadSavedState = () => {
|
||||||
|
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
|
||||||
|
const saved = allStates[props.workflowId];
|
||||||
|
if (!saved) return;
|
||||||
|
scale.value = saved.scale ?? scale.value;
|
||||||
|
translateX.value = saved.translateX ?? translateX.value;
|
||||||
|
translateY.value = saved.translateY ?? translateY.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveState = () => {
|
||||||
|
// TODO: different repos might have the same workflowId, but at the moment, we don't have repo id
|
||||||
|
// If overwritten occurs, acceptable, not too bad
|
||||||
|
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
|
||||||
|
allStates[props.workflowId] = {
|
||||||
|
scale: scale.value,
|
||||||
|
translateX: translateX.value,
|
||||||
|
translateY: translateY.value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedStates = Object.entries(allStates)
|
||||||
|
.sort(([, a], [, b]) => b.timestamp - a.timestamp)
|
||||||
|
.slice(0, maxStoredStates);
|
||||||
|
|
||||||
|
const limitedStates = Object.fromEntries(sortedStates);
|
||||||
|
localUserSettings.setJsonObject(settingKeyStates, limitedStates);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSavedState();
|
||||||
|
watch([translateX, translateY, scale], () => {
|
||||||
|
debounce(500, saveState);
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeWidth = computed(() => {
|
||||||
|
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
|
||||||
|
return Math.min(Math.max(140, maxNameLength * 8), 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
const horizontalSpacing = computed(() => nodeWidth.value + 20);
|
||||||
|
const graphWidth = computed(() => {
|
||||||
|
if (jobsWithLayout.value.length === 0) return 800;
|
||||||
|
const maxX = Math.max(...jobsWithLayout.value.map(j => j.x + nodeWidth.value));
|
||||||
|
return maxX + margin * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const graphHeight = computed(() => {
|
||||||
|
if (jobsWithLayout.value.length === 0) return 400;
|
||||||
|
const maxY = Math.max(...jobsWithLayout.value.map(j => j.y + nodeHeight));
|
||||||
|
return maxY + margin * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobsWithLayout = computed<JobNode[]>(() => {
|
||||||
|
try {
|
||||||
|
const levels = computeJobLevels(props.jobs);
|
||||||
|
const currentHorizontalSpacing = horizontalSpacing.value;
|
||||||
|
|
||||||
|
const jobsByLevel: ActionsJob[][] = [];
|
||||||
|
let maxJobsPerLevel = 0;
|
||||||
|
|
||||||
|
props.jobs.forEach(job => {
|
||||||
|
const level = levels.get(job.name) || levels.get(job.jobId) || 0;
|
||||||
|
|
||||||
|
if (!jobsByLevel[level]) {
|
||||||
|
jobsByLevel[level] = [];
|
||||||
|
}
|
||||||
|
jobsByLevel[level].push(job);
|
||||||
|
|
||||||
|
if (jobsByLevel[level].length > maxJobsPerLevel) {
|
||||||
|
maxJobsPerLevel = jobsByLevel[level].length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: JobNode[] = [];
|
||||||
|
jobsByLevel.forEach((levelJobs, levelIndex) => {
|
||||||
|
if (!levelJobs || levelJobs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelWidth = (levelJobs.length - 1) * currentHorizontalSpacing;
|
||||||
|
const startX = margin + (maxJobsPerLevel * currentHorizontalSpacing - levelWidth) / 2;
|
||||||
|
|
||||||
|
levelJobs.forEach((job, jobIndex) => {
|
||||||
|
result.push({
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
status: job.status,
|
||||||
|
needs: job.needs || [],
|
||||||
|
duration: job.duration,
|
||||||
|
|
||||||
|
index: props.jobs.findIndex(j => j.id === job.id),
|
||||||
|
|
||||||
|
x: startX + jobIndex * currentHorizontalSpacing,
|
||||||
|
y: margin + levelIndex * verticalSpacing,
|
||||||
|
level: levelIndex,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return props.jobs.map((job, index) => ({
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
status: job.status,
|
||||||
|
needs: job.needs || [],
|
||||||
|
duration: job.duration,
|
||||||
|
|
||||||
|
index: index,
|
||||||
|
|
||||||
|
x: margin + index * (nodeWidth.value + 40),
|
||||||
|
y: margin,
|
||||||
|
level: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = computed<Edge[]>(() => {
|
||||||
|
const edgesList: Edge[] = [];
|
||||||
|
|
||||||
|
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||||
|
for (const job of props.jobs) {
|
||||||
|
if (!jobsByJobId.has(job.jobId)) {
|
||||||
|
jobsByJobId.set(job.jobId, []);
|
||||||
|
}
|
||||||
|
jobsByJobId.get(job.jobId)!.push(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const job of props.jobs) {
|
||||||
|
for (const need of job.needs || []) {
|
||||||
|
const targetJobs = jobsByJobId.get(need) || [];
|
||||||
|
for (const targetJob of targetJobs) {
|
||||||
|
edgesList.push({
|
||||||
|
from: targetJob.name,
|
||||||
|
to: job.name,
|
||||||
|
key: `${targetJob.id}-${job.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgesList;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bezierEdges = computed<BezierEdge[]>(() => {
|
||||||
|
const bezierEdgesList: BezierEdge[] = [];
|
||||||
|
|
||||||
|
edges.value.forEach(edge => {
|
||||||
|
const fromNode = jobsWithLayout.value.find(j => j.name === edge.from);
|
||||||
|
const toNode = jobsWithLayout.value.find(j => j.name === edge.to);
|
||||||
|
|
||||||
|
if (!fromNode || !toNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = fromNode.x + nodeWidth.value / 2;
|
||||||
|
const startY = fromNode.y + nodeHeight;
|
||||||
|
const endX = toNode.x + nodeWidth.value / 2;
|
||||||
|
const endY = toNode.y;
|
||||||
|
|
||||||
|
const levelDiff = toNode.level - fromNode.level;
|
||||||
|
const curveStrength = 30 + Math.abs(levelDiff) * 15;
|
||||||
|
|
||||||
|
const controlX1 = startX;
|
||||||
|
const controlY1 = startY + curveStrength;
|
||||||
|
const controlX2 = endX;
|
||||||
|
const controlY2 = endY - curveStrength;
|
||||||
|
|
||||||
|
const path = `M ${startX} ${startY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${endX} ${endY}`;
|
||||||
|
|
||||||
|
bezierEdgesList.push({
|
||||||
|
...edge,
|
||||||
|
path,
|
||||||
|
fromNode,
|
||||||
|
toNode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return bezierEdgesList;
|
||||||
|
});
|
||||||
|
|
||||||
|
const graphMetrics = computed(() => {
|
||||||
|
const successCount = jobsWithLayout.value.filter(job => job.status === 'success').length;
|
||||||
|
|
||||||
|
const levels = new Map<number, number>();
|
||||||
|
jobsWithLayout.value.forEach(job => {
|
||||||
|
const count = levels.get(job.level) || 0;
|
||||||
|
levels.set(job.level, count + 1);
|
||||||
|
})
|
||||||
|
const parallelism = Math.max(...Array.from(levels.values()), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
successRate: `${((successCount / jobsWithLayout.value.length) * 100).toFixed(0)}%`,
|
||||||
|
parallelism,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeHeight = 50;
|
||||||
|
const verticalSpacing = 120;
|
||||||
|
const margin = 40;
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
scale.value = Math.min(scale.value * 1.2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
scale.value = Math.max(scale.value / 1.2, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
scale.value = 1;
|
||||||
|
translateX.value = 0;
|
||||||
|
translateY.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // only left mouse button can drag
|
||||||
|
const target = e.target as Element;
|
||||||
|
// don't start the drag if the click is on an interactive element (e.g.: link, button) or text element
|
||||||
|
const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group');
|
||||||
|
if (interactive?.closest('svg')) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
dragStart.value = {
|
||||||
|
x: e.clientX - translateX.value,
|
||||||
|
y: e.clientY - translateY.value,
|
||||||
|
};
|
||||||
|
lastMousePos.value = { x: e.clientX, y: e.clientY };
|
||||||
|
graphContainer.value!.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMoveOnDocument(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - lastMousePos.value.x;
|
||||||
|
const dy = event.clientY - lastMousePos.value.y;
|
||||||
|
|
||||||
|
translateX.value += dx;
|
||||||
|
translateY.value += dy;
|
||||||
|
|
||||||
|
lastMousePos.value = { x: event.clientX, y: event.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUpOnDocument() {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
isDragging.value = false;
|
||||||
|
graphContainer.value!.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', handleMouseMoveOnDocument);
|
||||||
|
document.addEventListener('mouseup', handleMouseUpOnDocument);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMoveOnDocument);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUpOnDocument);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleNodeMouseEnter(job: JobNode) {
|
||||||
|
hoveredJobId.value = job.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeMouseLeave() {
|
||||||
|
hoveredJobId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEdgeHighlighted(edge: BezierEdge): boolean {
|
||||||
|
if (!hoveredJobId.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoveredJob = jobsWithLayout.value.find(j => j.id === hoveredJobId.value);
|
||||||
|
if (!hoveredJob) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return edge.from === hoveredJob.name || edge.to === hoveredJob.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeColor(status: ActionsRunStatus): string {
|
||||||
|
if (status === 'success') {
|
||||||
|
return 'var(--color-green-dark-2)';
|
||||||
|
} else if (status === 'failure') {
|
||||||
|
return 'var(--color-red-dark-2)';
|
||||||
|
} else if (status === 'running') {
|
||||||
|
return 'var(--color-yellow-dark-2)';
|
||||||
|
} else if (status === 'blocked') {
|
||||||
|
return 'var(--color-purple)';
|
||||||
|
}
|
||||||
|
return 'var(--color-text-light-3)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusDotColor(status: ActionsRunStatus): string {
|
||||||
|
if (status === 'success') {
|
||||||
|
return 'var(--color-green)';
|
||||||
|
} else if (status === 'failure') {
|
||||||
|
return 'var(--color-red)';
|
||||||
|
} else if (status === 'running') {
|
||||||
|
return 'var(--color-yellow)';
|
||||||
|
}
|
||||||
|
return 'var(--color-text-light-2)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeColor(edge: BezierEdge): string {
|
||||||
|
if (!edge.fromNode || !edge.toNode) {
|
||||||
|
return 'var(--color-secondary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStatus = edge.fromNode.status;
|
||||||
|
const toStatus = edge.toNode.status;
|
||||||
|
|
||||||
|
if (fromStatus === 'failure' || toStatus === 'failure') {
|
||||||
|
return 'var(--color-red)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'running') {
|
||||||
|
return 'var(--color-yellow)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toStatus === 'running' && fromStatus === 'success') {
|
||||||
|
return 'var(--color-primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'success' && toStatus === 'success') {
|
||||||
|
return 'var(--color-green)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'success' && (toStatus === 'waiting' || toStatus === 'blocked')) {
|
||||||
|
return 'var(--color-primary-light)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'waiting' || fromStatus === 'blocked') {
|
||||||
|
return 'var(--color-text-light-2)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'cancelled' || toStatus === 'cancelled') {
|
||||||
|
return 'var(--color-text-light-2)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'var(--color-secondary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(name: string): string {
|
||||||
|
const maxChars = 26;
|
||||||
|
if (name.length <= maxChars) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.substring(0, maxChars - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: ActionsRunStatus): string {
|
||||||
|
const statusMap: Record<ActionsRunStatus, string> = {
|
||||||
|
skipped: 'Skipped',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
success: 'Success',
|
||||||
|
failure: 'Failed',
|
||||||
|
running: 'Running',
|
||||||
|
waiting: 'Waiting',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
blocked: 'Blocked'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeStyle(edge: BezierEdge) {
|
||||||
|
if (!edge.fromNode || !edge.toNode) {
|
||||||
|
return {
|
||||||
|
'stroke': 'var(--color-secondary)',
|
||||||
|
'stroke-width': '2',
|
||||||
|
'opacity': '0.7',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStatus = edge.fromNode.status;
|
||||||
|
const toStatus = edge.toNode.status;
|
||||||
|
const isHighlighted = isEdgeHighlighted(edge);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'stroke': getEdgeColor(edge),
|
||||||
|
'stroke-width': isHighlighted ? '3' : getStrokeWidth(fromStatus, toStatus),
|
||||||
|
'stroke-dasharray': getDashArray(fromStatus, toStatus),
|
||||||
|
'opacity': isHighlighted ? 1 : getEdgeOpacity(fromStatus, toStatus),
|
||||||
|
'transition': 'all 0.2s ease',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStrokeWidth(fromStatus: ActionsRunStatus, toStatus: ActionsRunStatus): string {
|
||||||
|
if (fromStatus === 'running' || toStatus === 'running') {
|
||||||
|
return '3';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'failure' || toStatus === 'failure') {
|
||||||
|
return '2.5';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDashArray(fromStatus: ActionsRunStatus, toStatus: ActionsRunStatus): string {
|
||||||
|
if (fromStatus === 'waiting' || toStatus === 'waiting') {
|
||||||
|
return '5,3';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'blocked') {
|
||||||
|
return '8,4';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'cancelled' || toStatus === 'cancelled') {
|
||||||
|
return '3,6';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeOpacity(fromStatus: ActionsRunStatus, toStatus: ActionsRunStatus): number {
|
||||||
|
if (fromStatus === 'success' && toStatus === 'success') {
|
||||||
|
return 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'failure' || toStatus === 'failure') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'running' || toStatus === 'running') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeClass(edge: BezierEdge): string {
|
||||||
|
if (!edge.fromNode || !edge.toNode) return '';
|
||||||
|
|
||||||
|
const fromStatus = edge.fromNode.status;
|
||||||
|
const toStatus = edge.toNode.status;
|
||||||
|
|
||||||
|
const classes: string[] = ['node-edge'];
|
||||||
|
|
||||||
|
if (fromStatus === 'running' || toStatus === 'running') {
|
||||||
|
classes.push('running-edge');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'success' && toStatus === 'success') {
|
||||||
|
classes.push('success-edge');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'failure' || toStatus === 'failure') {
|
||||||
|
classes.push('failure-edge');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStatus === 'waiting' || toStatus === 'waiting') {
|
||||||
|
classes.push('waiting-edge');
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||||
|
const jobMap = new Map<string, ActionsJob>()
|
||||||
|
jobs.forEach(job => {
|
||||||
|
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>();
|
||||||
|
const MAX_DEPTH = 100;
|
||||||
|
|
||||||
|
function dfs(jobNameOrId: string, depth: number = 0): number {
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${jobNameOrId}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recursionStack.has(jobNameOrId)) {
|
||||||
|
console.error(`Cycle detected involving: ${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);
|
||||||
|
recursionStack.delete(jobNameOrId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxLevel = -1;
|
||||||
|
for (const need of job.needs) {
|
||||||
|
const needJob = jobMap.get(need);
|
||||||
|
if (!needJob) continue;
|
||||||
|
|
||||||
|
const needLevel = dfs(need, depth + 1);
|
||||||
|
maxLevel = Math.max(maxLevel, needLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = maxLevel + 1
|
||||||
|
levels.set(job.name, level);
|
||||||
|
if (job.jobId && job.jobId !== job.name) {
|
||||||
|
levels.set(job.jobId, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
recursionStack.delete(jobNameOrId);
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.forEach(job => {
|
||||||
|
if (!visited.has(job.name) && !visited.has(job.jobId)) {
|
||||||
|
dfs(job.name);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||||
|
if (job.index === props.currentJobIndex) return;
|
||||||
|
|
||||||
|
const link = `${props.runLink}/jobs/${job.index}`;
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
window.open(link, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = link;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="workflow-graph" v-if="jobs.length > 0">
|
||||||
|
<div class="graph-header">
|
||||||
|
<h4 class="graph-title">Workflow Dependencies</h4>
|
||||||
|
<div class="graph-stats">
|
||||||
|
{{ jobs.length }} jobs • {{ edges.length }} dependencies
|
||||||
|
<span v-if="graphMetrics" class="graph-metrics">
|
||||||
|
• {{ graphMetrics.successRate }} success
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-text-block">
|
||||||
|
<button @click="zoomIn" class="ui compact tiny icon button" title="Zoom in">
|
||||||
|
<SvgIcon name="octicon-zoom-in" :size="12"/>
|
||||||
|
</button>
|
||||||
|
<button @click="resetView" class="ui compact tiny icon button" title="Reset view">
|
||||||
|
<SvgIcon name="octicon-sync" :size="12"/>
|
||||||
|
</button>
|
||||||
|
<button @click="zoomOut" class="ui compact tiny icon button" title="Zoom out">
|
||||||
|
<SvgIcon name="octicon-zoom-out" :size="12"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="graph-container"
|
||||||
|
ref="graphContainer"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
:class="{ 'dragging': isDragging }"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:width="graphWidth"
|
||||||
|
:height="graphHeight"
|
||||||
|
class="graph-svg"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
||||||
|
transformOrigin: '0 0'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
v-for="edge in bezierEdges"
|
||||||
|
:key="edge.key"
|
||||||
|
:d="edge.path"
|
||||||
|
fill="none"
|
||||||
|
v-bind="getEdgeStyle(edge)"
|
||||||
|
:class="[
|
||||||
|
getEdgeClass(edge),
|
||||||
|
{ 'highlighted-edge': isEdgeHighlighted(edge) }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g
|
||||||
|
v-for="job in jobsWithLayout"
|
||||||
|
:key="job.id"
|
||||||
|
:class="{'current-job': job.index === currentJobIndex}"
|
||||||
|
class="job-node-group"
|
||||||
|
@click="onNodeClick(job, $event)"
|
||||||
|
@mouseenter="handleNodeMouseEnter(job)"
|
||||||
|
@mouseleave="handleNodeMouseLeave"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
:x="job.x"
|
||||||
|
:y="job.y"
|
||||||
|
:width="nodeWidth"
|
||||||
|
:height="nodeHeight"
|
||||||
|
rx="8"
|
||||||
|
:fill="getNodeColor(job.status)"
|
||||||
|
:stroke="job.index === currentJobIndex ? 'var(--color-primary)' : 'var(--color-card-border)'"
|
||||||
|
:stroke-width="job.index === currentJobIndex ? '3' : '2'"
|
||||||
|
class="job-rect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
v-if="job.status === 'running'"
|
||||||
|
:x="job.x"
|
||||||
|
:y="job.y"
|
||||||
|
:width="nodeWidth"
|
||||||
|
:height="nodeHeight"
|
||||||
|
rx="8"
|
||||||
|
fill="url(#running-gradient)"
|
||||||
|
opacity="0.3"
|
||||||
|
class="running-background"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="job.x + 8"
|
||||||
|
:y="job.y + 18"
|
||||||
|
fill="white"
|
||||||
|
font-size="12"
|
||||||
|
text-anchor="start"
|
||||||
|
class="job-name"
|
||||||
|
>
|
||||||
|
{{ getDisplayName(job.name) }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
v-if="job.duration || (job.status === 'success' || job.status === 'failure')"
|
||||||
|
:x="job.x + nodeWidth - 10"
|
||||||
|
:y="job.y + nodeHeight - 25"
|
||||||
|
fill="rgba(255,255,255,0.7)"
|
||||||
|
font-size="9"
|
||||||
|
text-anchor="end"
|
||||||
|
class="job-duration"
|
||||||
|
>
|
||||||
|
{{ job.duration }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
:x="job.x + nodeWidth - 10"
|
||||||
|
:y="job.y + nodeHeight - 8"
|
||||||
|
fill="rgba(255,255,255,0.9)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor="end"
|
||||||
|
class="job-status"
|
||||||
|
>
|
||||||
|
{{ formatStatus(job.status) }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
v-if="job.status === 'running'"
|
||||||
|
:x="job.x + 2"
|
||||||
|
:y="job.y + nodeHeight - 6"
|
||||||
|
:width="(nodeWidth - 4) * 0.5"
|
||||||
|
height="4"
|
||||||
|
rx="2"
|
||||||
|
:fill="getStatusDotColor('running')"
|
||||||
|
class="progress-bar"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="width"
|
||||||
|
values="0; 100"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines="0.4, 0, 0.2, 1"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
|
||||||
|
<text
|
||||||
|
v-if="job.needs?.length"
|
||||||
|
:x="job.x + nodeWidth / 2"
|
||||||
|
:y="job.y - 8"
|
||||||
|
fill="var(--color-text-light-2)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor="middle"
|
||||||
|
class="job-deps-label"
|
||||||
|
>
|
||||||
|
← {{ job.needs.length }} deps
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="running-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" :stop-color="getStatusDotColor('running')" stop-opacity="0.2"/>
|
||||||
|
<stop offset="50%" :stop-color="getStatusDotColor('running')" stop-opacity="0.4"/>
|
||||||
|
<stop offset="100%" :stop-color="getStatusDotColor('running')" stop-opacity="0.2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workflow-graph {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-secondary-alpha-20);
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-stats {
|
||||||
|
color: var(--color-text-light-2);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-metrics {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-container {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 600px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-container.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-svg {
|
||||||
|
display: block;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-svg path {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted-edge {
|
||||||
|
stroke-width: 3 !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
stroke: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-node-group {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
--node-width: v-bind(nodeWidth + "px");
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-node-group:hover .job-rect {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-node-group.current-job {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-node-group.current-job .job-rect {
|
||||||
|
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--color-primary) 30%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-name {
|
||||||
|
max-width: calc(var(--node-width, 150px) - 50px);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status,
|
||||||
|
.job-duration,
|
||||||
|
.job-deps-label {
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: calc(200px + 100%) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-background {
|
||||||
|
animation: shimmer 2s infinite linear;
|
||||||
|
background-size: 200px 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flowRunning {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 20;
|
||||||
|
stroke-opacity: 0.7;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke-opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseFailure {
|
||||||
|
0%, 100% {
|
||||||
|
stroke-width: 2.5;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-width: 3;
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--color-red) 50%, transparent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerEdge {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 20;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-edge.running-edge {
|
||||||
|
stroke-dasharray: 10, 5;
|
||||||
|
animation: flowRunning 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-edge.failure-edge {
|
||||||
|
animation: pulseFailure 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-edge.waiting-edge {
|
||||||
|
stroke-dasharray: 5, 3;
|
||||||
|
animation: shimmerEdge 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-edge.success-edge {
|
||||||
|
transition: stroke-width 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-edge.success-edge:hover {
|
||||||
|
stroke-width: 3;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
animation: progressPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressPulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.graph-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,8 +11,8 @@ export function initRepositoryActionView() {
|
|||||||
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
|
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
|
||||||
|
|
||||||
const view = createApp(RepoActionView, {
|
const view = createApp(RepoActionView, {
|
||||||
runIndex: el.getAttribute('data-run-index'),
|
runIndex: parseInt(el.getAttribute('data-run-index')!),
|
||||||
jobIndex: el.getAttribute('data-job-index'),
|
jobIndex: parseInt(el.getAttribute('data-job-index')!),
|
||||||
actionsURL: el.getAttribute('data-actions-url'),
|
actionsURL: el.getAttribute('data-actions-url'),
|
||||||
locale: {
|
locale: {
|
||||||
approve: el.getAttribute('data-locale-approve'),
|
approve: el.getAttribute('data-locale-approve'),
|
||||||
@@ -22,6 +22,7 @@ export function initRepositoryActionView() {
|
|||||||
scheduled: el.getAttribute('data-locale-runs-scheduled'),
|
scheduled: el.getAttribute('data-locale-runs-scheduled'),
|
||||||
commit: el.getAttribute('data-locale-runs-commit'),
|
commit: el.getAttribute('data-locale-runs-commit'),
|
||||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||||
|
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
|
||||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
||||||
|
export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||||
|
|
||||||
|
export type ActionsJob = {
|
||||||
|
id: number;
|
||||||
|
jobId: string;
|
||||||
|
name: string;
|
||||||
|
status: ActionsRunStatus;
|
||||||
|
canRerun: boolean;
|
||||||
|
needs?: string[];
|
||||||
|
duration: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user