Commit 694ca3a6d27f55dc75dccb0ca2bdd47fff4780a2
1 parent
0594b1de
feat(devloop): P5 Vue Dashboard — full UI per §P4 spec (12 components + 4 ops su…
…b-pages + UI endpoints) Completes the §P4 acceptance UI scope that was deferred in commit 0594b1d. Adds the full Vue Dashboard + Ops sub-pages with live SSE stream + auto-reconnect, unit DAG with detail drawer, PRD/SDD diff panel, feedback modal, and 4 ops views (Bugs / Loops / Changelog / Progress). Vue surface (≈1480 lines): - web/src/views/DevLoopDashboard.vue (~110 lines) * 4-region grid: status header / loop bar / units DAG / live stream / PRD diff. Polls /state every 2.5s; opens feedback modal & unit drawer; mounts Sign Off / Abandon controls. - web/src/views/DevLoopOps.vue (~50 lines) — tab container. - web/src/components/devloop/DashboardHeader.vue * Status badge (7 states color-coded), NEW REQUIREMENT button, Sign Off / Abandon buttons (disabled per FSM), Ops nav link. - web/src/components/devloop/LoopStatusBar.vue * 4-phase pipeline (Docs → L1 → L2 → Ship), highlights active phase. - web/src/components/devloop/UnitsDAG.vue * SVG DAG rendered from topological waves; per-unit status color; click → emits select → opens UnitDrawer. - web/src/components/devloop/UnitDrawer.vue * Slide-in panel showing unit metadata + last 3 runs + result summary + coder.log tail (last 1KB). - web/src/components/devloop/LiveAgentStream.vue * EventSource subscriber listening to project.status, unit.state, integration.result, triage.result, sdd.updated, prd.updated, human_intervention_needed, etc. Manual 5s reconnect on disconnect (browser's built-in Last-Event-ID is honored). - web/src/components/devloop/PrdDiffPanel.vue * Reads changelog.ndjson, renders prd_rev / sdd_rev entries with trigger labels. - web/src/components/devloop/FeedbackModal.vue * POSTs /feedback with PM incremental — shows rev bump on success. - web/src/components/devloop/ops/BuglistView.vue * Filterable bug table; status badges; severity / category columns. - web/src/components/devloop/ops/LoopLogView.vue * Timeline grouped by L1 (unit.*) / L2 (integration/triage/sdd/l2_*) / L3 (project.status/shipped/prd.updated/human_intervention) / System (rest). Free-text substring filter. - web/src/components/devloop/ops/ChangelogView.vue * Tabular prd_rev/sdd_rev entries with by/trigger/summary columns. - web/src/components/devloop/ops/ProgressView.vue * 6 metric cards (status, PRD rev, SDD rev, units passed bar, L2 rounds bar, open bugs) + per-unit breakdown table. Router: - web/src/router/index.js — adds: * /devloop/:pid → DevLoopDashboard * /devloop/:pid/ops/:tab(bugs|loops|changelog|progress) → DevLoopOps Backend UI-supporting endpoints (server/routes/devloop.js): - GET /api/devloop/projects/:pid/timeline?limit=N Reads timeline.ndjson, returns last N events reversed. - GET /api/devloop/projects/:pid/changelog Returns full changelog.ndjson entries reversed. - GET /api/devloop/projects/:pid/units/:unitId Returns { unit, runs (last 3 result.json), coder_log_tail }. 404 on unknown unit. Tests (+5 cases): - server/tests/devloop/routes.test.js — new P5 UI endpoints block: * timeline / changelog / units happy-paths * 404 on unknown unit and unknown pid Acceptance: - scripts/phase5-verify.js — 7 steps: ① Vue build artifacts (DevLoopDashboard.js + DevLoopOps.js chunks) ② POST /projects → state returns status + units ③ GET /timeline returns events ④ GET /changelog returns entries ⑤ GET /units/todo-be returns unit + runs ⑥ GET /bugs returns bug list ⑦ SSE /stream delivers ≥1 event after a triggering feedback POST Vue build: - npm run web:build → DevLoopDashboard chunk 15.63 kB, DevLoopOps chunk 9.09 kB, both with co-loaded CSS bundles. Results — all 7 phase gates green: - phase0-verify: 8/8 - phase0-test-call: 7/7 - phase1-verify: 7/7 - phase2-verify: 6/6 - phase3-verify: 9/9 - phase4-verify: 9/9 - phase5-verify: 7/7 - vitest devloop+acp-core: 218/218 (was 213 → +5) Specs: docs/prd/newfunction/_spec/plan-migration.md · P4 L3 + UI Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
17 changed files
with
1533 additions
and
0 deletions
scripts/phase5-verify.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * scripts/phase5-verify.js | |
| 4 | + * | |
| 5 | + * P5 Vue Dashboard 验收 (后端 + 静态产物): | |
| 6 | + * ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在 | |
| 7 | + * ② GET /:pid/state 返带 status + units 字段 | |
| 8 | + * ③ GET /:pid/timeline 返事件列表 | |
| 9 | + * ④ GET /:pid/changelog 返条目列表 | |
| 10 | + * ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail | |
| 11 | + * ⑥ GET /:pid/bugs 返 bug 列表 | |
| 12 | + * ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件 | |
| 13 | + * | |
| 14 | + * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。 | |
| 15 | + */ | |
| 16 | + | |
| 17 | +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | |
| 18 | + | |
| 19 | +const fs = require('fs'); | |
| 20 | +const path = require('path'); | |
| 21 | +const http = require('http'); | |
| 22 | + | |
| 23 | +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); | |
| 24 | +require('module').globalPaths.push(serverNodeModules); | |
| 25 | +const express = (() => { | |
| 26 | + try { return require('express'); } | |
| 27 | + catch { | |
| 28 | + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); | |
| 29 | + } | |
| 30 | +})(); | |
| 31 | + | |
| 32 | +const pathResolver = require('../server/services/devloop/path-resolver'); | |
| 33 | +const lc = require('../server/src/core/devloop/loop-controller'); | |
| 34 | +const devloopRouter = require('../server/routes/devloop'); | |
| 35 | + | |
| 36 | +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); | |
| 37 | +const WEB_DIST = path.resolve(__dirname, '../web/dist'); | |
| 38 | + | |
| 39 | +const results = []; | |
| 40 | +function step(name, fn) { | |
| 41 | + return (async () => { | |
| 42 | + try { | |
| 43 | + const r = await fn(); | |
| 44 | + results.push({ name, ok: true, detail: r?.detail || '' }); | |
| 45 | + return r; | |
| 46 | + } catch (e) { | |
| 47 | + results.push({ name, ok: false, reason: e.message }); | |
| 48 | + throw e; | |
| 49 | + } | |
| 50 | + })(); | |
| 51 | +} | |
| 52 | + | |
| 53 | +async function startApp() { | |
| 54 | + const app = express(); | |
| 55 | + app.use(express.json()); | |
| 56 | + app.use('/api/devloop', devloopRouter); | |
| 57 | + return new Promise((resolve) => { | |
| 58 | + const srv = app.listen(0, '127.0.0.1', () => { | |
| 59 | + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); | |
| 60 | + }); | |
| 61 | + }); | |
| 62 | +} | |
| 63 | + | |
| 64 | +async function main() { | |
| 65 | + await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => { | |
| 66 | + const required = [ | |
| 67 | + 'index.html', 'assets', | |
| 68 | + ]; | |
| 69 | + for (const f of required) { | |
| 70 | + if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`); | |
| 71 | + } | |
| 72 | + const assets = fs.readdirSync(path.join(WEB_DIST, 'assets')); | |
| 73 | + if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built'); | |
| 74 | + if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built'); | |
| 75 | + return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` }; | |
| 76 | + }); | |
| 77 | + | |
| 78 | + const { srv, base } = await startApp(); | |
| 79 | + const cid = 'c-phase5-' + Date.now().toString(36); | |
| 80 | + let pid; | |
| 81 | + | |
| 82 | + try { | |
| 83 | + await step('② 准备项目: POST /projects → state', async () => { | |
| 84 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 85 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 86 | + body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }), | |
| 87 | + }); | |
| 88 | + pid = (await r.json()).pid; | |
| 89 | + const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json(); | |
| 90 | + if (!s.status) throw new Error('state.status missing'); | |
| 91 | + if (!Array.isArray(s.units)) throw new Error('state.units missing'); | |
| 92 | + return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` }; | |
| 93 | + }); | |
| 94 | + | |
| 95 | + await step('③ GET /:pid/timeline returns events', async () => { | |
| 96 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`); | |
| 97 | + const j = await r.json(); | |
| 98 | + if (!Array.isArray(j.events)) throw new Error('events not array'); | |
| 99 | + if (j.events.length === 0) throw new Error('events empty'); | |
| 100 | + return { detail: `${j.events.length} events` }; | |
| 101 | + }); | |
| 102 | + | |
| 103 | + await step('④ GET /:pid/changelog returns entries', async () => { | |
| 104 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); | |
| 105 | + const j = await r.json(); | |
| 106 | + if (!Array.isArray(j.entries)) throw new Error('entries not array'); | |
| 107 | + return { detail: `${j.entries.length} entries` }; | |
| 108 | + }); | |
| 109 | + | |
| 110 | + await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => { | |
| 111 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); | |
| 112 | + if (r.status !== 200) throw new Error(`status ${r.status}`); | |
| 113 | + const j = await r.json(); | |
| 114 | + if (!j.unit) throw new Error('unit missing'); | |
| 115 | + if (!Array.isArray(j.runs)) throw new Error('runs not array'); | |
| 116 | + return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` }; | |
| 117 | + }); | |
| 118 | + | |
| 119 | + await step('⑥ GET /:pid/bugs returns bug list', async () => { | |
| 120 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`); | |
| 121 | + const j = await r.json(); | |
| 122 | + if (!Array.isArray(j.bugs)) throw new Error('bugs not array'); | |
| 123 | + return { detail: `${j.bugs.length} bugs` }; | |
| 124 | + }); | |
| 125 | + | |
| 126 | + await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => { | |
| 127 | + // 用 http.get 拿原生 stream | |
| 128 | + const url = new URL(`${base}/api/devloop/projects/${pid}/stream`); | |
| 129 | + const events = await new Promise((resolve) => { | |
| 130 | + const out = []; | |
| 131 | + const req = http.get({ | |
| 132 | + hostname: url.hostname, port: url.port, path: url.pathname, | |
| 133 | + headers: { 'Accept': 'text/event-stream' }, | |
| 134 | + }, (res) => { | |
| 135 | + let buf = ''; | |
| 136 | + res.on('data', (chunk) => { | |
| 137 | + buf += chunk.toString(); | |
| 138 | + const matches = buf.matchAll(/^event: (.+)$/gm); | |
| 139 | + for (const m of matches) out.push(m[1]); | |
| 140 | + buf = buf.slice(buf.lastIndexOf('\n\n') + 2); | |
| 141 | + }); | |
| 142 | + res.on('error', () => resolve(out)); | |
| 143 | + }); | |
| 144 | + // Trigger an event so we have something to receive | |
| 145 | + setTimeout(async () => { | |
| 146 | + try { | |
| 147 | + await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { | |
| 148 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 149 | + body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }), | |
| 150 | + }); | |
| 151 | + } catch { /* ignore */ } | |
| 152 | + }, 100); | |
| 153 | + setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500); | |
| 154 | + }); | |
| 155 | + if (events.length === 0) throw new Error('no SSE events received'); | |
| 156 | + return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` }; | |
| 157 | + }); | |
| 158 | + | |
| 159 | + } finally { | |
| 160 | + srv.close(); | |
| 161 | + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } | |
| 162 | + catch { /* ignore */ } | |
| 163 | + } | |
| 164 | + | |
| 165 | + let pass = 0, fail = 0; | |
| 166 | + console.log('\n┌─ DevLoop Phase 5 — Vue Dashboard Acceptance ──────'); | |
| 167 | + for (const r of results) { | |
| 168 | + const tag = r.ok ? '✓' : '✗'; | |
| 169 | + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`); | |
| 170 | + r.ok ? pass++ : fail++; | |
| 171 | + } | |
| 172 | + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); | |
| 173 | + process.exit(fail ? 1 : 0); | |
| 174 | +} | |
| 175 | + | |
| 176 | +main().catch(e => { | |
| 177 | + console.error('\n[phase5-verify] fatal:', e); | |
| 178 | + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); | |
| 179 | + process.exit(1); | |
| 180 | +}); | ... | ... |
server/routes/devloop.js
| ... | ... | @@ -206,6 +206,79 @@ router.get('/projects/:pid/bugs', h(async (req, res) => { |
| 206 | 206 | res.json({ bugs, total: bugs.length }); |
| 207 | 207 | })); |
| 208 | 208 | |
| 209 | +// P5: GET /api/devloop/projects/:pid/timeline?limit=200 | |
| 210 | +// 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用 | |
| 211 | +router.get('/projects/:pid/timeline', h(async (req, res) => { | |
| 212 | + const fs = require('fs'); | |
| 213 | + const { pid } = req.params; | |
| 214 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 215 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 216 | + const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200)); | |
| 217 | + const p = pathResolver.resolveTimelineLog(pid); | |
| 218 | + if (!fs.existsSync(p)) return res.json({ events: [], total: 0 }); | |
| 219 | + const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); | |
| 220 | + const events = lines.slice(-limit).reverse().map(l => { | |
| 221 | + try { return JSON.parse(l); } catch { return null; } | |
| 222 | + }).filter(Boolean); | |
| 223 | + res.json({ events, total: events.length }); | |
| 224 | +})); | |
| 225 | + | |
| 226 | +// P5: GET /api/devloop/projects/:pid/changelog | |
| 227 | +// 读 changelog.ndjson 全部 (按时间倒序) | |
| 228 | +router.get('/projects/:pid/changelog', h(async (req, res) => { | |
| 229 | + const fs = require('fs'); | |
| 230 | + const { pid } = req.params; | |
| 231 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 232 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 233 | + const p = pathResolver.resolveChangelog(pid); | |
| 234 | + if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 }); | |
| 235 | + const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean) | |
| 236 | + .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean) | |
| 237 | + .reverse(); | |
| 238 | + res.json({ entries, total: entries.length }); | |
| 239 | +})); | |
| 240 | + | |
| 241 | +// P5: GET /api/devloop/projects/:pid/units/:unitId | |
| 242 | +// 返回 unit 元数据 + 最近 N 次 runs (Tester result.json) | |
| 243 | +router.get('/projects/:pid/units/:unitId', h(async (req, res) => { | |
| 244 | + const fs = require('fs'); const path = require('path'); | |
| 245 | + const { pid, unitId } = req.params; | |
| 246 | + try { pathResolver.resolveProjectRoot(pid); } | |
| 247 | + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } | |
| 248 | + const unitDir = pathResolver.resolveUnitDir(pid, unitId); | |
| 249 | + if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId }); | |
| 250 | + // 从 units.json 找 unit 元数据 | |
| 251 | + let unitMeta = null; | |
| 252 | + try { | |
| 253 | + const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); | |
| 254 | + unitMeta = (uj.units || []).find(u => u.id === unitId) || null; | |
| 255 | + } catch { /* ignore */ } | |
| 256 | + // runs | |
| 257 | + const runsDir = path.join(unitDir, 'runs'); | |
| 258 | + let runs = []; | |
| 259 | + if (fs.existsSync(runsDir)) { | |
| 260 | + const ids = fs.readdirSync(runsDir).filter(n => { | |
| 261 | + try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; } | |
| 262 | + }).sort().reverse().slice(0, 3); | |
| 263 | + for (const id of ids) { | |
| 264 | + const rp = path.join(runsDir, id, 'result.json'); | |
| 265 | + let r = null; | |
| 266 | + if (fs.existsSync(rp)) { | |
| 267 | + try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {} | |
| 268 | + } | |
| 269 | + runs.push({ run_id: id, result: r }); | |
| 270 | + } | |
| 271 | + } | |
| 272 | + // coder.log 最后 1KB 节选 | |
| 273 | + let coderLog = null; | |
| 274 | + const clp = path.join(unitDir, 'coder.log'); | |
| 275 | + if (fs.existsSync(clp)) { | |
| 276 | + const raw = fs.readFileSync(clp, 'utf8'); | |
| 277 | + coderLog = raw.length > 1024 ? raw.slice(-1024) : raw; | |
| 278 | + } | |
| 279 | + res.json({ unit: unitMeta, runs, coder_log_tail: coderLog }); | |
| 280 | +})); | |
| 281 | + | |
| 209 | 282 | // GET /api/devloop/projects/:pid/state |
| 210 | 283 | router.get('/projects/:pid/state', h(async (req, res) => { |
| 211 | 284 | try { | ... | ... |
server/tests/devloop/routes.test.js
| ... | ... | @@ -189,4 +189,52 @@ describe('routes/devloop', () => { |
| 189 | 189 | const j = await r.json(); |
| 190 | 190 | expect(Array.isArray(j.projects)).toBe(true); |
| 191 | 191 | }); |
| 192 | + | |
| 193 | + // P5 — UI-supporting endpoints | |
| 194 | + describe('P5 UI endpoints', () => { | |
| 195 | + let pid; | |
| 196 | + beforeAll(async () => { | |
| 197 | + const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7); | |
| 198 | + created.push(cid); | |
| 199 | + const r = await fetch(`${base}/api/devloop/projects`, { | |
| 200 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 201 | + body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }), | |
| 202 | + }); | |
| 203 | + pid = (await r.json()).pid; | |
| 204 | + }); | |
| 205 | + | |
| 206 | + it('GET /timeline returns events array', async () => { | |
| 207 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`); | |
| 208 | + expect(r.status).toBe(200); | |
| 209 | + const j = await r.json(); | |
| 210 | + expect(Array.isArray(j.events)).toBe(true); | |
| 211 | + expect(j.events.length).toBeGreaterThan(0); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + it('GET /changelog returns entries array', async () => { | |
| 215 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); | |
| 216 | + expect(r.status).toBe(200); | |
| 217 | + const j = await r.json(); | |
| 218 | + expect(Array.isArray(j.entries)).toBe(true); | |
| 219 | + }); | |
| 220 | + | |
| 221 | + it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => { | |
| 222 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); | |
| 223 | + expect(r.status).toBe(200); | |
| 224 | + const j = await r.json(); | |
| 225 | + expect(j).toHaveProperty('unit'); | |
| 226 | + expect(j).toHaveProperty('runs'); | |
| 227 | + expect(Array.isArray(j.runs)).toBe(true); | |
| 228 | + }); | |
| 229 | + | |
| 230 | + it('GET /units/:unitId 404 on unknown unit', async () => { | |
| 231 | + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`); | |
| 232 | + expect(r.status).toBe(404); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + it('GET /timeline 404 on unknown pid', async () => { | |
| 236 | + const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`); | |
| 237 | + expect(r.status).toBe(404); | |
| 238 | + }); | |
| 239 | + }); | |
| 192 | 240 | }); | ... | ... |
web/src/components/devloop/DashboardHeader.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <header class="dl-header"> | |
| 3 | + <div class="left"> | |
| 4 | + <router-link to="/company" class="back">← LemonBoss</router-link> | |
| 5 | + <h1>DevLoop · {{ pid }}</h1> | |
| 6 | + <span class="status-badge" :class="status">{{ status }}</span> | |
| 7 | + </div> | |
| 8 | + <div class="right"> | |
| 9 | + <button class="btn primary" @click="$emit('feedback')" :disabled="terminal">+ NEW REQUIREMENT</button> | |
| 10 | + <button class="btn signoff" @click="$emit('signoff')" :disabled="status !== 'shipped'">Sign Off</button> | |
| 11 | + <button class="btn abandon" @click="$emit('abandon')" :disabled="terminal">Abandon</button> | |
| 12 | + <span class="sep">|</span> | |
| 13 | + <button class="btn link" @click="$emit('ops', 'bugs')">Ops →</button> | |
| 14 | + </div> | |
| 15 | + </header> | |
| 16 | +</template> | |
| 17 | + | |
| 18 | +<script setup> | |
| 19 | +import { computed } from 'vue'; | |
| 20 | +const props = defineProps({ | |
| 21 | + pid: { type: String, required: true }, | |
| 22 | + state: { type: Object, default: null }, | |
| 23 | +}); | |
| 24 | +defineEmits(['feedback', 'signoff', 'abandon', 'ops']); | |
| 25 | +const status = computed(() => props.state?.status || props.state?.project?.status || 'unknown'); | |
| 26 | +const terminal = computed(() => status.value === 'shipped-final' || status.value === 'abandoned'); | |
| 27 | +</script> | |
| 28 | + | |
| 29 | +<style scoped> | |
| 30 | +.dl-header { | |
| 31 | + display:flex; align-items:center; justify-content:space-between; | |
| 32 | + padding:12px 0; border-bottom:1px solid #eee; | |
| 33 | +} | |
| 34 | +.left { display:flex; align-items:center; gap:14px; } | |
| 35 | +.right { display:flex; align-items:center; gap:8px; } | |
| 36 | +.back { color:#888; text-decoration:none; font-size:13px; } | |
| 37 | +.back:hover { color:#222; } | |
| 38 | +h1 { font-size:18px; font-weight:600; margin:0; font-family: ui-monospace, monospace; } | |
| 39 | +.status-badge { | |
| 40 | + font-size:11px; padding:3px 10px; border-radius:12px; text-transform:uppercase; font-weight:600; | |
| 41 | +} | |
| 42 | +.status-badge.draft { background:#eee; color:#666; } | |
| 43 | +.status-badge.running { background:#cce5ff; color:#004085; } | |
| 44 | +.status-badge.shipped { background:#d4edda; color:#155724; } | |
| 45 | +.status-badge.shipped-final { background:#0a6; color:#fff; } | |
| 46 | +.status-badge.abandoned { background:#222; color:#fff; } | |
| 47 | +.status-badge.l2_blocked { background:#fff3cd; color:#856404; } | |
| 48 | +.status-badge.l2_exhausted { background:#f8d7da; color:#721c24; } | |
| 49 | +.status-badge.unknown { background:#fafafa; color:#999; border:1px solid #eee; } | |
| 50 | +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; } | |
| 51 | +.btn:disabled { background:#ddd; color:#999; cursor:not-allowed; } | |
| 52 | +.btn.primary { background:#222; color:#fff; } | |
| 53 | +.btn.signoff { background:#0a6; color:#fff; } | |
| 54 | +.btn.abandon { background:#822; color:#fff; } | |
| 55 | +.btn.link { background:transparent; color:#0a6; padding:6px 0; } | |
| 56 | +.sep { color:#ccc; } | |
| 57 | +</style> | ... | ... |
web/src/components/devloop/FeedbackModal.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="modal-backdrop" @click.self="$emit('close')"> | |
| 3 | + <div class="modal"> | |
| 4 | + <header> | |
| 5 | + <h2>+ NEW REQUIREMENT</h2> | |
| 6 | + <button class="x" @click="$emit('close')">×</button> | |
| 7 | + </header> | |
| 8 | + <p class="hint">提交一段新的需求 / 反馈 → PM 进 incremental 模式,追加 PRD 章节并 bump rev。</p> | |
| 9 | + <textarea | |
| 10 | + v-model="text" | |
| 11 | + :disabled="submitting" | |
| 12 | + placeholder="例如:增加 toggle done 功能;或:把 POST /todos 改成支持 text+priority" | |
| 13 | + /> | |
| 14 | + <div v-if="err" class="err">{{ err }}</div> | |
| 15 | + <div v-if="ok" class="ok">{{ ok }}</div> | |
| 16 | + <footer> | |
| 17 | + <button class="btn cancel" @click="$emit('close')" :disabled="submitting">Cancel</button> | |
| 18 | + <button class="btn submit" @click="submit" :disabled="!text.trim() || submitting">{{ submitting ? '...' : 'Submit' }}</button> | |
| 19 | + </footer> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | +</template> | |
| 23 | + | |
| 24 | +<script setup> | |
| 25 | +import { ref } from 'vue'; | |
| 26 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 27 | +const emit = defineEmits(['close', 'submitted']); | |
| 28 | + | |
| 29 | +const text = ref(''); | |
| 30 | +const submitting = ref(false); | |
| 31 | +const err = ref(''); | |
| 32 | +const ok = ref(''); | |
| 33 | + | |
| 34 | +async function submit() { | |
| 35 | + if (!text.value.trim()) return; | |
| 36 | + submitting.value = true; | |
| 37 | + err.value = ''; ok.value = ''; | |
| 38 | + try { | |
| 39 | + const r = await fetch(`/api/devloop/projects/${props.pid}/feedback`, { | |
| 40 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 41 | + body: JSON.stringify({ text: text.value.trim(), by: 'human' }), | |
| 42 | + }); | |
| 43 | + const j = await r.json(); | |
| 44 | + if (!r.ok) { | |
| 45 | + err.value = j.error || `HTTP ${r.status}`; | |
| 46 | + } else { | |
| 47 | + ok.value = `PRD bumped: rev ${j.prd_rev_before} → ${j.prd_rev_after} · status=${j.status}`; | |
| 48 | + setTimeout(() => emit('submitted'), 800); | |
| 49 | + } | |
| 50 | + } catch (e) { | |
| 51 | + err.value = e.message; | |
| 52 | + } finally { | |
| 53 | + submitting.value = false; | |
| 54 | + } | |
| 55 | +} | |
| 56 | +</script> | |
| 57 | + | |
| 58 | +<style scoped> | |
| 59 | +.modal-backdrop { | |
| 60 | + position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:1000; | |
| 61 | + display:flex; align-items:center; justify-content:center; | |
| 62 | +} | |
| 63 | +.modal { | |
| 64 | + background:#fff; border-radius:8px; width:560px; max-width:90vw; | |
| 65 | + padding:20px 24px; box-shadow:0 8px 32px rgba(0,0,0,.2); | |
| 66 | +} | |
| 67 | +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; } | |
| 68 | +h2 { margin:0; font-size:16px; } | |
| 69 | +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; line-height:1; } | |
| 70 | +.hint { color:#888; font-size:12px; margin:0 0 12px 0; } | |
| 71 | +textarea { | |
| 72 | + width:100%; min-height:120px; padding:10px; box-sizing:border-box; | |
| 73 | + border:1px solid #ccc; border-radius:4px; font-family:inherit; resize:vertical; | |
| 74 | +} | |
| 75 | +.err { color:#c00; font-size:12px; margin-top:8px; } | |
| 76 | +.ok { color:#070; font-size:12px; margin-top:8px; } | |
| 77 | +footer { margin-top:12px; display:flex; justify-content:flex-end; gap:8px; } | |
| 78 | +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; } | |
| 79 | +.btn:disabled { background:#ddd !important; color:#999 !important; cursor:not-allowed; } | |
| 80 | +.btn.cancel { background:#eee; color:#222; } | |
| 81 | +.btn.submit { background:#222; color:#fff; } | |
| 82 | +</style> | ... | ... |
web/src/components/devloop/LiveAgentStream.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="stream"> | |
| 3 | + <div class="bar"> | |
| 4 | + <span class="conn" :class="status">● {{ status }}</span> | |
| 5 | + <span class="lei" v-if="lastEventId">last-event-id: {{ lastEventId }}</span> | |
| 6 | + <span class="grow" /> | |
| 7 | + <button class="btn" @click="clear">clear</button> | |
| 8 | + </div> | |
| 9 | + <div ref="logEl" class="log"> | |
| 10 | + <div v-for="(e, i) in events" :key="i" class="evt"> | |
| 11 | + <span class="ts">{{ formatTs(e.ts) }}</span> | |
| 12 | + <span class="kind">[{{ e.kind }}]</span> | |
| 13 | + <span class="payload">{{ e.payload }}</span> | |
| 14 | + </div> | |
| 15 | + <div v-if="!events.length" class="empty">waiting for events…</div> | |
| 16 | + </div> | |
| 17 | + </div> | |
| 18 | +</template> | |
| 19 | + | |
| 20 | +<script setup> | |
| 21 | +import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'; | |
| 22 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 23 | + | |
| 24 | +const events = ref([]); | |
| 25 | +const status = ref('connecting'); | |
| 26 | +const lastEventId = ref(null); | |
| 27 | +const logEl = ref(null); | |
| 28 | +let es = null; | |
| 29 | +let reconnectTimer = null; | |
| 30 | +const MAX_EVENTS = 250; | |
| 31 | + | |
| 32 | +function connect() { | |
| 33 | + if (es) try { es.close(); } catch {} | |
| 34 | + status.value = 'connecting'; | |
| 35 | + const url = `/api/devloop/projects/${props.pid}/stream`; | |
| 36 | + // EventSource 自带 Last-Event-ID; 用户网络断开自动重连。 | |
| 37 | + // (本应用浏览器会自动从 lastEventId 续传) | |
| 38 | + try { | |
| 39 | + es = new EventSource(url); | |
| 40 | + } catch (e) { | |
| 41 | + status.value = 'error'; | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + es.onopen = () => { status.value = 'connected'; }; | |
| 45 | + es.onerror = () => { | |
| 46 | + status.value = 'reconnecting'; | |
| 47 | + // EventSource 内置 reconnect, 但浏览器不一定保证 5s 内重连; | |
| 48 | + // 手动 5s 重试兜底:断后 5s 关掉重新打开,带 Last-Event-ID 由浏览器自动注入。 | |
| 49 | + try { es.close(); } catch {} | |
| 50 | + es = null; | |
| 51 | + if (reconnectTimer) clearTimeout(reconnectTimer); | |
| 52 | + reconnectTimer = setTimeout(connect, 5000); | |
| 53 | + }; | |
| 54 | + // 监听几个核心事件类型(SSE event name) | |
| 55 | + const listenTo = [ | |
| 56 | + 'project.status', 'project.shipped', 'project.run_started', | |
| 57 | + 'l1.wave_start', 'unit.state', | |
| 58 | + 'integration.run_start', 'integration.result', | |
| 59 | + 'triage.result', 'sdd.updated', 'prd.updated', | |
| 60 | + 'human_intervention_needed', 'project.l2_blocked', 'project.l2_exhausted', | |
| 61 | + 'units.reset', 'bug.opened', 'bug.transition', | |
| 62 | + ]; | |
| 63 | + for (const k of listenTo) { | |
| 64 | + es.addEventListener(k, (ev) => addEvent(k, ev)); | |
| 65 | + } | |
| 66 | + // generic message handler 兜底 | |
| 67 | + es.onmessage = (ev) => addEvent(ev.type || 'message', ev); | |
| 68 | +} | |
| 69 | + | |
| 70 | +function addEvent(kind, ev) { | |
| 71 | + let payload; | |
| 72 | + try { payload = JSON.stringify(JSON.parse(ev.data)); } catch { payload = ev.data; } | |
| 73 | + if (ev.lastEventId) lastEventId.value = ev.lastEventId; | |
| 74 | + events.value.push({ ts: Date.now(), kind, payload }); | |
| 75 | + if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS); | |
| 76 | + nextTick(() => { | |
| 77 | + if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight; | |
| 78 | + }); | |
| 79 | +} | |
| 80 | + | |
| 81 | +function formatTs(ts) { | |
| 82 | + const d = new Date(ts); | |
| 83 | + return d.toTimeString().slice(0, 8); | |
| 84 | +} | |
| 85 | + | |
| 86 | +function clear() { events.value = []; } | |
| 87 | + | |
| 88 | +onMounted(connect); | |
| 89 | +onBeforeUnmount(() => { | |
| 90 | + if (reconnectTimer) clearTimeout(reconnectTimer); | |
| 91 | + if (es) try { es.close(); } catch {} | |
| 92 | +}); | |
| 93 | +</script> | |
| 94 | + | |
| 95 | +<style scoped> | |
| 96 | +.stream { display:flex; flex-direction:column; height:240px; } | |
| 97 | +.bar { display:flex; gap:10px; align-items:center; font-size:12px; color:#666; margin-bottom:6px; } | |
| 98 | +.bar .grow { flex:1; } | |
| 99 | +.conn { font-weight:600; } | |
| 100 | +.conn.connecting { color:#888; } | |
| 101 | +.conn.connected { color:#0a6; } | |
| 102 | +.conn.reconnecting { color:#c70; } | |
| 103 | +.conn.error { color:#c00; } | |
| 104 | +.lei { font-family: ui-monospace, monospace; color:#aaa; } | |
| 105 | +.log { | |
| 106 | + flex:1; overflow-y:auto; | |
| 107 | + background:#0f1115; color:#e6e6e6; | |
| 108 | + font-family: ui-monospace, monospace; font-size:12px; | |
| 109 | + padding:10px; border-radius:6px; | |
| 110 | +} | |
| 111 | +.evt { display:flex; gap:8px; margin-bottom:4px; } | |
| 112 | +.ts { color:#7da; flex:0 0 70px; } | |
| 113 | +.kind { color:#ffce6a; flex:0 0 auto; } | |
| 114 | +.payload { color:#bbd; overflow-wrap:anywhere; flex:1; } | |
| 115 | +.empty { color:#666; } | |
| 116 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; } | |
| 117 | +</style> | ... | ... |
web/src/components/devloop/LoopStatusBar.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="loop-bar"> | |
| 3 | + <div class="phase" :class="{active: phaseAt('docs')}"> | |
| 4 | + <span class="dot" />Docs<small>PRD rev {{ state.prd_rev ?? '-' }} · SDD rev {{ state.sdd_rev ?? '-' }}</small> | |
| 5 | + </div> | |
| 6 | + <div class="arrow">→</div> | |
| 7 | + <div class="phase" :class="{active: phaseAt('l1')}"> | |
| 8 | + <span class="dot" />L1 Units<small>{{ l1Summary }}</small> | |
| 9 | + </div> | |
| 10 | + <div class="arrow">→</div> | |
| 11 | + <div class="phase" :class="{active: phaseAt('l2')}"> | |
| 12 | + <span class="dot" />L2 Integration<small>l2_rounds {{ l2Rounds }}/{{ l2Budget }}</small> | |
| 13 | + </div> | |
| 14 | + <div class="arrow">→</div> | |
| 15 | + <div class="phase" :class="{active: phaseAt('ship')}"> | |
| 16 | + <span class="dot" />Ship<small>{{ statusLabel }}</small> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | +</template> | |
| 20 | + | |
| 21 | +<script setup> | |
| 22 | +import { computed } from 'vue'; | |
| 23 | +const props = defineProps({ state: { type: Object, required: true } }); | |
| 24 | + | |
| 25 | +const status = computed(() => props.state.status || props.state.project?.status || 'unknown'); | |
| 26 | +const units = computed(() => props.state.units || []); | |
| 27 | +const l1Summary = computed(() => { | |
| 28 | + const u = units.value; | |
| 29 | + if (!u.length) return 'no units'; | |
| 30 | + const passed = u.filter(x => x.status === 'passed').length; | |
| 31 | + return `${passed}/${u.length} passed`; | |
| 32 | +}); | |
| 33 | +const l2Rounds = computed(() => props.state.project?.status === 'shipped' ? props.state.units_rev || 0 : props.state.units_rev || 0); | |
| 34 | +const l2Budget = computed(() => 3); | |
| 35 | +const statusLabel = computed(() => status.value); | |
| 36 | + | |
| 37 | +function phaseAt(p) { | |
| 38 | + const s = status.value; | |
| 39 | + if (p === 'docs') return s !== 'unknown' && s !== 'draft'; | |
| 40 | + if (p === 'l1') return ['running', 'shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s); | |
| 41 | + if (p === 'l2') return ['shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s); | |
| 42 | + if (p === 'ship') return ['shipped', 'shipped-final'].includes(s); | |
| 43 | + return false; | |
| 44 | +} | |
| 45 | +</script> | |
| 46 | + | |
| 47 | +<style scoped> | |
| 48 | +.loop-bar { display:flex; align-items:stretch; gap:8px; } | |
| 49 | +.phase { | |
| 50 | + flex:1; padding:12px 16px; border:1px solid #e3e3e8; border-radius:6px; | |
| 51 | + display:flex; flex-direction:column; gap:4px; background:#f9f9fb; | |
| 52 | + font-weight:500; color:#999; | |
| 53 | +} | |
| 54 | +.phase.active { background:#fff; border-color:#0a6; color:#222; } | |
| 55 | +.phase small { font-weight:400; font-size:11px; color:#888; } | |
| 56 | +.phase .dot { | |
| 57 | + width:8px; height:8px; border-radius:50%; background:#ccc; display:inline-block; margin-right:6px; | |
| 58 | +} | |
| 59 | +.phase.active .dot { background:#0a6; } | |
| 60 | +.arrow { display:flex; align-items:center; color:#aaa; font-size:18px; } | |
| 61 | +</style> | ... | ... |
web/src/components/devloop/PrdDiffPanel.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="prd-diff"> | |
| 3 | + <div class="meta"> | |
| 4 | + <span class="rev">PRD rev <b>{{ state?.prd_rev ?? '-' }}</b></span> | |
| 5 | + <span class="rev">SDD rev <b>{{ state?.sdd_rev ?? '-' }}</b></span> | |
| 6 | + <span class="grow" /> | |
| 7 | + <button class="btn" @click="reload" :disabled="loading">{{ loading ? '...' : 'refresh' }}</button> | |
| 8 | + </div> | |
| 9 | + | |
| 10 | + <div v-if="entries.length === 0" class="empty">no changelog yet</div> | |
| 11 | + | |
| 12 | + <ul v-else class="entries"> | |
| 13 | + <li v-for="e in entries.slice(0, 12)" :key="e.ts + ':' + e.kind" :class="['entry', e.kind]"> | |
| 14 | + <span class="kind">{{ e.kind }}</span> | |
| 15 | + <span class="rev-num">rev {{ e.rev }}</span> | |
| 16 | + <span class="trigger">[{{ e.trigger || '?' }}]</span> | |
| 17 | + <span class="summary">{{ e.summary }}</span> | |
| 18 | + </li> | |
| 19 | + </ul> | |
| 20 | + </div> | |
| 21 | +</template> | |
| 22 | + | |
| 23 | +<script setup> | |
| 24 | +import { ref, onMounted, watch } from 'vue'; | |
| 25 | +const props = defineProps({ | |
| 26 | + pid: { type: String, required: true }, | |
| 27 | + state: { type: Object, default: null }, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const entries = ref([]); | |
| 31 | +const loading = ref(false); | |
| 32 | + | |
| 33 | +async function reload() { | |
| 34 | + loading.value = true; | |
| 35 | + try { | |
| 36 | + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`); | |
| 37 | + const j = await r.json(); | |
| 38 | + entries.value = j.entries || []; | |
| 39 | + } catch (e) { | |
| 40 | + /* swallow */ | |
| 41 | + } finally { | |
| 42 | + loading.value = false; | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +onMounted(reload); | |
| 47 | +// Refresh changelog when prd_rev or sdd_rev changes | |
| 48 | +watch(() => props.state?.prd_rev, reload); | |
| 49 | +watch(() => props.state?.sdd_rev, reload); | |
| 50 | +</script> | |
| 51 | + | |
| 52 | +<style scoped> | |
| 53 | +.prd-diff { display:flex; flex-direction:column; gap:8px; } | |
| 54 | +.meta { display:flex; align-items:center; gap:14px; font-size:12px; color:#666; } | |
| 55 | +.meta .grow { flex:1; } | |
| 56 | +.meta .rev b { color:#222; font-size:14px; } | |
| 57 | +.empty { padding:20px; color:#888; text-align:center; } | |
| 58 | +.entries { list-style:none; padding:0; margin:0; max-height:200px; overflow-y:auto; } | |
| 59 | +.entry { | |
| 60 | + display:flex; gap:10px; padding:6px 8px; | |
| 61 | + border-bottom:1px solid #f0f0f3; font-size:13px; | |
| 62 | +} | |
| 63 | +.entry:last-child { border-bottom:none; } | |
| 64 | +.entry .kind { | |
| 65 | + font-family: ui-monospace, monospace; font-size:11px; padding:2px 8px; border-radius:10px; | |
| 66 | + background:#eef; color:#446; flex:0 0 auto; | |
| 67 | +} | |
| 68 | +.entry.sdd_rev .kind { background:#ffeede; color:#a64a00; } | |
| 69 | +.entry.prd_rev .kind { background:#deecff; color:#1d4d99; } | |
| 70 | +.entry .rev-num { font-weight:600; color:#222; flex:0 0 50px; } | |
| 71 | +.entry .trigger { color:#888; font-size:11px; flex:0 0 auto; } | |
| 72 | +.entry .summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; } | |
| 73 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; } | |
| 74 | +.btn:disabled { background:#bbb; } | |
| 75 | +</style> | ... | ... |
web/src/components/devloop/UnitDrawer.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="drawer-backdrop" @click.self="$emit('close')"> | |
| 3 | + <aside class="drawer"> | |
| 4 | + <header> | |
| 5 | + <h2>{{ unitId }}</h2> | |
| 6 | + <button class="x" @click="$emit('close')">×</button> | |
| 7 | + </header> | |
| 8 | + | |
| 9 | + <div v-if="loading" class="loading">loading…</div> | |
| 10 | + <div v-else-if="err" class="err">{{ err }}</div> | |
| 11 | + <div v-else class="body"> | |
| 12 | + <section> | |
| 13 | + <h3>Metadata</h3> | |
| 14 | + <div class="meta"> | |
| 15 | + <div><label>status</label><span :class="['badge', unit?.status]">{{ unit?.status || '?' }}</span></div> | |
| 16 | + <div><label>type</label><span>{{ unit?.type || unit?.kind || '?' }}</span></div> | |
| 17 | + <div><label>retries</label><span>{{ unit?.retries ?? 0 }}/{{ unit?.retry_budget ?? '?' }}</span></div> | |
| 18 | + <div><label>contract</label><span class="hash">{{ unit?.contract_hash || '?' }}</span></div> | |
| 19 | + <div><label>depends_on</label><span>{{ (unit?.depends_on || []).join(', ') || '∅' }}</span></div> | |
| 20 | + </div> | |
| 21 | + </section> | |
| 22 | + | |
| 23 | + <section> | |
| 24 | + <h3>Recent runs ({{ runs.length }})</h3> | |
| 25 | + <ul class="runs"> | |
| 26 | + <li v-for="r in runs" :key="r.run_id" :class="['run', r.result?.ok ? 'ok' : 'fail']"> | |
| 27 | + <div class="row"> | |
| 28 | + <span class="rid">{{ r.run_id }}</span> | |
| 29 | + <span class="dot" :class="{ok: r.result?.ok, fail: r.result?.ok === false, crashed: r.result?.crashed }" /> | |
| 30 | + <span class="sum"> | |
| 31 | + {{ r.result ? `${r.result.summary?.passed || 0}/${r.result.summary?.total || 0}` : 'no result' }} | |
| 32 | + </span> | |
| 33 | + </div> | |
| 34 | + <div v-for="(f, i) in (r.result?.failures || [])" :key="i" class="failure"> | |
| 35 | + <code>{{ f.test || f.case_id }}</code>: {{ f.hint || f.stack?.slice(0, 100) || 'no hint' }} | |
| 36 | + </div> | |
| 37 | + </li> | |
| 38 | + <li v-if="!runs.length" class="empty">no runs yet</li> | |
| 39 | + </ul> | |
| 40 | + </section> | |
| 41 | + | |
| 42 | + <section v-if="coderLog"> | |
| 43 | + <h3>coder.log (tail)</h3> | |
| 44 | + <pre class="coder-log">{{ coderLog }}</pre> | |
| 45 | + </section> | |
| 46 | + </div> | |
| 47 | + </aside> | |
| 48 | + </div> | |
| 49 | +</template> | |
| 50 | + | |
| 51 | +<script setup> | |
| 52 | +import { ref, onMounted } from 'vue'; | |
| 53 | +const props = defineProps({ | |
| 54 | + pid: { type: String, required: true }, | |
| 55 | + unitId: { type: String, required: true }, | |
| 56 | +}); | |
| 57 | +defineEmits(['close']); | |
| 58 | + | |
| 59 | +const loading = ref(true); | |
| 60 | +const err = ref(''); | |
| 61 | +const unit = ref(null); | |
| 62 | +const runs = ref([]); | |
| 63 | +const coderLog = ref(''); | |
| 64 | + | |
| 65 | +onMounted(async () => { | |
| 66 | + try { | |
| 67 | + const r = await fetch(`/api/devloop/projects/${props.pid}/units/${props.unitId}`); | |
| 68 | + if (!r.ok) { err.value = `HTTP ${r.status}`; return; } | |
| 69 | + const j = await r.json(); | |
| 70 | + unit.value = j.unit; | |
| 71 | + runs.value = j.runs || []; | |
| 72 | + coderLog.value = j.coder_log_tail || ''; | |
| 73 | + } catch (e) { | |
| 74 | + err.value = e.message; | |
| 75 | + } finally { | |
| 76 | + loading.value = false; | |
| 77 | + } | |
| 78 | +}); | |
| 79 | +</script> | |
| 80 | + | |
| 81 | +<style scoped> | |
| 82 | +.drawer-backdrop { | |
| 83 | + position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:900; | |
| 84 | + display:flex; justify-content:flex-end; | |
| 85 | +} | |
| 86 | +.drawer { | |
| 87 | + background:#fff; width:520px; max-width:95vw; height:100vh; overflow-y:auto; | |
| 88 | + padding:20px 24px; box-shadow:-8px 0 32px rgba(0,0,0,.15); | |
| 89 | +} | |
| 90 | +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; } | |
| 91 | +h2 { margin:0; font-family: ui-monospace, monospace; } | |
| 92 | +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; } | |
| 93 | +section { margin:18px 0; } | |
| 94 | +h3 { font-size:13px; color:#666; margin:0 0 8px; text-transform:uppercase; } | |
| 95 | +.loading, .err { padding:20px; text-align:center; color:#888; } | |
| 96 | +.err { color:#c00; } | |
| 97 | +.meta { display:grid; grid-template-columns: 110px 1fr; gap:6px 12px; font-size:13px; } | |
| 98 | +.meta label { color:#888; } | |
| 99 | +.meta .hash { font-family: ui-monospace, monospace; font-size:11px; color:#555; } | |
| 100 | +.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase; background:#eee; } | |
| 101 | +.badge.passed { background:#d4edda; color:#155724; } | |
| 102 | +.badge.failed { background:#f8d7da; color:#721c24; } | |
| 103 | +.badge.coding { background:#fff3cd; color:#856404; } | |
| 104 | +.badge.testing { background:#cce5ff; color:#004085; } | |
| 105 | +.badge.pending { background:#eee; color:#666; } | |
| 106 | +.badge.blocked { background:#d6d8db; color:#383d41; } | |
| 107 | +.runs { list-style:none; padding:0; margin:0; } | |
| 108 | +.run { border:1px solid #eee; border-radius:4px; padding:8px 10px; margin-bottom:8px; } | |
| 109 | +.run.ok { background:#fafefa; } | |
| 110 | +.run.fail { background:#fef9f9; } | |
| 111 | +.run .row { display:flex; align-items:center; gap:10px; } | |
| 112 | +.rid { font-family: ui-monospace, monospace; font-size:12px; color:#555; } | |
| 113 | +.dot { width:8px; height:8px; border-radius:50%; background:#ccc; } | |
| 114 | +.dot.ok { background:#0a6; } | |
| 115 | +.dot.fail { background:#c00; } | |
| 116 | +.dot.crashed { background:#822; } | |
| 117 | +.sum { color:#666; font-size:12px; } | |
| 118 | +.failure { margin-top:4px; padding:4px 8px; background:#fff8f8; border-left:2px solid #c00; font-size:11px; color:#444; } | |
| 119 | +.failure code { background:#eee; padding:1px 4px; border-radius:2px; font-size:11px; } | |
| 120 | +.coder-log { background:#0f1115; color:#e6e6e6; padding:10px; border-radius:4px; font-size:11px; max-height:240px; overflow-y:auto; } | |
| 121 | +.empty { color:#888; padding:10px; } | |
| 122 | +</style> | ... | ... |
web/src/components/devloop/UnitsDAG.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="dag-wrap"> | |
| 3 | + <svg v-if="units.length" :viewBox="`0 0 ${W} ${H}`" :width="W" :height="H"> | |
| 4 | + <defs> | |
| 5 | + <marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| 6 | + <path d="M0,0 L10,5 L0,10 z" fill="#999" /> | |
| 7 | + </marker> | |
| 8 | + </defs> | |
| 9 | + <line | |
| 10 | + v-for="(e, i) in edges" | |
| 11 | + :key="'e'+i" | |
| 12 | + :x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2" | |
| 13 | + stroke="#999" stroke-width="1.5" marker-end="url(#arr)" | |
| 14 | + /> | |
| 15 | + <g | |
| 16 | + v-for="(n, i) in nodes" | |
| 17 | + :key="'n'+n.id" | |
| 18 | + :transform="`translate(${n.x},${n.y})`" | |
| 19 | + class="node-g" | |
| 20 | + @click="$emit('select', n.id)" | |
| 21 | + > | |
| 22 | + <rect :width="NW" :height="NH" rx="6" :class="['node', n.status]" /> | |
| 23 | + <text :x="NW/2" :y="20" text-anchor="middle" class="id-label">{{ n.id }}</text> | |
| 24 | + <text :x="NW/2" :y="38" text-anchor="middle" class="status-label">{{ n.status }}</text> | |
| 25 | + </g> | |
| 26 | + </svg> | |
| 27 | + <div v-else class="empty">no units yet</div> | |
| 28 | + <p class="hint">click a unit node to inspect recent runs</p> | |
| 29 | + </div> | |
| 30 | +</template> | |
| 31 | + | |
| 32 | +<script setup> | |
| 33 | +import { computed } from 'vue'; | |
| 34 | +const props = defineProps({ units: { type: Array, default: () => [] } }); | |
| 35 | +defineEmits(['select']); | |
| 36 | + | |
| 37 | +const NW = 130, NH = 52, GAP_X = 40, GAP_Y = 80, PAD = 20; | |
| 38 | + | |
| 39 | +const waves = computed(() => { | |
| 40 | + const remaining = new Set(props.units.map(u => u.id)); | |
| 41 | + const deps = new Map(props.units.map(u => [u.id, (u.depends_on || []).slice()])); | |
| 42 | + const out = []; | |
| 43 | + let safety = 100; | |
| 44 | + while (remaining.size > 0 && safety-- > 0) { | |
| 45 | + const wave = []; | |
| 46 | + for (const id of remaining) { | |
| 47 | + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); | |
| 48 | + } | |
| 49 | + if (!wave.length) break; | |
| 50 | + for (const id of wave) remaining.delete(id); | |
| 51 | + out.push(wave); | |
| 52 | + } | |
| 53 | + return out; | |
| 54 | +}); | |
| 55 | + | |
| 56 | +const nodes = computed(() => { | |
| 57 | + const out = []; | |
| 58 | + waves.value.forEach((wave, wi) => { | |
| 59 | + wave.forEach((id, ni) => { | |
| 60 | + const u = props.units.find(x => x.id === id); | |
| 61 | + out.push({ | |
| 62 | + id, status: u?.status || 'unknown', | |
| 63 | + x: PAD + wi * (NW + GAP_X), | |
| 64 | + y: PAD + ni * (NH + 20), | |
| 65 | + wave: wi, | |
| 66 | + }); | |
| 67 | + }); | |
| 68 | + }); | |
| 69 | + return out; | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const edges = computed(() => { | |
| 73 | + const out = []; | |
| 74 | + for (const u of props.units) { | |
| 75 | + for (const dep of (u.depends_on || [])) { | |
| 76 | + const a = nodes.value.find(n => n.id === dep); | |
| 77 | + const b = nodes.value.find(n => n.id === u.id); | |
| 78 | + if (a && b) out.push({ x1: a.x + NW, y1: a.y + NH/2, x2: b.x, y2: b.y + NH/2 }); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + return out; | |
| 82 | +}); | |
| 83 | + | |
| 84 | +const W = computed(() => Math.max(400, PAD * 2 + waves.value.length * (NW + GAP_X) - GAP_X)); | |
| 85 | +const H = computed(() => { | |
| 86 | + const maxRow = Math.max(...waves.value.map(w => w.length), 1); | |
| 87 | + return PAD * 2 + maxRow * (NH + 20) - 20; | |
| 88 | +}); | |
| 89 | +</script> | |
| 90 | + | |
| 91 | +<style scoped> | |
| 92 | +.dag-wrap svg { display:block; max-width:100%; background:#fafafb; border:1px dashed #eee; border-radius:6px; } | |
| 93 | +.node-g { cursor: pointer; } | |
| 94 | +.node { fill:#fff; stroke:#888; stroke-width:1.5; } | |
| 95 | +.node:hover { stroke:#222; } | |
| 96 | +.node.pending { fill:#f5f5f5; stroke:#888; } | |
| 97 | +.node.coding { fill:#fff3cd; stroke:#856404; } | |
| 98 | +.node.testing { fill:#cce5ff; stroke:#004085; } | |
| 99 | +.node.passed { fill:#d4edda; stroke:#155724; } | |
| 100 | +.node.failed { fill:#f8d7da; stroke:#721c24; } | |
| 101 | +.node.blocked { fill:#d6d8db; stroke:#383d41; } | |
| 102 | +.node.unknown { fill:#fff; stroke:#aaa; } | |
| 103 | +.id-label { font-size:13px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; } | |
| 104 | +.status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; } | |
| 105 | +.empty { padding:24px; color:#888; text-align:center; } | |
| 106 | +.hint { font-size:11px; color:#aaa; margin-top:6px; } | |
| 107 | +</style> | ... | ... |
web/src/components/devloop/ops/BuglistView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="bug-view"> | |
| 3 | + <div class="filters"> | |
| 4 | + <label>Status: | |
| 5 | + <select v-model="filterStatus" @change="reload"> | |
| 6 | + <option value="">all</option> | |
| 7 | + <option>open</option><option>fixing</option> | |
| 8 | + <option>fixed</option><option>verified</option> | |
| 9 | + <option>wontfix</option><option>duplicate</option> | |
| 10 | + </select> | |
| 11 | + </label> | |
| 12 | + <span class="total">Total: {{ bugs.length }}</span> | |
| 13 | + <button class="btn" @click="reload">Refresh</button> | |
| 14 | + </div> | |
| 15 | + | |
| 16 | + <table v-if="bugs.length"> | |
| 17 | + <thead> | |
| 18 | + <tr> | |
| 19 | + <th>ID</th> | |
| 20 | + <th>Status</th> | |
| 21 | + <th>Unit</th> | |
| 22 | + <th>Severity</th> | |
| 23 | + <th>Category</th> | |
| 24 | + <th>Title</th> | |
| 25 | + <th>Updated</th> | |
| 26 | + </tr> | |
| 27 | + </thead> | |
| 28 | + <tbody> | |
| 29 | + <tr v-for="b in bugs" :key="b.id" :class="b.status"> | |
| 30 | + <td class="id">{{ b.id }}</td> | |
| 31 | + <td><span :class="['badge', b.status]">{{ b.status }}</span></td> | |
| 32 | + <td>{{ b.unit_id || '-' }}</td> | |
| 33 | + <td>{{ b.severity || '-' }}</td> | |
| 34 | + <td>{{ b.category || 'unknown' }}</td> | |
| 35 | + <td class="title">{{ b.title }}</td> | |
| 36 | + <td class="ts">{{ formatTs(b.last_updated_at || b.discovered_at) }}</td> | |
| 37 | + </tr> | |
| 38 | + </tbody> | |
| 39 | + </table> | |
| 40 | + <p v-else class="empty">no bugs (or none match filter)</p> | |
| 41 | + </div> | |
| 42 | +</template> | |
| 43 | + | |
| 44 | +<script setup> | |
| 45 | +import { ref, onMounted } from 'vue'; | |
| 46 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 47 | + | |
| 48 | +const bugs = ref([]); | |
| 49 | +const filterStatus = ref(''); | |
| 50 | + | |
| 51 | +async function reload() { | |
| 52 | + const qs = filterStatus.value ? `?status=${filterStatus.value}` : ''; | |
| 53 | + const r = await fetch(`/api/devloop/projects/${props.pid}/bugs${qs}`); | |
| 54 | + if (!r.ok) { bugs.value = []; return; } | |
| 55 | + const j = await r.json(); | |
| 56 | + bugs.value = j.bugs || []; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function formatTs(s) { | |
| 60 | + if (!s) return '-'; | |
| 61 | + try { return new Date(s).toLocaleString(); } catch { return s; } | |
| 62 | +} | |
| 63 | + | |
| 64 | +onMounted(reload); | |
| 65 | +</script> | |
| 66 | + | |
| 67 | +<style scoped> | |
| 68 | +.bug-view {} | |
| 69 | +.filters { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; } | |
| 70 | +.filters select { padding:4px 8px; } | |
| 71 | +.filters .total { color:#666; } | |
| 72 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; } | |
| 73 | +table { width:100%; border-collapse:collapse; font-size:13px; } | |
| 74 | +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; } | |
| 75 | +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; } | |
| 76 | +tr:hover { background:#fafafa; } | |
| 77 | +.id { font-family: ui-monospace, monospace; color:#555; font-size:11px; } | |
| 78 | +.title { color:#222; } | |
| 79 | +.ts { color:#888; font-size:11px; } | |
| 80 | +.badge { | |
| 81 | + display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase; | |
| 82 | +} | |
| 83 | +.badge.open { background:#fef0c8; color:#8d6708; } | |
| 84 | +.badge.fixing { background:#cce5ff; color:#004085; } | |
| 85 | +.badge.fixed { background:#d4edda; color:#155724; } | |
| 86 | +.badge.verified { background:#a3e0b8; color:#0a4a1d; } | |
| 87 | +.badge.wontfix { background:#ddd; color:#444; } | |
| 88 | +.badge.duplicate { background:#eee; color:#666; } | |
| 89 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 90 | +</style> | ... | ... |
web/src/components/devloop/ops/ChangelogView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="changelog-view"> | |
| 3 | + <div class="bar"> | |
| 4 | + <span>Total: {{ entries.length }} changes</span> | |
| 5 | + <span class="grow" /> | |
| 6 | + <button class="btn" @click="reload">Refresh</button> | |
| 7 | + </div> | |
| 8 | + | |
| 9 | + <table v-if="entries.length"> | |
| 10 | + <thead> | |
| 11 | + <tr> | |
| 12 | + <th>When</th> | |
| 13 | + <th>Kind</th> | |
| 14 | + <th>Rev</th> | |
| 15 | + <th>By</th> | |
| 16 | + <th>Trigger</th> | |
| 17 | + <th>Summary</th> | |
| 18 | + </tr> | |
| 19 | + </thead> | |
| 20 | + <tbody> | |
| 21 | + <tr v-for="(e, i) in entries" :key="i"> | |
| 22 | + <td class="ts">{{ formatTs(e.ts) }}</td> | |
| 23 | + <td><span :class="['k', e.kind]">{{ e.kind }}</span></td> | |
| 24 | + <td>{{ e.rev }}</td> | |
| 25 | + <td>{{ e.by || '-' }}</td> | |
| 26 | + <td class="trigger">[{{ e.trigger || '-' }}]</td> | |
| 27 | + <td class="summary">{{ e.summary }}</td> | |
| 28 | + </tr> | |
| 29 | + </tbody> | |
| 30 | + </table> | |
| 31 | + <p v-else class="empty">no changelog entries</p> | |
| 32 | + </div> | |
| 33 | +</template> | |
| 34 | + | |
| 35 | +<script setup> | |
| 36 | +import { ref, onMounted } from 'vue'; | |
| 37 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 38 | +const entries = ref([]); | |
| 39 | + | |
| 40 | +async function reload() { | |
| 41 | + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`); | |
| 42 | + if (!r.ok) return; | |
| 43 | + const j = await r.json(); | |
| 44 | + entries.value = j.entries || []; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function formatTs(s) { | |
| 48 | + if (!s) return '-'; | |
| 49 | + try { return new Date(s).toLocaleString(); } catch { return s; } | |
| 50 | +} | |
| 51 | + | |
| 52 | +onMounted(reload); | |
| 53 | +</script> | |
| 54 | + | |
| 55 | +<style scoped> | |
| 56 | +.changelog-view {} | |
| 57 | +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; } | |
| 58 | +.bar .grow { flex:1; } | |
| 59 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; } | |
| 60 | +table { width:100%; border-collapse:collapse; font-size:13px; } | |
| 61 | +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; } | |
| 62 | +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; } | |
| 63 | +.ts { color:#888; font-size:11px; } | |
| 64 | +.k { | |
| 65 | + display:inline-block; padding:1px 8px; border-radius:10px; | |
| 66 | + font-family: ui-monospace, monospace; font-size:11px; | |
| 67 | +} | |
| 68 | +.k.prd_rev { background:#deecff; color:#1d4d99; } | |
| 69 | +.k.sdd_rev { background:#ffeede; color:#a64a00; } | |
| 70 | +.trigger { color:#888; font-size:11px; } | |
| 71 | +.summary { color:#444; } | |
| 72 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 73 | +</style> | ... | ... |
web/src/components/devloop/ops/LoopLogView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="loops-view"> | |
| 3 | + <div class="bar"> | |
| 4 | + <span>Total: {{ events.length }} events</span> | |
| 5 | + <span class="grow" /> | |
| 6 | + <label>Filter: | |
| 7 | + <input v-model="filterText" placeholder="kind / summary substring" /> | |
| 8 | + </label> | |
| 9 | + <button class="btn" @click="reload">Refresh</button> | |
| 10 | + </div> | |
| 11 | + | |
| 12 | + <section v-for="(grp, key) in groups" :key="key" class="group"> | |
| 13 | + <h3>{{ key }} <span class="count">({{ grp.length }})</span></h3> | |
| 14 | + <ul> | |
| 15 | + <li v-for="e in grp" :key="e.ts + e.kind"> | |
| 16 | + <span class="ts">{{ formatTs(e.ts) }}</span> | |
| 17 | + <span :class="['kind', e.kind.replace(/\./g, '-')]">{{ e.kind }}</span> | |
| 18 | + <span class="src">[{{ e.source || 'system' }}]</span> | |
| 19 | + <span class="summary">{{ e.summary }}</span> | |
| 20 | + </li> | |
| 21 | + </ul> | |
| 22 | + </section> | |
| 23 | + | |
| 24 | + <p v-if="!events.length" class="empty">no timeline events</p> | |
| 25 | + </div> | |
| 26 | +</template> | |
| 27 | + | |
| 28 | +<script setup> | |
| 29 | +import { ref, computed, onMounted } from 'vue'; | |
| 30 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 31 | +const events = ref([]); | |
| 32 | +const filterText = ref(''); | |
| 33 | + | |
| 34 | +async function reload() { | |
| 35 | + const r = await fetch(`/api/devloop/projects/${props.pid}/timeline?limit=500`); | |
| 36 | + if (!r.ok) return; | |
| 37 | + const j = await r.json(); | |
| 38 | + events.value = j.events || []; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const filtered = computed(() => { | |
| 42 | + const q = filterText.value.toLowerCase().trim(); | |
| 43 | + if (!q) return events.value; | |
| 44 | + return events.value.filter(e => | |
| 45 | + (e.kind || '').toLowerCase().includes(q) || | |
| 46 | + (e.summary || '').toLowerCase().includes(q)); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +const groups = computed(() => { | |
| 50 | + const out = { 'L1 (unit)': [], 'L2 (integration / triage)': [], 'L3 (human / status)': [], 'System / Docs / Git': [] }; | |
| 51 | + for (const e of filtered.value) { | |
| 52 | + const k = e.kind || ''; | |
| 53 | + if (/^unit\.|^L1/.test(k)) out['L1 (unit)'].push(e); | |
| 54 | + else if (/integration|triage|sdd\.|units\.reset|l2_/.test(k)) out['L2 (integration / triage)'].push(e); | |
| 55 | + else if (/^project\.status|^project\.shipped|^project\.abandoned|human_intervention|prd\.updated/.test(k)) out['L3 (human / status)'].push(e); | |
| 56 | + else out['System / Docs / Git'].push(e); | |
| 57 | + } | |
| 58 | + return out; | |
| 59 | +}); | |
| 60 | + | |
| 61 | +function formatTs(ts) { | |
| 62 | + if (!ts) return '-'; | |
| 63 | + try { return new Date(ts).toLocaleTimeString(); } catch { return String(ts); } | |
| 64 | +} | |
| 65 | + | |
| 66 | +onMounted(reload); | |
| 67 | +</script> | |
| 68 | + | |
| 69 | +<style scoped> | |
| 70 | +.loops-view {} | |
| 71 | +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; } | |
| 72 | +.bar .grow { flex:1; } | |
| 73 | +.bar input { padding:4px 8px; border:1px solid #ccc; border-radius:4px; width:200px; } | |
| 74 | +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; } | |
| 75 | +.group { margin-bottom:18px; } | |
| 76 | +.group h3 { font-size:12px; color:#888; margin:0 0 6px; text-transform:uppercase; letter-spacing:0.5px; } | |
| 77 | +.group h3 .count { color:#bbb; font-weight:400; } | |
| 78 | +ul { list-style:none; padding:0; margin:0; } | |
| 79 | +li { | |
| 80 | + display:flex; gap:8px; padding:5px 8px; font-size:12px; | |
| 81 | + border-bottom:1px solid #f5f5f7; | |
| 82 | +} | |
| 83 | +li:hover { background:#fafafa; } | |
| 84 | +.ts { color:#888; flex:0 0 100px; font-family: ui-monospace, monospace; } | |
| 85 | +.kind { | |
| 86 | + flex:0 0 auto; font-family: ui-monospace, monospace; font-size:11px; | |
| 87 | + padding:1px 6px; border-radius:4px; background:#eef; color:#446; | |
| 88 | +} | |
| 89 | +.kind.unit-state { background:#cce5ff; color:#004085; } | |
| 90 | +.kind.integration-result { background:#ffeede; color:#a64a00; } | |
| 91 | +.kind.triage-result { background:#fde6f5; color:#8b1d72; } | |
| 92 | +.kind.sdd-updated, .kind.prd-updated { background:#ffd; color:#7a6b00; } | |
| 93 | +.kind.project-shipped { background:#d4edda; color:#155724; } | |
| 94 | +.kind.project-status { background:#eee; color:#444; } | |
| 95 | +.src { color:#999; font-size:11px; flex:0 0 auto; } | |
| 96 | +.summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; } | |
| 97 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 98 | +</style> | ... | ... |
web/src/components/devloop/ops/ProgressView.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="progress-view"> | |
| 3 | + <div v-if="!state" class="empty">loading…</div> | |
| 4 | + <div v-else class="cards"> | |
| 5 | + <div class="card big"> | |
| 6 | + <label>Project Status</label> | |
| 7 | + <div class="value"> | |
| 8 | + <span :class="['badge', currentStatus]">{{ currentStatus }}</span> | |
| 9 | + </div> | |
| 10 | + <div class="meta">created {{ formatTs(state.project?.created_at) }}</div> | |
| 11 | + </div> | |
| 12 | + | |
| 13 | + <div class="card"> | |
| 14 | + <label>PRD rev</label> | |
| 15 | + <div class="value mono">{{ state.prd_rev ?? '-' }}</div> | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <div class="card"> | |
| 19 | + <label>SDD rev</label> | |
| 20 | + <div class="value mono">{{ state.sdd_rev ?? '-' }}</div> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <div class="card"> | |
| 24 | + <label>Units progress</label> | |
| 25 | + <div class="value">{{ unitsPassed }}/{{ units.length }} passed</div> | |
| 26 | + <div class="bar"><div :style="{ width: pctPassed + '%' }" class="fill" /></div> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <div class="card"> | |
| 30 | + <label>L2 rounds</label> | |
| 31 | + <div class="value">{{ l2Rounds }} / {{ l2Budget }}</div> | |
| 32 | + <div class="bar"><div :style="{ width: pctL2 + '%' }" class="fill l2" /></div> | |
| 33 | + </div> | |
| 34 | + | |
| 35 | + <div class="card"> | |
| 36 | + <label>Bugs (open)</label> | |
| 37 | + <div class="value">{{ bugsOpen }} <small>/ {{ bugsTotal }} total</small></div> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <h3>Per-unit breakdown</h3> | |
| 42 | + <table v-if="units.length"> | |
| 43 | + <thead> | |
| 44 | + <tr><th>Unit</th><th>Status</th><th>Retries</th><th>Contract hash</th></tr> | |
| 45 | + </thead> | |
| 46 | + <tbody> | |
| 47 | + <tr v-for="u in units" :key="u.id"> | |
| 48 | + <td class="mono">{{ u.id }}</td> | |
| 49 | + <td><span :class="['badge', u.status]">{{ u.status }}</span></td> | |
| 50 | + <td>{{ u.retries ?? 0 }} / {{ u.retry_budget ?? '-' }}</td> | |
| 51 | + <td class="mono hash">{{ u.contract_hash || '-' }}</td> | |
| 52 | + </tr> | |
| 53 | + </tbody> | |
| 54 | + </table> | |
| 55 | + </div> | |
| 56 | +</template> | |
| 57 | + | |
| 58 | +<script setup> | |
| 59 | +import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; | |
| 60 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 61 | + | |
| 62 | +const state = ref(null); | |
| 63 | +const bugs = ref([]); | |
| 64 | +let timer = null; | |
| 65 | + | |
| 66 | +async function load() { | |
| 67 | + try { | |
| 68 | + const [sR, bR] = await Promise.all([ | |
| 69 | + fetch(`/api/devloop/projects/${props.pid}/state`), | |
| 70 | + fetch(`/api/devloop/projects/${props.pid}/bugs`), | |
| 71 | + ]); | |
| 72 | + if (sR.ok) state.value = await sR.json(); | |
| 73 | + if (bR.ok) bugs.value = (await bR.json()).bugs || []; | |
| 74 | + } catch { /* ignore */ } | |
| 75 | +} | |
| 76 | + | |
| 77 | +const units = computed(() => state.value?.units || []); | |
| 78 | +const unitsPassed = computed(() => units.value.filter(u => u.status === 'passed').length); | |
| 79 | +const pctPassed = computed(() => units.value.length ? Math.round(unitsPassed.value / units.value.length * 100) : 0); | |
| 80 | +const currentStatus = computed(() => state.value?.status || state.value?.project?.status || 'unknown'); | |
| 81 | +const l2Rounds = computed(() => { | |
| 82 | + // pull from units.json via the existing /state endpoint structure | |
| 83 | + // state.units_rev exists but rounds field needs separate fetch; for now read project.json if surfaced | |
| 84 | + return state.value?.project?.l2_rounds ?? 0; | |
| 85 | +}); | |
| 86 | +const l2Budget = computed(() => 3); | |
| 87 | +const pctL2 = computed(() => Math.min(100, (l2Rounds.value / l2Budget.value) * 100)); | |
| 88 | +const bugsOpen = computed(() => bugs.value.filter(b => b.status === 'open' || b.status === 'fixing').length); | |
| 89 | +const bugsTotal = computed(() => bugs.value.length); | |
| 90 | + | |
| 91 | +function formatTs(s) { if (!s) return '-'; try { return new Date(s).toLocaleString(); } catch { return s; } } | |
| 92 | + | |
| 93 | +onMounted(() => { | |
| 94 | + load(); | |
| 95 | + timer = setInterval(load, 3000); | |
| 96 | +}); | |
| 97 | +onBeforeUnmount(() => { if (timer) clearInterval(timer); }); | |
| 98 | +</script> | |
| 99 | + | |
| 100 | +<style scoped> | |
| 101 | +.progress-view {} | |
| 102 | +.cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:14px; } | |
| 103 | +.card { background:#fafafb; border:1px solid #e7e7eb; border-radius:8px; padding:14px 16px; } | |
| 104 | +.card.big { grid-column: 1 / -1; } | |
| 105 | +.card label { font-size:11px; text-transform:uppercase; color:#888; } | |
| 106 | +.card .value { font-size:22px; font-weight:600; margin-top:4px; } | |
| 107 | +.card .value small { font-size:13px; color:#888; font-weight:400; } | |
| 108 | +.card .meta { font-size:11px; color:#aaa; } | |
| 109 | +.mono { font-family: ui-monospace, monospace; } | |
| 110 | +.bar { width:100%; height:6px; background:#eee; border-radius:3px; margin-top:6px; overflow:hidden; } | |
| 111 | +.fill { height:6px; background:#0a6; border-radius:3px; transition: width .3s; } | |
| 112 | +.fill.l2 { background:#a64a00; } | |
| 113 | +.empty { padding:30px; text-align:center; color:#888; } | |
| 114 | +h3 { font-size:13px; color:#666; margin:24px 0 8px; text-transform:uppercase; } | |
| 115 | +table { width:100%; border-collapse:collapse; font-size:13px; } | |
| 116 | +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; } | |
| 117 | +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; } | |
| 118 | +.hash { font-size:11px; color:#666; } | |
| 119 | +.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:11px; text-transform:uppercase; font-weight:600; } | |
| 120 | +.badge.draft { background:#eee; color:#666; } | |
| 121 | +.badge.running { background:#cce5ff; color:#004085; } | |
| 122 | +.badge.shipped { background:#d4edda; color:#155724; } | |
| 123 | +.badge.shipped-final { background:#0a6; color:#fff; } | |
| 124 | +.badge.abandoned { background:#222; color:#fff; } | |
| 125 | +.badge.l2_blocked { background:#fff3cd; color:#856404; } | |
| 126 | +.badge.l2_exhausted { background:#f8d7da; color:#721c24; } | |
| 127 | +.badge.pending { background:#eee; color:#666; } | |
| 128 | +.badge.coding { background:#fff3cd; color:#856404; } | |
| 129 | +.badge.testing { background:#cce5ff; color:#004085; } | |
| 130 | +.badge.passed { background:#d4edda; color:#155724; } | |
| 131 | +.badge.failed { background:#f8d7da; color:#721c24; } | |
| 132 | +.badge.blocked { background:#d6d8db; color:#383d41; } | |
| 133 | +.badge.unknown { background:#fafafa; color:#888; } | |
| 134 | +</style> | ... | ... |
web/src/router/index.js
| ... | ... | @@ -69,6 +69,19 @@ const routes = [ |
| 69 | 69 | name: 'QuotaMonitor', |
| 70 | 70 | component: () => import('@/views/QuotaMonitor.vue'), |
| 71 | 71 | }, |
| 72 | + // P5 · DevLoop Dashboard + Ops | |
| 73 | + { | |
| 74 | + path: '/devloop/:pid', | |
| 75 | + name: 'DevLoopDashboard', | |
| 76 | + component: () => import('@/views/DevLoopDashboard.vue'), | |
| 77 | + props: true, | |
| 78 | + }, | |
| 79 | + { | |
| 80 | + path: '/devloop/:pid/ops/:tab(bugs|loops|changelog|progress)', | |
| 81 | + name: 'DevLoopOps', | |
| 82 | + component: () => import('@/views/DevLoopOps.vue'), | |
| 83 | + props: true, | |
| 84 | + }, | |
| 72 | 85 | ] |
| 73 | 86 | |
| 74 | 87 | const router = createRouter({ | ... | ... |
web/src/views/DevLoopDashboard.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="devloop-dashboard"> | |
| 3 | + <DashboardHeader | |
| 4 | + :pid="pid" | |
| 5 | + :state="state" | |
| 6 | + @ops="goOps" | |
| 7 | + @feedback="openFeedback" | |
| 8 | + @signoff="onSignOff" | |
| 9 | + @abandon="onAbandon" | |
| 10 | + /> | |
| 11 | + | |
| 12 | + <div v-if="error" class="error-banner">{{ error }}</div> | |
| 13 | + | |
| 14 | + <div v-if="state" class="grid"> | |
| 15 | + <section class="loop-status"> | |
| 16 | + <LoopStatusBar :state="state" /> | |
| 17 | + </section> | |
| 18 | + | |
| 19 | + <section class="dag"> | |
| 20 | + <h2>Units DAG</h2> | |
| 21 | + <UnitsDAG | |
| 22 | + :units="state.units || []" | |
| 23 | + @select="onUnitClick" | |
| 24 | + /> | |
| 25 | + </section> | |
| 26 | + | |
| 27 | + <section class="live"> | |
| 28 | + <h2>Live agent stream</h2> | |
| 29 | + <LiveAgentStream :pid="pid" /> | |
| 30 | + </section> | |
| 31 | + | |
| 32 | + <section class="diff"> | |
| 33 | + <h2>PRD changes</h2> | |
| 34 | + <PrdDiffPanel :pid="pid" :state="state" /> | |
| 35 | + </section> | |
| 36 | + </div> | |
| 37 | + | |
| 38 | + <UnitDrawer | |
| 39 | + v-if="drawerUnit" | |
| 40 | + :pid="pid" | |
| 41 | + :unit-id="drawerUnit" | |
| 42 | + @close="drawerUnit = null" | |
| 43 | + /> | |
| 44 | + | |
| 45 | + <FeedbackModal | |
| 46 | + v-if="showFeedback" | |
| 47 | + :pid="pid" | |
| 48 | + @close="showFeedback = false" | |
| 49 | + @submitted="onFeedbackSubmitted" | |
| 50 | + /> | |
| 51 | + </div> | |
| 52 | +</template> | |
| 53 | + | |
| 54 | +<script setup> | |
| 55 | +import { ref, onMounted, onBeforeUnmount } from 'vue'; | |
| 56 | +import { useRouter } from 'vue-router'; | |
| 57 | +import DashboardHeader from '@/components/devloop/DashboardHeader.vue'; | |
| 58 | +import LoopStatusBar from '@/components/devloop/LoopStatusBar.vue'; | |
| 59 | +import UnitsDAG from '@/components/devloop/UnitsDAG.vue'; | |
| 60 | +import LiveAgentStream from '@/components/devloop/LiveAgentStream.vue'; | |
| 61 | +import PrdDiffPanel from '@/components/devloop/PrdDiffPanel.vue'; | |
| 62 | +import FeedbackModal from '@/components/devloop/FeedbackModal.vue'; | |
| 63 | +import UnitDrawer from '@/components/devloop/UnitDrawer.vue'; | |
| 64 | + | |
| 65 | +const props = defineProps({ pid: { type: String, required: true } }); | |
| 66 | +const router = useRouter(); | |
| 67 | + | |
| 68 | +const state = ref(null); | |
| 69 | +const error = ref(''); | |
| 70 | +const showFeedback = ref(false); | |
| 71 | +const drawerUnit = ref(null); | |
| 72 | +let pollTimer = null; | |
| 73 | + | |
| 74 | +async function fetchState() { | |
| 75 | + try { | |
| 76 | + const r = await fetch(`/api/devloop/projects/${props.pid}/state`); | |
| 77 | + if (!r.ok) { | |
| 78 | + error.value = r.status === 404 ? `项目不存在: ${props.pid}` : `读 state 失败: ${r.status}`; | |
| 79 | + state.value = null; | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + error.value = ''; | |
| 83 | + state.value = await r.json(); | |
| 84 | + } catch (e) { | |
| 85 | + error.value = e.message; | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +function goOps(tab = 'bugs') { | |
| 90 | + router.push(`/devloop/${props.pid}/ops/${tab}`); | |
| 91 | +} | |
| 92 | + | |
| 93 | +function openFeedback() { showFeedback.value = true; } | |
| 94 | + | |
| 95 | +function onUnitClick(unitId) { drawerUnit.value = unitId; } | |
| 96 | + | |
| 97 | +async function onSignOff() { | |
| 98 | + if (!confirm('确认 Sign Off? 此后将无法再提交反馈。')) return; | |
| 99 | + const r = await fetch(`/api/devloop/projects/${props.pid}/sign-off`, { | |
| 100 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 101 | + body: JSON.stringify({ by: 'human' }), | |
| 102 | + }); | |
| 103 | + const j = await r.json(); | |
| 104 | + if (!r.ok) alert('Sign off failed: ' + (j.error || r.status)); | |
| 105 | + await fetchState(); | |
| 106 | +} | |
| 107 | + | |
| 108 | +async function onAbandon() { | |
| 109 | + const reason = prompt('Abandon 原因 (可空):', 'no longer needed'); | |
| 110 | + if (reason === null) return; | |
| 111 | + const r = await fetch(`/api/devloop/projects/${props.pid}/abandon`, { | |
| 112 | + method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 113 | + body: JSON.stringify({ by: 'human', reason }), | |
| 114 | + }); | |
| 115 | + const j = await r.json(); | |
| 116 | + if (!r.ok) alert('Abandon failed: ' + (j.error || r.status)); | |
| 117 | + await fetchState(); | |
| 118 | +} | |
| 119 | + | |
| 120 | +function onFeedbackSubmitted() { showFeedback.value = false; fetchState(); } | |
| 121 | + | |
| 122 | +onMounted(async () => { | |
| 123 | + await fetchState(); | |
| 124 | + pollTimer = setInterval(fetchState, 2500); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +onBeforeUnmount(() => { | |
| 128 | + if (pollTimer) clearInterval(pollTimer); | |
| 129 | +}); | |
| 130 | +</script> | |
| 131 | + | |
| 132 | +<style scoped> | |
| 133 | +.devloop-dashboard { max-width: 1280px; margin: 0 auto; padding: 16px 24px; } | |
| 134 | +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:8px 0; } | |
| 135 | +.grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; } | |
| 136 | +.grid section { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:16px; min-height: 200px; } | |
| 137 | +.grid section.live, .grid section.diff { min-height: 280px; } | |
| 138 | +.grid h2 { font-size:14px; font-weight:600; margin:0 0 8px 0; color:#555; } | |
| 139 | +.loop-status, .dag { grid-column: 1 / -1; } | |
| 140 | +@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } } | |
| 141 | +</style> | ... | ... |
web/src/views/DevLoopOps.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="devloop-ops"> | |
| 3 | + <header class="hdr"> | |
| 4 | + <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link> | |
| 5 | + <h1>Ops · {{ pid }}</h1> | |
| 6 | + </header> | |
| 7 | + | |
| 8 | + <nav class="tabs"> | |
| 9 | + <router-link | |
| 10 | + v-for="t in tabs" | |
| 11 | + :key="t.id" | |
| 12 | + :to="`/devloop/${pid}/ops/${t.id}`" | |
| 13 | + :class="['tab', { active: t.id === tab }]" | |
| 14 | + >{{ t.label }}</router-link> | |
| 15 | + </nav> | |
| 16 | + | |
| 17 | + <component :is="activeView" :pid="pid" /> | |
| 18 | + </div> | |
| 19 | +</template> | |
| 20 | + | |
| 21 | +<script setup> | |
| 22 | +import { computed } from 'vue'; | |
| 23 | +import BuglistView from '@/components/devloop/ops/BuglistView.vue'; | |
| 24 | +import LoopLogView from '@/components/devloop/ops/LoopLogView.vue'; | |
| 25 | +import ChangelogView from '@/components/devloop/ops/ChangelogView.vue'; | |
| 26 | +import ProgressView from '@/components/devloop/ops/ProgressView.vue'; | |
| 27 | + | |
| 28 | +const props = defineProps({ | |
| 29 | + pid: { type: String, required: true }, | |
| 30 | + tab: { type: String, required: true }, | |
| 31 | +}); | |
| 32 | + | |
| 33 | +const tabs = [ | |
| 34 | + { id: 'bugs', label: 'Bugs' }, | |
| 35 | + { id: 'loops', label: 'Loops (Timeline)' }, | |
| 36 | + { id: 'changelog', label: 'Changelog' }, | |
| 37 | + { id: 'progress', label: 'Progress' }, | |
| 38 | +]; | |
| 39 | + | |
| 40 | +const viewMap = { | |
| 41 | + bugs: BuglistView, | |
| 42 | + loops: LoopLogView, | |
| 43 | + changelog: ChangelogView, | |
| 44 | + progress: ProgressView, | |
| 45 | +}; | |
| 46 | +const activeView = computed(() => viewMap[props.tab] || BuglistView); | |
| 47 | +</script> | |
| 48 | + | |
| 49 | +<style scoped> | |
| 50 | +.devloop-ops { max-width: 1280px; margin: 0 auto; padding: 16px 24px; } | |
| 51 | +.hdr { display:flex; align-items:center; gap:14px; padding-bottom:8px; border-bottom:1px solid #eee; } | |
| 52 | +.back { color:#888; text-decoration:none; font-size:13px; } | |
| 53 | +.back:hover { color:#222; } | |
| 54 | +h1 { font-size:18px; margin:0; font-family: ui-monospace, monospace; } | |
| 55 | +.tabs { display:flex; gap:0; margin:16px 0; border-bottom:1px solid #e3e3e8; } | |
| 56 | +.tab { | |
| 57 | + padding:8px 16px; text-decoration:none; color:#666; border-bottom:2px solid transparent; | |
| 58 | + font-size:13px; font-weight:500; | |
| 59 | +} | |
| 60 | +.tab:hover { color:#222; } | |
| 61 | +.tab.active { color:#0a6; border-bottom-color:#0a6; } | |
| 62 | +</style> | ... | ... |