ProgressView.vue 5.54 KB
<template>
  <div class="progress-view">
    <div v-if="!state" class="empty">loading…</div>
    <div v-else class="cards">
      <div class="card big">
        <label>Project Status</label>
        <div class="value">
          <span :class="['badge', currentStatus]">{{ currentStatus }}</span>
        </div>
        <div class="meta">created {{ formatTs(state.project?.created_at) }}</div>
      </div>

      <div class="card">
        <label>PRD rev</label>
        <div class="value mono">{{ state.prd_rev ?? '-' }}</div>
      </div>

      <div class="card">
        <label>SDD rev</label>
        <div class="value mono">{{ state.sdd_rev ?? '-' }}</div>
      </div>

      <div class="card">
        <label>Units progress</label>
        <div class="value">{{ unitsPassed }}/{{ units.length }} passed</div>
        <div class="bar"><div :style="{ width: pctPassed + '%' }" class="fill" /></div>
      </div>

      <div class="card">
        <label>L2 rounds</label>
        <div class="value">{{ l2Rounds }} / {{ l2Budget }}</div>
        <div class="bar"><div :style="{ width: pctL2 + '%' }" class="fill l2" /></div>
      </div>

      <div class="card">
        <label>Bugs (open)</label>
        <div class="value">{{ bugsOpen }} <small>/ {{ bugsTotal }} total</small></div>
      </div>
    </div>

    <h3>Per-unit breakdown</h3>
    <table v-if="units.length">
      <thead>
        <tr><th>Unit</th><th>Status</th><th>Retries</th><th>Contract hash</th></tr>
      </thead>
      <tbody>
        <tr v-for="u in units" :key="u.id">
          <td class="mono">{{ u.id }}</td>
          <td><span :class="['badge', u.status]">{{ u.status }}</span></td>
          <td>{{ u.retries ?? 0 }} / {{ u.retry_budget ?? '-' }}</td>
          <td class="mono hash">{{ u.contract_hash || '-' }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({ pid: { type: String, required: true } });

const state = ref(null);
const bugs = ref([]);
let timer = null;

async function load() {
  try {
    const [sR, bR] = await Promise.all([
      fetch(`/api/devloop/projects/${props.pid}/state`),
      fetch(`/api/devloop/projects/${props.pid}/bugs`),
    ]);
    if (sR.ok) state.value = await sR.json();
    if (bR.ok) bugs.value = (await bR.json()).bugs || [];
  } catch { /* ignore */ }
}

const units = computed(() => state.value?.units || []);
const unitsPassed = computed(() => units.value.filter(u => u.status === 'passed').length);
const pctPassed = computed(() => units.value.length ? Math.round(unitsPassed.value / units.value.length * 100) : 0);
const currentStatus = computed(() => state.value?.status || state.value?.project?.status || 'unknown');
const l2Rounds = computed(() => {
  // pull from units.json via the existing /state endpoint structure
  // state.units_rev exists but rounds field needs separate fetch; for now read project.json if surfaced
  return state.value?.project?.l2_rounds ?? 0;
});
const l2Budget = computed(() => 3);
const pctL2 = computed(() => Math.min(100, (l2Rounds.value / l2Budget.value) * 100));
const bugsOpen = computed(() => bugs.value.filter(b => b.status === 'open' || b.status === 'fixing').length);
const bugsTotal = computed(() => bugs.value.length);

function formatTs(s) { if (!s) return '-'; try { return new Date(s).toLocaleString(); } catch { return s; } }

onMounted(() => {
  load();
  timer = setInterval(load, 3000);
});
onBeforeUnmount(() => { if (timer) clearInterval(timer); });
</script>

<style scoped>
.progress-view {}
.cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:14px; }
.card { background:#fafafb; border:1px solid #e7e7eb; border-radius:8px; padding:14px 16px; }
.card.big { grid-column: 1 / -1; }
.card label { font-size:11px; text-transform:uppercase; color:#888; }
.card .value { font-size:22px; font-weight:600; margin-top:4px; }
.card .value small { font-size:13px; color:#888; font-weight:400; }
.card .meta { font-size:11px; color:#aaa; }
.mono { font-family: ui-monospace, monospace; }
.bar { width:100%; height:6px; background:#eee; border-radius:3px; margin-top:6px; overflow:hidden; }
.fill { height:6px; background:#0a6; border-radius:3px; transition: width .3s; }
.fill.l2 { background:#a64a00; }
.empty { padding:30px; text-align:center; color:#888; }
h3 { font-size:13px; color:#666; margin:24px 0 8px; text-transform:uppercase; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
.hash { font-size:11px; color:#666; }
.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:11px; text-transform:uppercase; font-weight:600; }
.badge.draft         { background:#eee; color:#666; }
.badge.running       { background:#cce5ff; color:#004085; }
.badge.shipped       { background:#d4edda; color:#155724; }
.badge.shipped-final { background:#0a6;    color:#fff; }
.badge.abandoned     { background:#222;    color:#fff; }
.badge.l2_blocked    { background:#fff3cd; color:#856404; }
.badge.l2_exhausted  { background:#f8d7da; color:#721c24; }
.badge.pending       { background:#eee; color:#666; }
.badge.coding        { background:#fff3cd; color:#856404; }
.badge.testing       { background:#cce5ff; color:#004085; }
.badge.passed        { background:#d4edda; color:#155724; }
.badge.failed        { background:#f8d7da; color:#721c24; }
.badge.blocked       { background:#d6d8db; color:#383d41; }
.badge.unknown       { background:#fafafa; color:#888; }
</style>