ProgressView.vue
5.54 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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
<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>