UnitsDAG.vue
3.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<template>
<div class="dag-wrap">
<svg v-if="units.length" :viewBox="`0 0 ${W} ${H}`" :width="W" :height="H">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#999" />
</marker>
</defs>
<line
v-for="(e, i) in edges"
:key="'e'+i"
:x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2"
stroke="#999" stroke-width="1.5" marker-end="url(#arr)"
/>
<g
v-for="(n, i) in nodes"
:key="'n'+n.id"
:transform="`translate(${n.x},${n.y})`"
class="node-g"
@click="$emit('select', n.id)"
>
<rect :width="NW" :height="NH" rx="6" :class="['node', n.status]" />
<text :x="NW/2" :y="20" text-anchor="middle" class="id-label">{{ n.id }}</text>
<text :x="NW/2" :y="38" text-anchor="middle" class="status-label">{{ n.status }}</text>
</g>
</svg>
<div v-else class="empty">no units yet</div>
<p class="hint">click a unit node to inspect recent runs</p>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({ units: { type: Array, default: () => [] } });
defineEmits(['select']);
const NW = 130, NH = 52, GAP_X = 40, GAP_Y = 80, PAD = 20;
const waves = computed(() => {
const remaining = new Set(props.units.map(u => u.id));
const deps = new Map(props.units.map(u => [u.id, (u.depends_on || []).slice()]));
const out = [];
let safety = 100;
while (remaining.size > 0 && safety-- > 0) {
const wave = [];
for (const id of remaining) {
if (deps.get(id).every(d => !remaining.has(d))) wave.push(id);
}
if (!wave.length) break;
for (const id of wave) remaining.delete(id);
out.push(wave);
}
return out;
});
const nodes = computed(() => {
const out = [];
waves.value.forEach((wave, wi) => {
wave.forEach((id, ni) => {
const u = props.units.find(x => x.id === id);
out.push({
id, status: u?.status || 'unknown',
x: PAD + wi * (NW + GAP_X),
y: PAD + ni * (NH + 20),
wave: wi,
});
});
});
return out;
});
const edges = computed(() => {
const out = [];
for (const u of props.units) {
for (const dep of (u.depends_on || [])) {
const a = nodes.value.find(n => n.id === dep);
const b = nodes.value.find(n => n.id === u.id);
if (a && b) out.push({ x1: a.x + NW, y1: a.y + NH/2, x2: b.x, y2: b.y + NH/2 });
}
}
return out;
});
const W = computed(() => Math.max(400, PAD * 2 + waves.value.length * (NW + GAP_X) - GAP_X));
const H = computed(() => {
const maxRow = Math.max(...waves.value.map(w => w.length), 1);
return PAD * 2 + maxRow * (NH + 20) - 20;
});
</script>
<style scoped>
.dag-wrap svg { display:block; max-width:100%; background:#fafafb; border:1px dashed #eee; border-radius:6px; }
.node-g { cursor: pointer; }
.node { fill:#fff; stroke:#888; stroke-width:1.5; }
.node:hover { stroke:#222; }
.node.pending { fill:#f5f5f5; stroke:#888; }
.node.coding { fill:#fff3cd; stroke:#856404; }
.node.testing { fill:#cce5ff; stroke:#004085; }
.node.passed { fill:#d4edda; stroke:#155724; }
.node.failed { fill:#f8d7da; stroke:#721c24; }
.node.blocked { fill:#d6d8db; stroke:#383d41; }
.node.unknown { fill:#fff; stroke:#aaa; }
.id-label { font-size:13px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; }
.status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; }
.empty { padding:24px; color:#888; text-align:center; }
.hint { font-size:11px; color:#aaa; margin-top:6px; }
</style>