You need to sign in before continuing.
UnitsDAG.vue 3.6 KB
<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>