Files
gitea/web_src/js/components/WorkflowGraph.vue
T
silverwindandGitHub 7f3b4a0bdd fix(ui): misc ui fixes (#39336)
1. Give menu items an inset pill highlight, text position and menu width are unchanged in most menus
1. Add arrow key, Enter, Space and Escape handling to all tippy menus
1. Mark the keyboard cursor of fomantic and tippy menus with the focus ring instead of the hover background
1. Mark the current dropdown item with the active color so it stands out from the hovered one
1. Stop navbar dropdown links from taking the navbar item hover color
1. Replace the actions job log options dropdown with the shared tippy menu, its hover highlight was invisible
1. Stop changing font weight on active and selected menu items, it resized rows while arrowing
1. Stretch the "All extensions" button in the diff file extension filter to the full menu width
1. Fix the actions run summary block covering the panel's rounded corners and indenting wrapped stats
2026-09-25 00:01:09 +00:00

712 lines
21 KiB
Vue

<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref, shallowRef, watch} from 'vue';
import SvgIcon from './SvgIcon.vue';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {localUserSettings} from '../modules/user-settings.ts';
import {isPlainClick} from '../utils/dom.ts';
import {trN, trString} from '../modules/i18n.ts';
import {debounce} from '../utils/func.ts';
import type {ActionsJob} from '../modules/gitea-actions.ts';
import type {ActionRunViewStore} from './ActionRunView.ts';
import {
boxBottom,
boxCenterY,
computeGraphHighlightState,
createWorkflowGraphModel,
getWorkflowGraphLayoutOptions,
type GraphNode,
type RoutedEdge,
} from './WorkflowGraph.utils.ts';
export type WorkflowGraphLocale = {
graphJobsCount1: string,
graphJobsCountN: string,
graphDependenciesCount1: string,
graphDependenciesCountN: string,
graphSuccessRate: string,
graphZoomIn: string,
graphZoomMax: string,
graphZoomOut: string,
graphResetView: string,
};
interface StoredState {
scale: number;
translateX: number;
translateY: number;
timestamp: number;
}
const props = defineProps<{
store: ActionRunViewStore;
jobs: ActionsJob[];
runLink: string;
workflowId: string;
workflowLink?: string;
triggerEvent?: string;
locale: WorkflowGraphLocale;
}>();
const settingKeyStates = 'actions-graph-states';
const maxStoredStates = 10;
const layout = getWorkflowGraphLayoutOptions();
const scale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const isDragging = ref(false);
const lastMousePos = ref({x: 0, y: 0});
const graphContainer = ref<HTMLElement | null>(null);
const hoveredGraphId = ref<string | null>(null);
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
const expandedMatrixNodeIds = shallowRef<Set<string>>(new Set()); // always replaced wholesale, never mutated
function isMatrixExpanded(nodeId: string): boolean {
return expandedMatrixNodeIds.value.has(nodeId);
}
function toggleMatrixExpanded(nodeId: string) {
const next = new Set(expandedMatrixNodeIds.value);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
expandedMatrixNodeIds.value = next;
}
const loadSavedState = () => {
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
const saved = allStates[stateKey()];
if (!saved) return;
scale.value = clampScale(saved.scale ?? scale.value);
translateX.value = saved.translateX ?? translateX.value;
translateY.value = saved.translateY ?? translateY.value;
};
const saveState = () => {
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
allStates[stateKey()] = {
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);
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
};
const graphModel = computed(() => createWorkflowGraphModel(props.jobs, expandedMatrixNodeIds.value));
const jobsWithLayout = computed(() => graphModel.value.nodes);
const edges = computed(() => graphModel.value.edges);
const routedEdges = computed<RoutedEdge[]>(() => graphModel.value.routedEdges);
const nodeWidth = layout.nodeWidth;
const graphWidth = computed(() => {
if (jobsWithLayout.value.length === 0) return 800;
const maxX = Math.max(...jobsWithLayout.value.map((job) => job.x + nodeWidth));
return maxX + layout.margin * 2;
});
const graphHeight = computed(() => {
if (jobsWithLayout.value.length === 0) return 400;
const maxY = Math.max(...jobsWithLayout.value.map((job) => boxBottom(job)));
return maxY + layout.margin * 2;
});
const successRateLabel = computed(() => {
if (props.jobs.length === 0) return '0%';
const successCount = props.jobs.filter((job) => job.status === 'success').length;
return `${((successCount / props.jobs.length) * 100).toFixed(0)}%`;
});
const graphStats = computed(() => [
trN(props.jobs.length, props.locale.graphJobsCount1, props.locale.graphJobsCountN),
trN(edges.value.length, props.locale.graphDependenciesCount1, props.locale.graphDependenciesCountN),
trString(props.locale.graphSuccessRate, successRateLabel.value),
].join(' • '));
const minScale = 0.3;
const maxScale = 1;
function clampScale(nextScale: number): number {
return Math.min(Math.max(Math.round(nextScale * 100) / 100, minScale), maxScale);
}
const canZoomIn = computed(() => scale.value < maxScale);
function zoomTo(nextScale: number) {
scale.value = clampScale(nextScale);
}
function zoomIn() {
zoomTo(scale.value * 1.2);
}
function zoomOut() {
zoomTo(scale.value / 1.2);
}
function resetView() {
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
}
function handleMouseDown(e: MouseEvent) {
if (!isPlainClick(e)) return;
const target = e.target as Element;
const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group');
if (interactive?.closest('svg')) return;
e.preventDefault();
isDragging.value = true;
lastMousePos.value = {x: e.clientX, y: e.clientY};
if (graphContainer.value) graphContainer.value.style.cursor = 'grabbing';
}
function handleMouseMoveOnDocument(event: MouseEvent) {
if (!isDragging.value) return;
translateX.value += event.clientX - lastMousePos.value.x;
translateY.value += event.clientY - lastMousePos.value.y;
lastMousePos.value = {x: event.clientX, y: event.clientY};
}
function handleMouseUpOnDocument() {
if (!isDragging.value) return;
isDragging.value = false;
if (graphContainer.value) graphContainer.value.style.cursor = 'grab';
}
function handleWheel(event: WheelEvent) {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const zoomFactor = Math.exp(-event.deltaY * 0.0015);
zoomTo(scale.value * zoomFactor);
}
onMounted(() => {
loadSavedState();
watch([translateX, translateY, scale], debounce(saveState, 500));
document.addEventListener('mousemove', handleMouseMoveOnDocument);
document.addEventListener('mouseup', handleMouseUpOnDocument);
});
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMoveOnDocument);
document.removeEventListener('mouseup', handleMouseUpOnDocument);
});
function handleNodeMouseEnter(id: string) {
hoveredGraphId.value = id;
}
function handleNodeMouseLeave() {
hoveredGraphId.value = null;
}
const highlightState = computed(() => computeGraphHighlightState(hoveredGraphId.value, graphModel.value.adjacency));
function isNodeHighlighted(nodeId: string): boolean {
return highlightState.value.nodeIds.has(nodeId);
}
function isEdgeHighlighted(edge: RoutedEdge): boolean {
return highlightState.value.edgeKeys.has(edge.key);
}
const splitRoutedEdges = computed(() => {
const highlighted: RoutedEdge[] = [];
const dimmed: RoutedEdge[] = [];
for (const edge of routedEdges.value) (isEdgeHighlighted(edge) ? highlighted : dimmed).push(edge);
return {highlighted, dimmed};
});
const nodesWithIncomingEdge = computed(() => new Set(graphModel.value.adjacency.incomingByNodeId.keys()));
const nodesWithOutgoingEdge = computed(() => new Set(graphModel.value.adjacency.outgoingByNodeId.keys()));
function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
const target = 'jobs' in job ? job.jobs[0]! : job;
// Reusable callers have no per-job detail page; clicking them is a no-op so the graph
// doesn't lead users to a dead destination.
if (target.isReusableCaller) return;
const link = `${props.runLink}/jobs/${target.id}`;
if (event.ctrlKey || event.metaKey) {
window.open(link, '_blank');
return;
}
window.location.href = link;
}
</script>
<template>
<div v-if="jobs.length > 0" class="workflow-graph">
<div class="graph-header">
<div class="graph-workflow-info">
<a v-if="workflowLink" class="graph-workflow-name silenced" :href="workflowLink">{{ workflowId }}</a>
<span v-else class="graph-workflow-name">{{ workflowId }}</span>
<div v-if="triggerEvent" class="graph-workflow-trigger">on: {{ triggerEvent }}</div>
</div>
<div class="graph-stats">{{ graphStats }}</div>
<div class="flex-text-block graph-controls">
<button
type="button"
@click="zoomIn"
class="ui compact tiny icon button"
:disabled="!canZoomIn"
:title="canZoomIn ? locale.graphZoomIn : locale.graphZoomMax"
>
<SvgIcon name="octicon-zoom-in" :size="12"/>
</button>
<button type="button" @click="resetView" class="ui compact tiny icon button" :title="locale.graphResetView">
<SvgIcon name="octicon-sync" :size="12"/>
</button>
<button type="button" @click="zoomOut" class="ui compact tiny icon button" :title="locale.graphZoomOut">
<SvgIcon name="octicon-zoom-out" :size="12"/>
</button>
</div>
</div>
<div
ref="graphContainer"
class="graph-container"
:class="{dragging: isDragging}"
@mousedown="handleMouseDown"
@wheel="handleWheel"
>
<svg
:width="graphWidth"
:height="graphHeight"
class="graph-svg"
:class="{ 'has-hover': hoveredGraphId !== null }"
:style="{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: '0 0',
}"
>
<defs>
<mask :id="`workflow-graph-edge-mask-${workflowId}`">
<rect :width="graphWidth" :height="graphHeight" fill="white"/>
<rect
v-for="job in jobsWithLayout"
:key="`mask-${job.id}`"
:x="job.x"
:y="job.y"
:width="nodeWidth"
:height="job.displayHeight"
rx="6"
fill="black"
/>
</mask>
</defs>
<g :mask="`url(#workflow-graph-edge-mask-${workflowId})`">
<path
v-for="edge in splitRoutedEdges.dimmed"
:key="edge.key"
:d="edge.path"
fill="none"
class="node-edge"
/>
</g>
<template v-for="job in jobsWithLayout" :key="job.id">
<g
v-if="job.type === 'matrix'"
class="job-node-group matrix-job-group"
:class="{ 'related-node': isNodeHighlighted(job.id) }"
@mouseenter="handleNodeMouseEnter(job.id)"
@mouseleave="handleNodeMouseLeave"
>
<title>Matrix: {{ job.name }}</title>
<rect :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" rx="6" class="job-rect"/>
<foreignObject :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" class="matrix-foreign-object">
<div class="matrix-panel" xmlns="http://www.w3.org/1999/xhtml">
<div class="matrix-panel-label" @click.stop="toggleMatrixExpanded(job.id)">Matrix: {{ job.name }}</div>
<div
v-if="!isMatrixExpanded(job.id)"
class="matrix-panel-collapsed"
@click.stop="toggleMatrixExpanded(job.id)"
>
<div class="matrix-panel-summary-row">
<ActionStatusIcon :status="job.status" icon-variant="circle-fill"/>
<span class="matrix-panel-summary">{{ job.jobs.length }} jobs completed</span>
</div>
<span class="matrix-panel-toggle">Show all jobs</span>
</div>
<div v-else class="matrix-panel-jobs">
<div
v-for="ch in job.jobs"
:key="ch.id"
class="graph-list-row"
@mouseenter="handleNodeMouseEnter(job.id)"
@click.stop="onNodeClick(ch, $event)"
>
<div class="graph-list-row-main">
<ActionStatusIcon :status="ch.status" icon-variant="circle-fill"/>
<span class="graph-list-row-name">{{ ch.name }}</span>
</div>
<span class="graph-list-row-duration">{{ ch.duration }}</span>
</div>
</div>
</div>
</foreignObject>
<circle v-if="nodesWithIncomingEdge.has(job.id)" :cx="job.x" :cy="boxCenterY(job)" r="3.5" class="node-port"/>
<circle v-if="nodesWithOutgoingEdge.has(job.id)" :cx="job.x + nodeWidth" :cy="boxCenterY(job)" r="3.5" class="node-port"/>
</g>
<g
v-else-if="job.type === 'group'"
class="job-node-group grouped-job-group"
:class="{ 'related-node': isNodeHighlighted(job.id) }"
@mouseenter="handleNodeMouseEnter(job.id)"
@mouseleave="handleNodeMouseLeave"
>
<title>{{ job.name }}</title>
<rect :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" rx="6" class="job-rect"/>
<foreignObject :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" class="matrix-foreign-object">
<div class="grouped-panel" xmlns="http://www.w3.org/1999/xhtml" @click.stop>
<div
v-for="ch in job.jobs"
:key="ch.id"
class="graph-list-row"
@mouseenter="handleNodeMouseEnter(job.id)"
@click="onNodeClick(ch, $event)"
>
<div class="graph-list-row-main">
<ActionStatusIcon :status="ch.status" icon-variant="circle-fill"/>
<span class="graph-list-row-name">{{ ch.name }}</span>
</div>
<span class="graph-list-row-duration">{{ ch.duration }}</span>
</div>
</div>
</foreignObject>
<circle v-if="nodesWithIncomingEdge.has(job.id)" :cx="job.x" :cy="boxCenterY(job)" r="3.5" class="node-port"/>
<circle v-if="nodesWithOutgoingEdge.has(job.id)" :cx="job.x + nodeWidth" :cy="boxCenterY(job)" r="3.5" class="node-port"/>
</g>
<g
v-else
class="job-node-group"
:class="{ 'related-node': isNodeHighlighted(job.id), 'caller-node': job.jobs[0]!.isReusableCaller }"
@click="onNodeClick(job, $event)"
@mouseenter="handleNodeMouseEnter(job.id)"
@mouseleave="handleNodeMouseLeave"
>
<title>{{ job.name }}</title>
<rect :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" rx="6" class="job-rect"/>
<foreignObject :x="job.x + 10" :y="job.y + 6" :width="nodeWidth - 20" :height="job.displayHeight - 12">
<div class="job-row job-card" xmlns="http://www.w3.org/1999/xhtml">
<div class="job-row-main">
<ActionStatusIcon :status="job.status" icon-variant="circle-fill"/>
<span class="job-name">{{ job.name }}</span>
</div>
<span class="job-duration">{{ job.duration }}</span>
</div>
</foreignObject>
<circle v-if="nodesWithIncomingEdge.has(job.id)" :cx="job.x" :cy="boxCenterY(job)" r="3.5" class="node-port"/>
<circle v-if="nodesWithOutgoingEdge.has(job.id)" :cx="job.x + nodeWidth" :cy="boxCenterY(job)" r="3.5" class="node-port"/>
</g>
</template>
<!-- Highlighted edges render on top of nodes so they remain visible across dimmed boxes. -->
<g class="highlighted-edge-layer">
<path
v-for="edge in splitRoutedEdges.highlighted"
:key="`highlight-${edge.key}`"
:d="edge.path"
fill="none"
class="node-edge highlighted-edge"
/>
<template v-for="edge in splitRoutedEdges.highlighted" :key="`highlight-port-${edge.key}`">
<circle :cx="edge.fromNode.x + nodeWidth" :cy="boxCenterY(edge.fromNode)" r="3.5" class="node-port highlighted-port"/>
<circle :cx="edge.toNode.x" :cy="boxCenterY(edge.toNode)" r="3.5" class="node-port highlighted-port"/>
</template>
</g>
</svg>
</div>
</div>
</template>
<style scoped>
.workflow-graph {
flex: 1;
display: flex;
flex-direction: column;
}
.graph-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 8px;
gap: var(--gap-block);
flex-wrap: wrap;
border-bottom: 1px solid var(--color-secondary);
}
.graph-workflow-info {
min-width: 0;
}
.graph-workflow-name {
display: block;
color: var(--color-text);
font-size: 16px;
font-weight: var(--font-weight-semibold);
line-height: 1.25;
}
.graph-workflow-trigger {
margin-top: 4px;
color: var(--color-text-light-2);
font-size: 12px;
line-height: 1.4;
}
.graph-stats {
display: flex;
align-items: baseline;
column-gap: 8px;
color: var(--color-text-light-1);
font-size: 13px;
white-space: nowrap;
margin-left: auto;
padding: 0 16px;
}
.graph-controls {
flex-shrink: 0;
}
.graph-container {
flex: 1;
overflow: auto;
padding: 10px 14px 18px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
cursor: grab;
position: relative;
background: var(--color-box-body);
}
.graph-container.dragging {
cursor: grabbing;
}
.graph-svg {
display: block;
will-change: transform;
}
.graph-svg path {
transition: stroke-width 0.2s ease, opacity 0.2s ease;
stroke-linecap: round;
stroke-linejoin: round;
}
.node-edge {
stroke: var(--color-secondary-dark-2);
stroke-width: 1.5;
opacity: 0.9;
}
.highlighted-edge {
stroke: var(--color-primary);
stroke-width: 2;
}
.job-node-group {
cursor: pointer;
transition: opacity 0.15s ease;
}
.job-node-group.caller-node {
cursor: default;
}
.job-node-group:hover .job-rect,
.job-node-group.related-node .job-rect {
stroke: var(--color-primary);
stroke-width: 1.5;
fill: var(--color-primary-alpha-10);
}
.graph-svg.has-hover .job-node-group:not(.related-node) {
opacity: 0.2;
}
.graph-svg.has-hover .node-edge:not(.highlighted-edge) {
opacity: 0.15;
}
.highlighted-edge-layer {
pointer-events: none;
}
.highlighted-port {
fill: var(--color-primary);
stroke: var(--color-primary);
}
.job-rect {
fill: var(--color-box-body);
stroke: var(--color-secondary);
stroke-width: 1;
}
.matrix-foreign-object {
pointer-events: auto;
overflow: visible;
}
.matrix-panel,
.grouped-panel {
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 6px;
background: transparent;
pointer-events: auto;
user-select: none;
}
.matrix-panel {
display: flex;
flex-direction: column;
padding: 6px 10px 8px;
}
.matrix-panel-label {
font-size: 10px;
font-weight: var(--font-weight-medium);
color: var(--color-text-light-2);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.matrix-panel-collapsed {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0 0 2px;
cursor: pointer;
}
.matrix-panel-summary-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.matrix-panel-summary {
font-size: 12px;
font-weight: var(--font-weight-semibold);
line-height: 1.3;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.matrix-panel-toggle {
font-size: 11px;
color: var(--color-text-light-2);
padding-left: 24px;
cursor: pointer;
}
.matrix-panel-toggle:hover {
color: var(--color-primary);
text-decoration: underline;
}
.matrix-panel-jobs {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0 0 2px;
overflow-y: auto;
}
.grouped-panel {
display: flex;
flex-direction: column;
justify-content: center;
padding: 6px;
gap: 2px;
}
.graph-list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 24px;
padding: 1px 6px;
border-radius: 5px;
}
.graph-list-row:hover {
background: var(--color-hover);
}
.graph-list-row-main,
.job-row-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.graph-list-row-name,
.job-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
.graph-list-row-duration,
.job-duration {
flex: 0 0 auto;
font-size: 10px;
color: var(--color-text-light-2);
white-space: nowrap;
}
.job-row {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.job-card {
border-radius: 6px;
padding: 0 2px;
}
.node-port {
fill: var(--color-secondary-dark-2);
stroke: var(--color-box-body);
stroke-width: 1.25;
opacity: 0.9;
pointer-events: none;
}
.job-node-group.related-node .node-port {
fill: var(--color-primary);
}
</style>