mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-22 17:33:16 +01:00
Changes: - i18n: Removed all comments, made code self-documenting - fix(zoom): Corrected cumulative scaling drift (1.2 * 0.8 != 1.0) - refactor(styles): Replaced inline dynamic styles with CSS classes - fix(storage): Implemented LRU cache for localStorage (max 15 entries) - cleanup: Removed CSS variable fallbacks, use design system only - fix: Corrected zoom controls rendering and border display - chore: Aligned component structure with project conventions Note: Component implemented manually with strict typing and performance optimizations (rAF, cycle detection)
1327 lines
32 KiB
Vue
1327 lines
32 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted } from 'vue';
|
|
|
|
interface Job {
|
|
id: number;
|
|
job_id: string;
|
|
name: string;
|
|
status: string;
|
|
needs?: string[];
|
|
duration?: string | number;
|
|
}
|
|
|
|
interface JobNode extends Job {
|
|
x: number;
|
|
y: number;
|
|
level: number;
|
|
index: 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: Job[];
|
|
currentJobIdx?: number;
|
|
}>()
|
|
|
|
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 animationFrameId = ref<number | null>(null);
|
|
const container = ref<HTMLElement | null>(null);
|
|
const hoveredJobId = ref<number | null>(null);
|
|
const STORAGE_KEY = 'workflow-graph-states';
|
|
const MAX_STORED_STATES = 15;
|
|
|
|
const loadSavedState = () => {
|
|
try {
|
|
const currentRunId = getCurrentRunId();
|
|
const allStates = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
|
const saved = allStates[currentRunId];
|
|
|
|
if (saved) {
|
|
if (saved.scale !== undefined) {
|
|
scale.value = saved.scale;
|
|
}
|
|
if (saved.translateX !== undefined) {
|
|
translateX.value = saved.translateX;
|
|
}
|
|
if (saved.translateY !== undefined) {
|
|
translateY.value = saved.translateY;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load workflow graph state from localStorage:', e);
|
|
}
|
|
}
|
|
|
|
const getCurrentRunId = () => {
|
|
const runMatch = window.location.pathname.match(/\/runs\/(\d+)/);
|
|
return runMatch ? runMatch[1] : 'unknown';
|
|
};
|
|
|
|
const saveState = () => {
|
|
try {
|
|
const currentRunId = getCurrentRunId();
|
|
const allStates = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Record<string, StoredState>;
|
|
|
|
allStates[currentRunId] = {
|
|
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, MAX_STORED_STATES);
|
|
|
|
const limitedStates = Object.fromEntries(sortedStates);
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(limitedStates));
|
|
} catch (e) {
|
|
console.error('Failed to save workflow graph state:', e);
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadSavedState();
|
|
})
|
|
|
|
watch([translateX, translateY, scale], () => {
|
|
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: Job[][] = [];
|
|
let maxJobsPerLevel = 0;
|
|
|
|
props.jobs.forEach(job => {
|
|
const level = levels.get(job.name) || levels.get(job.job_id) || 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({
|
|
...job,
|
|
status: job.status.toLowerCase(),
|
|
x: startX + jobIndex * currentHorizontalSpacing,
|
|
y: margin + levelIndex * verticalSpacing,
|
|
level: levelIndex,
|
|
index: props.jobs.findIndex(j => j.id === job.id)
|
|
});
|
|
});
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
return props.jobs.map((job, index) => ({
|
|
...job,
|
|
x: margin + index * (nodeWidth.value + 40),
|
|
y: margin,
|
|
level: 0,
|
|
index: index
|
|
}));
|
|
}
|
|
});
|
|
|
|
const edges = computed<Edge[]>(() => {
|
|
const edgesList: Edge[] = [];
|
|
|
|
const jobsByJobId = new Map<string, Job[]>();
|
|
props.jobs.forEach(job => {
|
|
if (job.job_id) {
|
|
if (!jobsByJobId.has(job.job_id)) {
|
|
jobsByJobId.set(job.job_id, []);
|
|
}
|
|
jobsByJobId.get(job.job_id)!.push(job);
|
|
}
|
|
})
|
|
|
|
props.jobs.forEach(job => {
|
|
if (job.needs && job.needs.length > 0 && job.job_id) {
|
|
job.needs.forEach(need => {
|
|
const targetJobs = jobsByJobId.get(need) || [];
|
|
|
|
if (targetJobs.length > 0) {
|
|
targetJobs.forEach(targetJob => {
|
|
edgesList.push({
|
|
from: targetJob.name,
|
|
to: job.name,
|
|
key: `${targetJob.id}-${job.id}`
|
|
})
|
|
});
|
|
} else {
|
|
console.warn(`Job "${job.name}": need "${need}" not found`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
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 =>
|
|
['success', 'completed'].includes(job.status)
|
|
).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 statusTypes = computed(() => {
|
|
const statuses = new Set<string>();
|
|
jobsWithLayout.value.forEach(job => statuses.add(job.status));
|
|
return Array.from(statuses);
|
|
})
|
|
|
|
const nodeHeight = 50;
|
|
const verticalSpacing = 120;
|
|
const margin = 40;
|
|
|
|
function zoomIn() {
|
|
scale.value = Math.min(scale.value + 0.25, 3);
|
|
}
|
|
|
|
function zoomOut() {
|
|
scale.value = Math.max(scale.value - 0.25, 0.5);
|
|
}
|
|
|
|
function resetView() {
|
|
scale.value = 1;
|
|
translateX.value = 0;
|
|
translateY.value = 0;
|
|
}
|
|
|
|
function handleWheel(event: WheelEvent) {
|
|
event.preventDefault();
|
|
|
|
if (animationFrameId.value !== null) {
|
|
cancelAnimationFrame(animationFrameId.value);
|
|
}
|
|
|
|
animationFrameId.value = requestAnimationFrame(() => {
|
|
const delta = event.deltaY > 0 ? 0.9 : 1.1;
|
|
scale.value = Math.min(Math.max(scale.value * delta, 0.5), 3);
|
|
})
|
|
}
|
|
|
|
function handleMouseDown(event: MouseEvent) {
|
|
if (event.button !== 0) {
|
|
return
|
|
}
|
|
event.preventDefault();
|
|
|
|
isDragging.value = true;
|
|
dragStart.value = {
|
|
x: event.clientX - translateX.value,
|
|
y: event.clientY - translateY.value
|
|
};
|
|
lastMousePos.value = { x: event.clientX, y: event.clientY };
|
|
|
|
if (container.value) {
|
|
container.value.style.cursor = 'grabbing';
|
|
}
|
|
|
|
document.body.style.userSelect = 'none';
|
|
document.addEventListener('mouseup', handleMouseUpOnDocument);
|
|
document.addEventListener('mousemove', handleMouseMoveOnDocument);
|
|
}
|
|
|
|
function handleMouseMoveOnDocument(event: MouseEvent) {
|
|
if (!isDragging.value) {
|
|
return
|
|
};
|
|
|
|
if (animationFrameId.value !== null) {
|
|
cancelAnimationFrame(animationFrameId.value);
|
|
}
|
|
|
|
animationFrameId.value = requestAnimationFrame(() => {
|
|
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;
|
|
}
|
|
|
|
if (animationFrameId.value !== null) {
|
|
cancelAnimationFrame(animationFrameId.value);
|
|
animationFrameId.value = null;
|
|
}
|
|
|
|
isDragging.value = false;
|
|
|
|
if (container.value) {
|
|
container.value.style.cursor = 'grab';
|
|
}
|
|
|
|
document.body.style.userSelect = '';
|
|
document.removeEventListener('mouseup', handleMouseUpOnDocument);
|
|
document.removeEventListener('mousemove', handleMouseMoveOnDocument);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const highlighted = edge.from === hoveredJob.name || edge.to === hoveredJob.name;
|
|
return highlighted;
|
|
}
|
|
|
|
function getNodeColor(status: string): string {
|
|
const statusLower = status;
|
|
|
|
if (statusLower === 'success' || statusLower === 'completed') {
|
|
return 'var(--color-green-darker)';
|
|
} else if (statusLower === 'failure') {
|
|
return 'var(--color-red-darker)';
|
|
} else if (statusLower === 'running') {
|
|
return 'var(--color-yellow-darker)';
|
|
} else if (statusLower === 'blocked') {
|
|
return 'var(--color-purple)';
|
|
}
|
|
|
|
return 'var(--color-text-light-3)';
|
|
}
|
|
|
|
function getStatusDotColor(status: string): string {
|
|
const statusLower = status;
|
|
|
|
if (statusLower === 'success' || statusLower === 'completed') {
|
|
return 'var(--color-green)';
|
|
} else if (statusLower === 'failure') {
|
|
return 'var(--color-red)';
|
|
} else if (statusLower === 'running') {
|
|
return 'var(--color-yellow)';
|
|
}
|
|
|
|
return 'var(--color-text-light-2)';
|
|
}
|
|
|
|
function getRunningGradientEndColor(): string {
|
|
return 'var(--color-yellow-light)';
|
|
}
|
|
|
|
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: string): string {
|
|
const statusMap: Record<string, string> = {
|
|
success: 'Success',
|
|
failure: 'Failed',
|
|
running: 'Running',
|
|
waiting: 'Waiting',
|
|
cancelled: 'Cancelled',
|
|
completed: 'Completed',
|
|
blocked: 'Blocked'
|
|
};
|
|
|
|
return statusMap[status] || status;
|
|
}
|
|
|
|
function getEdgeStyle(edge: BezierEdge) {
|
|
if (!edge.fromNode || !edge.toNode) {
|
|
return {
|
|
stroke: 'var(--color-secondary)',
|
|
strokeWidth: '2',
|
|
opacity: '0.7'
|
|
};
|
|
}
|
|
|
|
const fromStatus = edge.fromNode.status;
|
|
const toStatus = edge.toNode.status;
|
|
const isHighlighted = isEdgeHighlighted(edge);
|
|
|
|
return {
|
|
stroke: fromStatus === 'running' ? 'url(#edge-running-gradient)' : getEdgeColor(edge),
|
|
strokeWidth: isHighlighted ? '3' : getStrokeWidth(fromStatus, toStatus),
|
|
strokeDasharray: getDashArray(fromStatus, toStatus),
|
|
opacity: isHighlighted ? 1 : getEdgeOpacity(fromStatus, toStatus),
|
|
markerEnd: getMarkerEnd(fromStatus, toStatus),
|
|
transition: 'all 0.2s ease'
|
|
};
|
|
}
|
|
|
|
function getStrokeWidth(fromStatus: string, toStatus: string): string {
|
|
if (fromStatus === 'running' || toStatus === 'running') {
|
|
return '3';
|
|
}
|
|
|
|
if (fromStatus === 'failure' || toStatus === 'failure') {
|
|
return '2.5'
|
|
}
|
|
|
|
return '2'
|
|
}
|
|
|
|
function getDashArray(fromStatus: string, toStatus: string): 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: string, toStatus: string): 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 getMarkerEnd(fromStatus: string, toStatus: string): string {
|
|
if (fromStatus === 'failure' || toStatus === 'failure') {
|
|
return 'url(#arrowhead-failure)';
|
|
}
|
|
|
|
if (fromStatus === 'running') {
|
|
return 'url(#arrowhead-running)';
|
|
}
|
|
|
|
if (fromStatus === 'success') {
|
|
if (toStatus === 'running') {
|
|
return 'url(#arrowhead-ready)';
|
|
}
|
|
|
|
if (toStatus === 'success' || toStatus === 'completed') {
|
|
return 'url(#arrowhead-success)';
|
|
}
|
|
}
|
|
|
|
if (fromStatus === 'waiting' || fromStatus === 'blocked') {
|
|
return 'url(#arrowhead-waiting)';
|
|
}
|
|
|
|
return 'none';
|
|
}
|
|
|
|
function getEdgeClass(edge: BezierEdge): string {
|
|
if (!edge.fromNode || !edge.toNode) return '';
|
|
|
|
const fromStatus = edge.fromNode.status;
|
|
const toStatus = edge.toNode.status;
|
|
|
|
const classes: string[] = [];
|
|
|
|
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: Job[]): Map<string, number> {
|
|
const jobMap = new Map<string, Job>()
|
|
jobs.forEach(job => {
|
|
jobMap.set(job.name, job);
|
|
|
|
if (job.job_id) {
|
|
jobMap.set(job.job_id, 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 || job.needs.length === 0) {
|
|
levels.set(job.name, 0);
|
|
if (job.job_id && job.job_id !== job.name) {
|
|
levels.set(job.job_id, 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.job_id && job.job_id !== job.name) {
|
|
levels.set(job.job_id, level);
|
|
}
|
|
|
|
recursionStack.delete(jobNameOrId);
|
|
return level;
|
|
}
|
|
|
|
jobs.forEach(job => {
|
|
if (!visited.has(job.name) && !visited.has(job.job_id)) {
|
|
dfs(job.name);
|
|
}
|
|
})
|
|
|
|
return levels;
|
|
}
|
|
|
|
function onNodeClick(job: JobNode, event?: MouseEvent) {
|
|
if (job.index === props.currentJobIdx) {
|
|
return;
|
|
}
|
|
|
|
const currentPath = window.location.pathname;
|
|
const jobsIndex = currentPath.indexOf('/jobs/');
|
|
|
|
if (jobsIndex !== -1) {
|
|
const basePath = currentPath.substring(0, jobsIndex);
|
|
const newJobUrl = `${basePath}/jobs/${job.index}`;
|
|
|
|
const isCtrlClick = event?.ctrlKey || event?.metaKey;
|
|
const isMiddleClick = event?.button === 1;
|
|
const isNewTab = isCtrlClick || isMiddleClick;
|
|
|
|
if (isNewTab) {
|
|
window.open(newJobUrl, '_blank');
|
|
} else {
|
|
window.location.href = newJobUrl;
|
|
}
|
|
} else {
|
|
const runMatch = currentPath.match(/\/runs\/(\d+)/);
|
|
if (runMatch) {
|
|
const runId = runMatch[1];
|
|
const pathParts = currentPath.split(`/runs/${runId}`);
|
|
const newJobUrl = `${pathParts[0]}/runs/${runId}/jobs/${job.id}`;
|
|
|
|
if (event?.ctrlKey || event?.metaKey) {
|
|
window.open(newJobUrl, '_blank');
|
|
} else {
|
|
window.location.href = newJobUrl;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</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 • Parallelism: {{ graphMetrics.parallelism }}
|
|
</span>
|
|
</div>
|
|
<div class="graph-controls">
|
|
<button @click="resetView" class="control-btn" title="Reset view">
|
|
<svg class="control-icon" viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
</svg>
|
|
</button>
|
|
<div class="zoom-controls">
|
|
<button @click="zoomOut" class="control-btn" title="Zoom out">
|
|
<svg class="control-icon" viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M2.75 7.25a.75.75 0 0 0 0 1.5h10.5a.75.75 0 0 0 0-1.5H2.75z"/>
|
|
</svg>
|
|
</button>
|
|
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
|
|
<button @click="zoomIn" class="control-btn" title="Zoom in">
|
|
<svg class="control-icon" viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="graph-container"
|
|
ref="container"
|
|
@wheel="handleWheel"
|
|
@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="job-node-group"
|
|
:class="{
|
|
'current-job': job.index === currentJobIdx
|
|
}"
|
|
@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 === currentJobIdx ? 'var(--color-primary)' : 'var(--color-card-border)'"
|
|
:stroke-width="job.index === currentJobIdx ? '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"
|
|
/>
|
|
|
|
<g
|
|
:transform="`translate(${job.x + 12}, ${job.y + 12})`"
|
|
class="status-icon"
|
|
>
|
|
<circle
|
|
v-if="job.status === 'success'"
|
|
r="6"
|
|
:fill="getStatusDotColor('success')"
|
|
/>
|
|
<polygon
|
|
v-else-if="job.status === 'failure'"
|
|
points="0,-5 5,3 -5,3"
|
|
:fill="getStatusDotColor('failure')"
|
|
/>
|
|
<circle
|
|
v-else-if="job.status === 'running'"
|
|
r="5"
|
|
:fill="getStatusDotColor('running')"
|
|
:class="{ 'pulse-dot': job.status === 'running' }"
|
|
/>
|
|
<circle
|
|
v-else
|
|
r="4"
|
|
:fill="getStatusDotColor('waiting')"
|
|
:stroke="getStatusDotColor('waiting')"
|
|
stroke-width="2"
|
|
/>
|
|
</g>
|
|
<text
|
|
:x="job.x + 25"
|
|
:y="job.y + 16"
|
|
fill="white"
|
|
font-size="12"
|
|
text-anchor="start"
|
|
class="job-name"
|
|
>
|
|
{{ getDisplayName(job.name) }}
|
|
</text>
|
|
|
|
<text
|
|
v-if="job.duration && ['success', 'failure', 'completed'].includes(job.status)"
|
|
: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 && job.needs.length > 0"
|
|
: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>
|
|
|
|
<marker
|
|
id="arrowhead-success"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" :fill="getStatusDotColor('success')"/>
|
|
</marker>
|
|
|
|
<marker
|
|
id="arrowhead-failure"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" :fill="getStatusDotColor('failure')"/>
|
|
</marker>
|
|
|
|
<marker
|
|
id="arrowhead-running"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" :fill="getStatusDotColor('running')"/>
|
|
</marker>
|
|
|
|
<marker
|
|
id="arrowhead-ready"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="var(--color-primary)"/>
|
|
</marker>
|
|
|
|
<marker
|
|
id="arrowhead-waiting"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" :fill="getStatusDotColor('waiting')"/>
|
|
</marker>
|
|
|
|
<linearGradient id="edge-running-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" :stop-color="getStatusDotColor('running')" stop-opacity="0.7"/>
|
|
<stop offset="100%" :stop-color="getRunningGradientEndColor()" stop-opacity="0.9"/>
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="graph-legend">
|
|
<div class="legend-item" v-for="status in statusTypes" :key="status">
|
|
<span
|
|
:class="['legend-dot', `status-${status.toLowerCase()}`]"
|
|
/>
|
|
<span
|
|
class="legend-text"
|
|
>
|
|
{{ formatStatus(status) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.workflow-graph {
|
|
padding: 5px 12px;
|
|
background: var(--color-box-body);
|
|
position: relative;
|
|
}
|
|
|
|
.graph-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 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;
|
|
padding: 4px 8px;
|
|
background: var(--color-secondary-alpha-10);
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.graph-metrics {
|
|
color: var(--color-primary);
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
|
|
.graph-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.control-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-body);
|
|
border: 1px solid var(--color-secondary-alpha-30);
|
|
border-radius: 4px;
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
padding: 0;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: var(--color-secondary-alpha-10);
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.control-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.zoom-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: var(--color-body);
|
|
}
|
|
|
|
.zoom-level {
|
|
display: flex;
|
|
font-size: 12px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--color-text-light);
|
|
min-width: 40px;
|
|
min-height: 28px;
|
|
border: 1px solid var(--color-secondary-alpha-30);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.graph-container {
|
|
overflow: auto;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
background: var(--color-body);
|
|
cursor: grab;
|
|
min-height: 300px;
|
|
max-height: 600px;
|
|
position: relative;
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.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 .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;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.job-status,
|
|
.job-duration,
|
|
.job-deps-label {
|
|
user-select: none;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
opacity: 0.7;
|
|
transform: scale(1.1);
|
|
}
|
|
}
|
|
|
|
.status-icon circle.pulse-dot {
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@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;
|
|
}
|
|
}
|
|
|
|
.running-edge {
|
|
stroke-dasharray: 10, 5;
|
|
animation: flowRunning 1s linear infinite;
|
|
}
|
|
|
|
.failure-edge {
|
|
animation: pulseFailure 0.8s ease-in-out infinite;
|
|
}
|
|
|
|
.waiting-edge {
|
|
stroke-dasharray: 5, 3;
|
|
animation: shimmerEdge 2s linear infinite;
|
|
}
|
|
|
|
.success-edge {
|
|
transition: stroke-width 0.3s ease, opacity 0.3s ease;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
|
|
.graph-legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
margin-top: 20px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--color-secondary-alpha-20);
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
}
|
|
|
|
.legend-dot.status-success {
|
|
background: var(--color-green);
|
|
}
|
|
|
|
.legend-dot.status-failure {
|
|
background: var(--color-red);
|
|
}
|
|
|
|
.legend-dot.status-running {
|
|
background: var(--color-orange);
|
|
}
|
|
|
|
.legend-dot.status-waiting {
|
|
background: var(--color-text-light-2);
|
|
}
|
|
|
|
.legend-text {
|
|
color: var(--color-text-light);
|
|
font-size: 12px;
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.graph-header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.graph-controls {
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.graph-stats {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.workflow-graph {
|
|
padding: 15px;
|
|
}
|
|
}
|
|
</style>
|