From 8531fd61d4e65618ad8f280900e12ba303d77988 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:02:02 +0800 Subject: [PATCH 01/20] =?UTF-8?q?UI-SP2=20spec=20=E2=80=94=20download-mana?= =?UTF-8?q?ger=20Task=20Detail=20(4=20additive=20read=20endpoints=20+=20re?= =?UTF-8?q?designed=20page)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack additive: zero migration (all columns exist), honors UI-SP1 locked decisions (useLiveResource single seam, DataBoundary, el-table-v2, no new dep), implements the already-declared SourceAllocation/TaskEvent contract + adds subtask-chunks/participating-executors. Client-derived speed/ETA; canvas matrix / retry-upgrade / SSE deferred & documented. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-20-ui-sp2-task-detail-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-ui-sp2-task-detail-design.md diff --git a/docs/superpowers/specs/2026-05-20-ui-sp2-task-detail-design.md b/docs/superpowers/specs/2026-05-20-ui-sp2-task-detail-design.md new file mode 100644 index 0000000..e67fe35 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-ui-sp2-task-detail-design.md @@ -0,0 +1,207 @@ +# UI-SP2 — Download-Manager-Grade Task Detail (Design) + +> Sub-project 2 of the Web UI decomposition (UI-SP1 merged PR #19 `8203c58`). +> Status: design self-approved per project Rule #1 (autonomous; recommended/conservative at every fork). +> Branch: `feat/ui-sp2-task-detail`. + +## 1. Context & Scope + +UI-SP1 shipped the app shell, auth, Dashboard, Task List, Task Create — **frontend-only**, on the +~11 existing v1 endpoints. It deliberately left `/tasks/:id` as a thin scaffold and recorded that +"UI-SP2 upgrades it" and that **UI-SP2 is the sub-project that adds backend read-endpoints**. + +UI-SP2 is **full-stack but additive**: it adds four read-only `GET` endpoints over the *existing* +schema (verified: **zero Alembic migration** — every column already exists) and rebuilds +`/tasks/:id` into a download-accelerator-grade detail view. + +**In scope** + +- Backend: 4 additive read-only endpoints (RBAC + tenant-scoped, contract-faithful): + 1. `GET /api/v1/tasks/{id}/subtask-chunks` — per-file chunk segments (NEW path in contract) + 2. `GET /api/v1/tasks/{id}/source-allocation` — per-source contribution + chunk routing + (**contract already declares** `getSourceAllocation` + `SourceAllocation` schema — implement to match) + 3. `GET /api/v1/tasks/{id}/participating-executors` — executor swimlanes (NEW path in contract) + 4. `GET /api/v1/tasks/{id}/events` — audit-derived event log, paginated + (**contract already declares** `getTaskEvents` + `TaskEvent` + Limit/Cursor — implement to match) +- Frontend: redesigned `/tasks/:id` — header (basic info + aggregate progress ring + client-derived + speed/ETA + cancel/delete) and an `el-tabs` body: **Files & Chunks** (virtualized `el-table-v2` + with inline segmented chunk bars), **Source Allocation** (per-source segmented bar + table), + **Executors** (swimlane rows), **Event Log** (level filter + cursor pagination). +- An additive, view-transparent `enabled` option on `useLiveResource` so inactive tabs pause polling. + +**Out of scope (deferred, documented)** + +- Live byte-rate from the backend (no speed column exists). Speed/ETA are **client-derived** from + successive `bytes_downloaded` polls. Documented limitation. +- Retry / pause / upgrade actions (no endpoints exist). Only cancel + delete are wired (UI-SP1 endpoints). +- Real event emission / per-chunk event rows. Event Log reads existing `audit_log` rows only + (task.created / task.cancelled / quota.exceeded / permission_denied / subtask.* if present). +- SSE/WS push (UI-SP5). `useLiveResource` stays the single live seam; SP5 swaps internals view-free. +- ECharts. All visuals are inline SVG / Element Plus (no new runtime dep). +- A 163-cell canvas file matrix (v2.0 §3.3 Panel 4). The virtualized chunk-segmented table conveys + the same per-file state richly; the canvas matrix is YAGNI-trimmed for SP2. + +## 2. Inherited Locked Decisions (from UI-SP1 §8 — binding on SP2) + +- `useLiveResource` is the **only** realtime seam; views never touch `vue-query` directly. +- `DataBoundary` wraps every view (loading skeleton / empty / error / forbidden). +- `el-table-v2` (ships in Element Plus 2.8.4 — **no new dep**) is the SP2 virtualization primitive. +- 9 status semantic colors from `styles/tokens.scss`; never color-only (icon + label + color); ≥4.5:1. +- Tenant/role from JWT via `stores/session.ts` (read-only chip); RBAC server-side. +- Additive philosophy: frontend stays in `frontend/**`; backend changes are additive read endpoints + + `api/openapi.yaml` + Pydantic DTOs only — **no** schema/alembic/existing-route changes. +- Pass existing CI only (`frontend-lint`, `frontend-build`, backend `pytest`, `OpenAPI`, + `Invariant`); **no new CI gate**. +- i18n: `en-US.json` + `zh-CN.json` must stay at exact key parity. + +## 3. Backend Design + +### 3.1 Contract (`api/openapi.yaml`) + +The static contract is the documented intent and is CI-linted by `spectral lint --fail-severity=error` ++ `swagger-cli validate` (CI does **not** diff it against the runtime app — but the project lesson is +contract-faithful). Servers basePath is `/api/v2` in the static doc; **runtime FastAPI routes use the +existing `/api/v1/tasks` prefix** (consistent with every implemented route). We: + +- Implement `getSourceAllocation` and `getTaskEvents` to **exactly match the already-declared + schemas** (`SourceAllocation`, `TaskEvent`, `Limit`, `Cursor`, `next_cursor`). No contract churn. +- **Add** two paths + schemas, in the existing style (path under `# Tasks` section, `tags: [tasks]`, + camelCase `operationId`, `$ref: '#/components/parameters/TaskId'`, `'200'` + reuse existing + `Unauthenticated`/`RbacDenied` response refs where the file already uses them): + - `/tasks/{taskId}/subtask-chunks` → `getSubtaskChunks` → `SubtaskChunkReport` + - `/tasks/{taskId}/participating-executors` → `getParticipatingExecutors` → `ParticipatingExecutors` + +### 3.2 Endpoints (router: existing `src/dlw/api/tasks.py`, `prefix="/api/v1/tasks"`) + +Every endpoint follows the **proven cancel-pattern tenant gate** (tasks.py:143–148): resolve +`DownloadTask.id` via `tenant_filtered(select(DownloadTask.id).where(id==task_id), DownloadTask, +principal)`; `None` → `HTTPException(404, "task not found")` (cross-tenant must 404, never leak). +Auth: `Depends(require_perm("/api/v1/tasks*", "GET"))`. Session: `Depends(_session)`. Sub-resource +queries are then joined through `FileSubTask.task_id == task_id` and also filtered by +`FileSubTask.tenant_id == principal.tenant_id` (defence-in-depth; `file_subtasks.tenant_id` is denormalized). + +| Endpoint | Source columns | Response DTO (Pydantic, `from_attributes` where ORM-mapped) | +|---|---|---| +| `GET /{id}/subtask-chunks` | `file_subtasks`(id, filename, file_size, status, bytes_downloaded, is_chunked, chunks_total, chunks_completed) + `subtask_chunks`(chunk_index, byte_start, byte_end, source_id, status, bytes_done) | `SubtaskChunkReport{ items: [ SubtaskChunkRow{ subtask_id, filename, file_size\|None, status, bytes_downloaded, is_chunked, chunks_total\|None, chunks_completed, chunks: [ ChunkSeg{ chunk_index, byte_start, byte_end, source_id, status, bytes_done } ] } ] }` | +| `GET /{id}/source-allocation` | `file_subtasks`(source_id, file_size, bytes_downloaded) + `subtask_chunks`(source_id, byte_start, byte_end, bytes_done) | `SourceAllocation{ task_id, sources_used:[{ source_id, bytes_assigned, percent, measured_speed_bps }], chunk_level_routing:[{ filename, chunks:[{ chunk_index, byte_start, byte_end, source_id, status, bytes_done }] }] }` — **matches existing contract schema**. `measured_speed_bps` = `0.0` (no live speed source; documented; field kept for contract fidelity). `percent` = source bytes ÷ task total bytes ×100. `chunk_level_routing` only for `is_chunked` files. | +| `GET /{id}/participating-executors` | `file_subtasks`(executor_id, status, bytes_downloaded, assigned_at, last_heartbeat_seen_at) + `executors`(id, status, health_score, last_heartbeat_at) | `ParticipatingExecutors{ items:[ ParticipatingExecutor{ executor_id, executor_status\|None, health_score\|None, last_heartbeat_at\|None, assigned_subtasks, active_subtasks, bytes_downloaded } ] }` — left-join executors (a subtask may reference an executor row that was pruned → null exec fields, still listed). | +| `GET /{id}/events` | `audit_log`(occurred_at, action, resource_type, resource_id, outcome, payload) where `tenant_id==principal.tenant_id` and (`resource_type='task'` and `resource_id==str(task_id)`) or (`resource_type='subtask'` and `resource_id` in this task's subtask ids) | `{ items:[ TaskEvent{ ts, type, message, details } ], next_cursor: str\|None }` — **matches existing contract**. `type`=`action`; `message` synthesized (`outcome=='denied'`→prefix); `details`=`payload or {}`. Cursor = opaque base64 of `occurred_at` iso + `id` (stable order `occurred_at DESC, id DESC`); `Limit` default 50, max 200 (reuse contract `Limit`/`Cursor` params). | + +DTOs live in a new `src/dlw/schemas/task_detail.py` (keeps `schemas/task.py` focused; imported by +the router). All `int64` byte fields are Python `int`. Datetimes serialize ISO-8601 (Pydantic default). + +### 3.3 Service layer + +Read aggregation is thin; put query helpers in a new `src/dlw/services/task_detail.py` +(`async def chunks_for_task / source_allocation_for_task / executors_for_task / events_for_task`, +each takes `(session, task_id, tenant_id, ...)`, returns DTO-ready data). Router stays declarative +(tenant gate → service call → DTO). No write paths, no state machine, no audit writes. + +### 3.4 Backend tests (`tests/api/`) + +One file per endpoint (`test_task_detail_chunks.py`, `_source_alloc.py`, `_executors.py`, +`_events.py`), module-scoped bootstrap mirroring `tests/api/test_tasks.py`. Each: **happy path** +(seed task + subtasks [+ chunks/executor/audit rows], assert shape & aggregation), +**cross-tenant → 404**, **unauthenticated → 401**, plus one aggregation-correctness assertion +(e.g. `percent` sums ≈100; events cursor paginates; terminal task still returns rows). Use the +existing `principal_headers` / `auth` fixtures and seed via the public `POST /api/v1/tasks` plus +direct ORM inserts for sub-rows (chunks/audit) within the test session. + +## 4. Frontend Design + +### 4.1 Route & page + +`/tasks/:id` (name `taskDetail`, `props:true`) **unchanged** — `pages/TaskDetail.vue` rebuilt in +place. Top-level `` on the parent task query (`useTaskDetail`, already exists, polls +via `useLiveResource`). 404 → `DataBoundary` empty state ("task not found"), not an error (fixes the +UI-SP1 bounded-404 LOW). Header: basic info grid (repo, revision, status badge, priority, created, +completed/error), ``, ``, and cancel/delete buttons via existing +`useTaskMutations` (`canCancel`/`canDelete`). Body: `` with 4 lazy panes; only the active +pane's composable is `enabled` (others paused) — gated by the new `useLiveResource` `enabled` option. + +### 4.2 Components (`frontend/src/components/taskdetail/`) + +- `AggregateRing.vue` — inline SVG donut (props: `percent`, `filesDone`, `filesTotal`, + `bytesDone`, `bytesTotal`). Pure; uses status tokens. Unit-tested via a pure `ringDash(percent,r)`. +- `SpeedEta.vue` — consumes `useDownloadRate` (client-derived). Shows current/avg B/s + ETA; + "—" when indeterminate (rate 0 / terminal). +- `SourceBar.vue` — stacked segmented bar from `sources_used` (inline divs/SVG, % widths, legend). +- `ChunkBar.vue` — per-file inline SVG segmented bar from `chunks[]` (segment width ∝ byte span, + fill ∝ `bytes_done/(byte_end-byte_start+1)`, color by chunk `status` + source). +- `SwimLane.vue` — one executor row (health badge, status, counts, bytes). +- `EventRow.vue` — ts + level chip + message; level from `type`/message. + +### 4.3 Composables (`frontend/src/composables/`) — all wrap `useLiveResource` + +`useSubtaskChunks(idRef, enabledRef)`, `useSourceAllocation(idRef, enabledRef)`, +`useParticipatingExecutors(idRef, enabledRef)`, `useTaskEvents(idRef, enabledRef, cursorRef)` — +each: `useLiveResource(['', idRef], () => client.get('/api/v1/tasks/'+id+'/').then(r=>r.data), { baseIntervalMs, enabled: enabledRef, isTerminal: () => parentTerminalRef.value })`. +Intervals: chunks 1500ms, source-alloc 2000ms, executors 2000ms, events 5000ms. Terminal-stop +keyed off the parent task's terminal status (passed in). Types added to `frontend/src/api/types.ts`. + +`useDownloadRate(bytesDoneRef, bytesTotalRef)` — keeps a small in-memory ring of +`{t, bytes}` samples (cap ~30), exposes `currentBps` (EWMA over last ~10s), `avgBps`, +`etaSeconds|null`. Pure rate math extracted to `computeRate(samples)` for unit tests. No store. + +### 4.4 `useLiveResource` additive change + +Add `enabled?: Ref | boolean` to `LiveOptions`; pass `enabled: opts.enabled` straight +into `useQuery`. View-transparent, single-seam preserved, vue-query v5 unwraps the ref. Existing +callers unaffected (optional, defaults undefined ⇒ vue-query treats as enabled). + +### 4.5 i18n & tokens + +New keys under `tasks.detail.*` (tabs, columns, event levels, source/exec labels, speed/eta, +"task not found") added to **both** locales at parity. Reuse the 9 status colors + tokens; no new palette. + +### 4.6 Frontend tests (`frontend/tests/unit/`) + +Pure-function specs: `computeRate`, `ringDash`, chunk-segment geometry helper, event-level +classifier, source-percent formatter. Component specs (mount + ElementPlus + i18n + Pinia, happy-dom, +`vi.hoisted` mocks of `@/api/client`): `TaskDetail` (renders tabs; 404→empty; cancel disabled when +terminal), `ChunkBar`, `SwimLane`, `EventRow`, tab-gating (only active tab composable enabled). Match +UI-SP1 conventions exactly (findComponent by name; no layout reliance). + +## 5. Data Flow & Error Handling + +`useLiveResource` is the only seam. Cancel/delete reuse `useTaskMutations` (optimistic → rollback → +invalidate). axios 401 interceptor (existing) → logout+redirect. Each tab pane wrapped in its own +`` (independent loading/empty/error/forbidden) inside the page-level boundary. 403 on a +sub-endpoint → that pane shows forbidden, page still usable. Cross-tenant/unknown id → page empty +("task not found"). No new Pinia store; all server state is query state. + +## 6. Milestones (preview for writing-plans) + +- **M1 Backend endpoints + contract**: schemas/task_detail.py, services/task_detail.py, 4 routes, + openapi.yaml (implement 2 declared + add 2), per-endpoint pytest (happy/cross-tenant/unauth/agg). + Gate: `pytest`, `spectral`, `swagger-cli`, `lint_invariants`. +- **M2 Frontend foundation**: `useLiveResource.enabled`; api/types; 4 composables + `useDownloadRate`; + pure-fn helpers + their specs. Gate: lint/typecheck/vitest. +- **M3 Visual components**: AggregateRing, SpeedEta, SourceBar, ChunkBar, SwimLane, EventRow + + component specs. Gate: lint/typecheck/vitest. +- **M4 Page assembly + i18n + smoke**: rebuild TaskDetail.vue (header + el-tabs + DataBoundary + + tab-gating + el-table-v2 chunk table + cursor-paginated events), both locales, TaskDetail spec. + Gate: full backend pytest + frontend lint/typecheck/vitest/build; headed-Playwright smoke against + local stack (controller :8001, Vite :5173, 30-day tenant JWT); docs. + +## 7. Risks + +- **el-table-v2 first use**: confirm full `app.use(ElementPlus)` registers it (it ships with the + full plugin in 2.8.4); column-based API differs from `el-table` — plan pins the exact API. If a + blocker, fall back to plain `el-table` with windowed slice (still satisfies "virtualized intent" + at expected file counts) — documented contingency, not default. +- **Static contract `/api/v2` vs runtime `/api/v1`**: pre-existing intentional split; we keep both + styles internally consistent (matches all current code). spectral/swagger-cli lint the static doc + only — adding well-formed paths/schemas in the existing style passes. +- **Speed/ETA fidelity**: client-derived from poll deltas — coarse vs the wireframe's live rate. + Accepted, documented; SP5 (SSE) improves it without view changes. + +## 8. Self-Review + +- Placeholder scan: none (every endpoint has concrete columns + DTO; every component has props). +- Consistency: single live seam (§2/§4.3/§4.4), additive backend (§1/§3), DataBoundary everywhere + (§4.1/§5), contract fidelity (§3.1) — no contradictions. +- Scope: one plan (4 additive endpoints + one page) — appropriately sized; canvas-matrix & + retry/upgrade & live-speed explicitly deferred to keep it single-plan. +- Ambiguity: endpoint paths/DTOs pinned to the on-disk contract (verified lines 495–530, 1829–1868); + tenant gate pinned to tasks.py:143–148; `enabled` semantics pinned to vue-query v5. From 1549d314e5d3edb2bd45918270312242c4938a36 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:12:58 +0800 Subject: [PATCH 02/20] =?UTF-8?q?UI-SP2=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=2020=20bite-sized=20TDD=20tasks=20across=20M1-M4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 backend (schemas/services/4 routes/openapi, per-endpoint pytest), M2 frontend foundation (useLiveResource enabled, types, composables, rate), M3 visual components (ring/chunkbar/sourcebar/swimlane/eventrow), M4 page rebuild + i18n parity + full gate + headed smoke + docs. Complete code, no placeholders; grounded in verified on-disk contract + cancel-pattern gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-20-ui-sp2-task-detail.md | 3198 +++++++++++++++++ 1 file changed, 3198 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md diff --git a/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md b/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md new file mode 100644 index 0000000..f118979 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md @@ -0,0 +1,3198 @@ +# UI-SP2 — Download-Manager-Grade Task Detail Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add four additive read-only backend endpoints over the existing schema and rebuild `/tasks/:id` into a download-accelerator-grade detail view (aggregate ring, per-source bar, virtualized chunk table, executor swimlanes, event log). + +**Architecture:** Backend = 1 new schemas module + 1 new services module + 4 GET routes appended to `src/dlw/api/tasks.py` (the exact proven cancel-pattern tenant gate) + `api/openapi.yaml` (implement the 2 already-declared paths to match their schemas, add 2 new paths/schemas). Frontend = additive `enabled` option on the single `useLiveResource` seam, 4 live composables + a client-derived rate composable, 6 inline-SVG/Element-Plus visual components, a rebuilt `TaskDetail.vue` with `el-tabs` + per-pane `DataBoundary` + virtualized `el-table-v2` chunk table. Zero Alembic migration (all columns already exist). + +**Tech Stack:** FastAPI · SQLAlchemy 2 async · asyncpg · Pydantic v2 · pytest · OpenAPI 3.1 (spectral + swagger-cli) · Vue 3.5 ` + + + + +``` + +- [ ] **Step 4: Run tests → PASS; typecheck → 0 errors** + +Run: `pnpm test:unit -- ringDash AggregateRing` then `pnpm typecheck`. + +- [ ] **Step 5: Lint + commit** + +```bash +cd /d/download_weights/frontend && pnpm lint:fix +cd /d/download_weights && git add frontend/src/components/taskdetail/ringMath.ts frontend/src/components/taskdetail/AggregateRing.vue frontend/tests/unit/ringDash.spec.ts frontend/tests/unit/AggregateRing.spec.ts +git commit -m "UI-SP2 M3: AggregateRing (inline SVG donut) + ringDash" +``` + +--- + +### Task 14: `ChunkBar` + `chunkSegments`, and `SourceBar` + +**Files:** +- Create: `frontend/src/components/taskdetail/segMath.ts`, `ChunkBar.vue`, `SourceBar.vue` +- Test: `frontend/tests/unit/chunkSegments.spec.ts`, `ChunkBar.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +`frontend/tests/unit/chunkSegments.spec.ts`: + +```ts +import { describe, expect, test } from 'vitest' +import { chunkSegments } from '@/components/taskdetail/segMath' +import type { ChunkSeg } from '@/api/types' + +const seg = (i: number, s: number, e: number, st: string, + done: number): ChunkSeg => ({ + chunk_index: i, byte_start: s, byte_end: e, source_id: 'hf', + status: st, bytes_done: done, +}) + +describe('chunkSegments', () => { + test('empty → []', () => { + expect(chunkSegments([], 100, 200)).toEqual([]) + }) + test('two equal chunks → x/width proportional, fill ratio', () => { + const out = chunkSegments( + [seg(0, 0, 49, 'succeeded', 50), seg(1, 50, 99, 'pending', 25)], + 100, 200) + expect(out).toHaveLength(2) + expect(out[0]?.x).toBeCloseTo(0, 5) + expect(out[0]?.w).toBeCloseTo(100, 5) + expect(out[0]?.fill).toBeCloseTo(1, 5) + expect(out[1]?.x).toBeCloseTo(100, 5) + expect(out[1]?.fill).toBeCloseTo(0.5, 5) + }) + test('fileSize null → falls back to span sum', () => { + const out = chunkSegments([seg(0, 0, 99, 'pending', 0)], null, 200) + expect(out[0]?.w).toBeCloseTo(200, 5) + }) +}) +``` + +`frontend/tests/unit/ChunkBar.spec.ts`: + +```ts +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import ChunkBar from '@/components/taskdetail/ChunkBar.vue' +import type { ChunkSeg } from '@/api/types' + +const chunks: ChunkSeg[] = [ + { chunk_index: 0, byte_start: 0, byte_end: 49, source_id: 'hf', + status: 'succeeded', bytes_done: 50 }, + { chunk_index: 1, byte_start: 50, byte_end: 99, source_id: 'ms', + status: 'pending', bytes_done: 0 }, +] + +describe('ChunkBar', () => { + test('renders one rect group per chunk', () => { + const w = mount(ChunkBar, { props: { chunks, fileSize: 100 } }) + expect(w.findAll('rect.seg-bg').length).toBe(2) + }) + test('empty chunks → placeholder, no rects', () => { + const w = mount(ChunkBar, { props: { chunks: [], fileSize: null } }) + expect(w.findAll('rect.seg-bg').length).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pnpm test:unit -- chunkSegments ChunkBar` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Create `segMath.ts`, `ChunkBar.vue`, `SourceBar.vue`** + +`frontend/src/components/taskdetail/segMath.ts`: + +```ts +import type { ChunkSeg } from '@/api/types' + +export interface Seg { + x: number + w: number + fill: number + status: string + source_id: string + chunk_index: number +} + +/** Lay out chunk byte-ranges into [0,totalWidth] px, with fill ratio. */ +export function chunkSegments( + chunks: ChunkSeg[], fileSize: number | null, totalWidth: number, +): Seg[] { + if (chunks.length === 0) return [] + const spanSum = chunks.reduce( + (a, c) => a + (c.byte_end - c.byte_start + 1), 0) + const total = fileSize && fileSize > 0 ? fileSize : spanSum + if (total <= 0) return [] + const out: Seg[] = [] + for (const c of chunks) { + const span = c.byte_end - c.byte_start + 1 + const x = (c.byte_start / total) * totalWidth + const w = (span / total) * totalWidth + const fill = span > 0 ? Math.min(1, Math.max(0, c.bytes_done / span)) : 0 + out.push({ + x, w, fill, status: c.status, source_id: c.source_id, + chunk_index: c.chunk_index, + }) + } + return out +} + +/** Element-Plus status-token color for a chunk status. */ +export function segColor(status: string): string { + if (status === 'succeeded' || status === 'done') { + return 'var(--el-color-success)' + } + if (status === 'failed') return 'var(--el-color-danger)' + if (status === 'pending') return 'var(--el-color-info)' + return 'var(--el-color-primary)' +} +``` + +`frontend/src/components/taskdetail/ChunkBar.vue`: + +```vue + + + + + +``` + +`frontend/src/components/taskdetail/SourceBar.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Run tests → PASS; typecheck → 0 errors** + +Run: `pnpm test:unit -- chunkSegments ChunkBar` then `pnpm typecheck`. + +- [ ] **Step 5: Lint + commit** + +```bash +cd /d/download_weights/frontend && pnpm lint:fix +cd /d/download_weights && git add frontend/src/components/taskdetail/segMath.ts frontend/src/components/taskdetail/ChunkBar.vue frontend/src/components/taskdetail/SourceBar.vue frontend/tests/unit/chunkSegments.spec.ts frontend/tests/unit/ChunkBar.spec.ts +git commit -m "UI-SP2 M3: ChunkBar+chunkSegments + SourceBar (inline SVG)" +``` + +--- + +### Task 15: `SwimLane` + `SpeedEta` + +**Files:** +- Create: `frontend/src/components/taskdetail/SwimLane.vue`, `SpeedEta.vue` +- Test: `frontend/tests/unit/SwimLane.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`frontend/tests/unit/SwimLane.spec.ts`: + +```ts +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import SwimLane from '@/components/taskdetail/SwimLane.vue' +import en from '@/locale/en-US.json' +import type { ParticipatingExecutor } from '@/api/types' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +const ex: ParticipatingExecutor = { + executor_id: 'host-1-w1', executor_status: 'healthy', health_score: 90, + last_heartbeat_at: '2026-05-20T12:00:00Z', assigned_subtasks: 3, + active_subtasks: 2, bytes_downloaded: 1048576, +} + +describe('SwimLane', () => { + test('renders id, status, counts, bytes', () => { + const w = mount(SwimLane, { + props: { executor: ex }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('host-1-w1') + expect(w.text()).toContain('healthy') + expect(w.text()).toContain('2') + expect(w.text()).toContain('1.0 MB') + }) + test('null status → unknown badge, no crash', () => { + const w = mount(SwimLane, { + props: { + executor: { ...ex, executor_status: null, health_score: null, + last_heartbeat_at: null }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('host-1-w1') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm test:unit -- SwimLane` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `SwimLane.vue` + `SpeedEta.vue`** + +`frontend/src/components/taskdetail/SwimLane.vue`: + +```vue + + + + + +``` + +`frontend/src/components/taskdetail/SpeedEta.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Run test → PASS; typecheck → 0 errors** + +Run: `pnpm test:unit -- SwimLane` then `pnpm typecheck`. (i18n keys `tasks.detail.*` are added in Task 17; this test only references `tasks.detail.unknown/active/health` — add them to BOTH locales now in Step 5 so the test resolves, OR the test asserts substrings not requiring those keys. To avoid coupling, this test asserts ids/counts/bytes only — `t()` of a missing key returns the key string, which still contains no assertion dependency. PASS holds.) + +- [ ] **Step 5: Lint + commit** + +```bash +cd /d/download_weights/frontend && pnpm lint:fix +cd /d/download_weights && git add frontend/src/components/taskdetail/SwimLane.vue frontend/src/components/taskdetail/SpeedEta.vue frontend/tests/unit/SwimLane.spec.ts +git commit -m "UI-SP2 M3: SwimLane + SpeedEta" +``` + +--- + +### Task 16: `EventRow` + `eventLevel` + +**Files:** +- Create: `frontend/src/components/taskdetail/eventLevel.ts`, `EventRow.vue` +- Test: `frontend/tests/unit/eventLevel.spec.ts`, `EventRow.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +`frontend/tests/unit/eventLevel.spec.ts`: + +```ts +import { describe, expect, test } from 'vitest' +import { eventLevel } from '@/components/taskdetail/eventLevel' + +describe('eventLevel', () => { + test('denied / failed → error', () => { + expect(eventLevel('task.denied', 'task.denied (denied)')).toBe('error') + expect(eventLevel('subtask.failed', 'subtask.failed')).toBe('error') + }) + test('quota / paused / retry → warn', () => { + expect(eventLevel('quota.exceeded', 'quota.exceeded')).toBe('warn') + expect(eventLevel('subtask.paused_external', 'x')).toBe('warn') + }) + test('default → info', () => { + expect(eventLevel('task.created', 'task.created')).toBe('info') + }) +}) +``` + +`frontend/tests/unit/EventRow.spec.ts`: + +```ts +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import EventRow from '@/components/taskdetail/EventRow.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('EventRow', () => { + test('renders ts, message, level tag', () => { + const w = mount(EventRow, { + props: { + event: { + ts: '2026-05-20T12:00:00Z', type: 'task.denied', + message: 'task.denied (denied)', details: {}, + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('task.denied (denied)') + expect(w.findComponent({ name: 'ElTag' }).exists()).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pnpm test:unit -- eventLevel EventRow` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Create `eventLevel.ts` + `EventRow.vue`** + +`frontend/src/components/taskdetail/eventLevel.ts`: + +```ts +export type EventLevel = 'info' | 'warn' | 'error' + +const ERROR_HINTS = ['denied', 'failed', 'error'] +const WARN_HINTS = ['quota', 'paused', 'retry', 'blacklist', 'degraded'] + +export function eventLevel(type: string, message: string): EventLevel { + const hay = `${type} ${message}`.toLowerCase() + if (ERROR_HINTS.some((h) => hay.includes(h))) return 'error' + if (WARN_HINTS.some((h) => hay.includes(h))) return 'warn' + return 'info' +} +``` + +`frontend/src/components/taskdetail/EventRow.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Run tests → PASS; typecheck → 0 errors** + +Run: `pnpm test:unit -- eventLevel EventRow` then `pnpm typecheck`. + +- [ ] **Step 5: Lint + commit** + +```bash +cd /d/download_weights/frontend && pnpm lint:fix +cd /d/download_weights && git add frontend/src/components/taskdetail/eventLevel.ts frontend/src/components/taskdetail/EventRow.vue frontend/tests/unit/eventLevel.spec.ts frontend/tests/unit/EventRow.spec.ts +git commit -m "UI-SP2 M3: EventRow + eventLevel classifier" +``` + +--- + +# Milestone M4 — Page assembly + i18n + smoke + docs + +### Task 17: i18n — add `tasks.detail.*` to both locales + +**Files:** +- Modify: `frontend/src/locale/en-US.json`, `frontend/src/locale/zh-CN.json` +- Test: `frontend/tests/unit/localeParity.spec.ts` + +- [ ] **Step 1: Write the failing parity test** + +Create `frontend/tests/unit/localeParity.spec.ts`: + +```ts +import { describe, expect, test } from 'vitest' +import en from '@/locale/en-US.json' +import zh from '@/locale/zh-CN.json' + +function keys(o: Record, prefix = ''): string[] { + return Object.entries(o).flatMap(([k, v]) => + v && typeof v === 'object' + ? keys(v as Record, `${prefix}${k}.`) + : [`${prefix}${k}`]) +} + +describe('locale parity', () => { + test('en and zh have identical key sets', () => { + expect(keys(en).sort()).toEqual(keys(zh).sort()) + }) + test('tasks.detail subtree exists', () => { + expect((en as { tasks: { detail?: unknown } }).tasks.detail) + .toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm test:unit -- localeParity` +Expected: FAIL — `tasks.detail` missing. + +- [ ] **Step 3: Add the `detail` subtree to BOTH locales** + +In `frontend/src/locale/en-US.json`, inside the `"tasks"` object, after the `"subtaskColumns"` entry (and its closing brace) add a comma and: + +```json + "detail": { + "tabFiles": "Files & chunks", "tabSources": "Sources", + "tabExecutors": "Executors", "tabEvents": "Events", + "progress": "Progress", "speedNow": "Current", "speedAvg": "Average", + "eta": "ETA", "active": "Active", "health": "Health", + "unknown": "unknown", "noEvents": "No events recorded", + "loadOlder": "Load older", "noSources": "No source allocation yet", + "noExecutors": "No executors participating yet", + "noChunks": "No files yet", "colFile": "File", "colSize": "Size", + "colStatus": "Status", "colChunks": "Chunks", "colProgress": "Progress", + "cancel": "Cancel task", "delete": "Delete task", + "cancelConfirm": "Cancel this task?", + "deleteConfirm": "Delete this terminal task?", + "cancelled": "Cancellation requested", "deleted": "Deleted" + } +``` + +In `frontend/src/locale/zh-CN.json`, inside `"tasks"`, after `"subtaskColumns"` add a comma and: + +```json + "detail": { + "tabFiles": "文件与分块", "tabSources": "源分配", + "tabExecutors": "执行节点", "tabEvents": "事件", + "progress": "进度", "speedNow": "当前", "speedAvg": "平均", + "eta": "预计剩余", "active": "活跃", "health": "健康分", + "unknown": "未知", "noEvents": "暂无事件记录", + "loadOlder": "加载更早", "noSources": "暂无源分配", + "noExecutors": "暂无执行节点参与", + "noChunks": "暂无文件", "colFile": "文件", "colSize": "大小", + "colStatus": "状态", "colChunks": "分块", "colProgress": "进度", + "cancel": "取消任务", "delete": "删除任务", + "cancelConfirm": "确认取消该任务?", + "deleteConfirm": "确认删除该终态任务?", + "cancelled": "已请求取消", "deleted": "已删除" + } +``` + +- [ ] **Step 4: Run test → PASS; typecheck → 0 errors** + +Run: `pnpm test:unit -- localeParity` then `pnpm typecheck`. + +- [ ] **Step 5: Lint + commit** + +```bash +cd /d/download_weights/frontend && pnpm lint:fix +cd /d/download_weights && git add frontend/src/locale/en-US.json frontend/src/locale/zh-CN.json frontend/tests/unit/localeParity.spec.ts +git commit -m "UI-SP2 M4: i18n tasks.detail.* keys (en/zh parity)" +``` + +--- + +### Task 18: Rebuild `TaskDetail.vue` (header + el-tabs + DataBoundary + tab-gated panes) + +**Files:** +- Modify (full rewrite): `frontend/src/pages/TaskDetail.vue` +- Test: `frontend/tests/unit/TaskDetailSP2.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/tests/unit/TaskDetailSP2.spec.ts`: + +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ElementPlus from 'element-plus' +import { createI18n } from 'vue-i18n' +import { ref } from 'vue' +import en from '@/locale/en-US.json' + +const { detailData } = vi.hoisted(() => ({ + detailData: { value: null as unknown }, +})) +vi.mock('@/composables/useTaskDetail', () => ({ + useTaskDetail: () => ({ + data: detailData, isLoading: ref(false), isError: ref(false), + error: ref(null), + }), +})) +const live = () => ({ data: ref(null), isLoading: ref(false), + isError: ref(false), error: ref(null) }) +vi.mock('@/composables/useSubtaskChunks', () => ({ + useSubtaskChunks: live, +})) +vi.mock('@/composables/useSourceAllocation', () => ({ + useSourceAllocation: live, +})) +vi.mock('@/composables/useParticipatingExecutors', () => ({ + useParticipatingExecutors: live, +})) +vi.mock('@/composables/useTaskEvents', () => ({ + useTaskEvents: live, fetchOlderEvents: vi.fn(), +})) +const { cancelMut, removeMut } = vi.hoisted(() => ({ + cancelMut: { mutate: vi.fn() }, removeMut: { mutate: vi.fn() }, +})) +vi.mock('@/composables/useTaskMutations', () => ({ + useTaskMutations: () => ({ cancel: cancelMut, remove: removeMut }), + canCancel: (s: string) => s === 'downloading', + canDelete: (s: string) => s === 'succeeded', +})) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +function mountPage() { + return import('@/pages/TaskDetail.vue').then((m) => + mount(m.default, { + props: { id: 'abc' }, + global: { plugins: [ElementPlus, i18n] }, + })) +} + +describe('TaskDetail (SP2)', () => { + beforeEach(() => { setActivePinia(createPinia()); detailData.value = null }) + + test('no data → DataBoundary empty (not crash)', async () => { + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + + test('data present → tabs render, AggregateRing shown', async () => { + detailData.value = { + id: 'abc', repo_id: 'o/m', revision: 'a'.repeat(40), + status: 'downloading', priority: 1, + created_at: '2026-05-20T00:00:00Z', completed_at: null, + error_message: null, subtasks: [], + } + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'ElTabs' }).exists()).toBe(true) + expect(w.findComponent({ name: 'AggregateRing' }).exists()).toBe(true) + }) + + test('terminal task → cancel hidden, delete shown', async () => { + detailData.value = { + id: 'abc', repo_id: 'o/m', revision: 'a'.repeat(40), + status: 'succeeded', priority: 1, + created_at: '2026-05-20T00:00:00Z', completed_at: null, + error_message: null, subtasks: [], + } + const w = await mountPage() + await flushPromises() + expect(w.text()).toContain(en.tasks.detail.delete) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm test:unit -- TaskDetailSP2` +Expected: FAIL — the current `TaskDetail.vue` has no `ElTabs`/`AggregateRing`/DataBoundary structure. + +- [ ] **Step 3: Replace `frontend/src/pages/TaskDetail.vue` entirely** + +```vue + + + + + +``` + +> **Note on virtualization:** the spec's locked decision is `el-table-v2` for the chunk table. `el-table` with `max-height="520"` (above) renders correctly under happy-dom and at expected file counts (tens–hundreds) is performant; `el-table-v2`'s `cellRenderer`-only column API does not support the `#default` slot used for `ChunkBar`/`StatusBadge` and does not render rows under happy-dom (untestable). This task ships `el-table` with capped height as the conservative, testable realization of the "virtualized intent"; true windowing via `el-table-v2` is recorded as a documented follow-up in the spec's §7 contingency. This is a deliberate, reviewed scope decision — not a placeholder. + +- [ ] **Step 4: Run test → PASS; typecheck → 0 errors** + +Run: `pnpm test:unit -- TaskDetailSP2` then `pnpm typecheck`. +Expected: 3 tests PASS; 0 type errors. + +- [ ] **Step 5: Lint + commit** + +```bash +cd /d/download_weights/frontend && pnpm lint:fix +cd /d/download_weights && git add frontend/src/pages/TaskDetail.vue frontend/tests/unit/TaskDetailSP2.spec.ts +git commit -m "UI-SP2 M4: rebuild TaskDetail (header+ring+tabs+DataBoundary+chunk table+events)" +``` + +--- + +### Task 19: M4 full gate (backend + frontend) + headed-Playwright smoke + +**Files:** none (verification; smoke artifacts under `.run/pw/` are gitignored). + +- [ ] **Step 1: Full backend suite** + +Run: `uv run pytest tests/ -q` +Expected: 0 failures. + +- [ ] **Step 2: Full frontend gate** + +From `frontend`: `pnpm test:unit` (all pass) · `pnpm typecheck` (0) · `pnpm lint` (0 warnings) · `pnpm build` (success). + +- [ ] **Step 3: OpenAPI + invariant** + +Run: `npx --yes @stoplight/spectral-cli lint api/openapi.yaml --fail-severity=error` → 0 errors. +Run: `npx --yes @apidevtools/swagger-cli validate api/openapi.yaml` → valid. +Run: `python tools/lint_invariants.py` → OK. + +- [ ] **Step 4: Headed Playwright smoke against the running local stack** + +Pre-req (already running per session): controller plain-HTTP on `:8001`, Vite on `:5173` (proxies `/api`→`:8001`), a 30-day tenant-user JWT available. Create `.run/pw/sp2-smoke.mjs`: + +```js +import { chromium } from 'playwright' +const TOKEN = process.env.DLW_TOKEN +const b = await chromium.launch({ headless: false }) +const pg = await b.newPage() +await pg.goto('http://localhost:5173/login') +await pg.fill('input', TOKEN) +await pg.click('button[type="submit"]') +await pg.waitForURL('**/') +await pg.goto('http://localhost:5173/tasks') +await pg.waitForSelector('table') +const first = await pg.locator('table tbody tr a, table tbody tr').first() +await first.click() +await pg.waitForSelector('.el-tabs') +for (const name of ['sources', 'executors', 'events']) { + await pg.click(`#tab-${name}`) + await pg.waitForTimeout(1200) +} +console.log('SP2 smoke OK') +await pg.waitForTimeout(2500) +await b.close() +``` + +Run (PowerShell): `$env:DLW_TOKEN = (Get-Content .run/dlw-token.txt -Raw).Trim(); node .run/pw/sp2-smoke.mjs` +Expected: a non-headless Chromium opens, logs in, navigates to a task detail, switches Sources/Executors/Events tabs without console errors, prints `SP2 smoke OK`. If no task exists, create one via the UI first (TaskCreate) using the tenant JWT. **The smoke is a manual gate; record the outcome in the task notes. It does not block if the local stack is unavailable — note that explicitly instead.** + +- [ ] **Step 5: Commit (only if gate fixups were required)** + +```bash +git add -A +git commit -m "UI-SP2 M4 gate: full backend+frontend green; headed smoke verified" +``` + +--- + +### Task 20: Docs + +**Files:** +- Modify: `docs/operator/web-ui.md` + +- [ ] **Step 1: Append a UI-SP2 section to `docs/operator/web-ui.md`** + +Add at the end of the file: + +```markdown + +## UI-SP2 — Download-manager Task Detail + +`/tasks/:id` is a full download-accelerator view backed by four additive +read-only endpoints (zero migration): + +- `GET /api/v1/tasks/{id}/subtask-chunks` — per-file chunk segments +- `GET /api/v1/tasks/{id}/source-allocation` — per-source contribution + chunk routing +- `GET /api/v1/tasks/{id}/participating-executors` — executor swimlanes +- `GET /api/v1/tasks/{id}/events` — audit-derived event log (cursor-paginated) + +The page: header (basic info + aggregate progress ring + client-derived +speed/ETA + cancel/delete) and four tabs (Files & chunks, Sources, +Executors, Events). All polling flows through the single `useLiveResource` +seam; only the active tab polls (others paused via the `enabled` option). + +**Known limitations (intentional, deferred):** speed/ETA are derived +client-side from successive byte-count polls (no backend speed source); +retry/pause/upgrade actions are not exposed (no endpoints); the file table +uses height-capped `el-table` (true `el-table-v2` windowing is a documented +follow-up); the event log reads existing `audit_log` rows only; real-time +push (SSE/WS) arrives in UI-SP5 with no view changes. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/operator/web-ui.md +git commit -m "UI-SP2 M4: operator docs for the download-manager Task Detail" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §3.2 four endpoints → Tasks 2-5 (chunks, source-alloc, executors, events). ✓ +- §3.1 contract (implement declared `getSourceAllocation`/`getTaskEvents` to match `SourceAllocation`/`TaskEvent`; add 2 paths/schemas) → Task 1 + DTOs in Task 2 match the on-disk schemas (verified lines 1829-1868). ✓ +- §3.3 service layer in `services/task_detail.py` → Tasks 3-5. ✓ +- §3.4 backend tests happy/cross-tenant-404/unauth-401/aggregation → each of Tasks 2-5 has all four. ✓ +- §4.1 route unchanged, header, el-tabs, DataBoundary, tab-gating, cancel/delete → Task 18. ✓ +- §4.2 six components → Tasks 13-16. ✓ §4.3 four composables + useDownloadRate → Tasks 10-11. ✓ +- §4.4 `useLiveResource.enabled` additive → Task 7. ✓ §4.5 i18n parity → Task 17 + parity test. ✓ +- §4.6 frontend tests (pure + component + page) → Tasks 9,10,13,14,15,16,18. ✓ +- §5 data flow / per-pane DataBoundary / no new store → Task 18. ✓ +- §6 milestones M1-M4 → tasks grouped + gates (Tasks 6,12,19). ✓ +- §1 deferrals (canvas matrix, retry/upgrade, live speed, SSE, ECharts) → not implemented, documented in Task 20. ✓ Zero migration → no alembic task. ✓ + +**2. Placeholder scan:** No "TBD/handle edge cases/similar to Task N". Every code step has complete code. The `el-table` vs `el-table-v2` note in Task 18 is a complete, reviewed implementation decision with full code given (not a placeholder). + +**3. Type consistency:** DTO names identical across backend (`task_detail.py`), OpenAPI (Task 1), and frontend `types.ts` (Task 8): `ChunkSeg/SubtaskChunkRow/SubtaskChunkReport/SourceUsed/ChunkRouting/SourceAllocation/ParticipatingExecutor/ParticipatingExecutors/TaskEvent(Item)/TaskEventsResponse`. Composable signatures `(taskId: Ref, enabled: Ref, terminal: Ref)` consistent across Task 11 and consumed identically in Task 18. `computeRate`/`ringDash`/`chunkSegments`/`eventLevel`/`formatBytes` signatures match between their defining task and their consumers. `useLiveResource` `enabled?: Ref | boolean` (Task 7) matches composable usage (Task 11, passing `Ref`). Backend tenant gate verbatim from `tasks.py:143-148`. `auth` fixture uses `role="tenant_admin"` (matches existing passing `test_tasks.py` so RBAC behavior is identical for the new GET routes). No gaps found. From 269c882a1601b40e9039f308550a8568fc71e9ef Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:22:37 +0800 Subject: [PATCH 03/20] UI-SP2 plan: fix 2 pre-execution BLOCKERs found by opus reviewers - Backend B1: and_(False) is invalid in SQLAlchemy 2 -> use false() (+ import) - Frontend B1: TaskDetailSP2 mock returned plain {value} not a ref; Vue template auto-unwrap needs a real ref -> self-contained async vi.mock factories that await import('vue') and create real refs - Minor: deps are caret-resolved (vue-query 5.100.x / element-plus 2.13.x); don't assert exact minors (behavior verified equivalent) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-20-ui-sp2-task-detail.md | 83 ++++++++++++------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md b/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md index f118979..b08cc59 100644 --- a/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md +++ b/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md @@ -6,7 +6,7 @@ **Architecture:** Backend = 1 new schemas module + 1 new services module + 4 GET routes appended to `src/dlw/api/tasks.py` (the exact proven cancel-pattern tenant gate) + `api/openapi.yaml` (implement the 2 already-declared paths to match their schemas, add 2 new paths/schemas). Frontend = additive `enabled` option on the single `useLiveResource` seam, 4 live composables + a client-derived rate composable, 6 inline-SVG/Element-Plus visual components, a rebuilt `TaskDetail.vue` with `el-tabs` + per-pane `DataBoundary` + virtualized `el-table-v2` chunk table. Zero Alembic migration (all columns already exist). -**Tech Stack:** FastAPI · SQLAlchemy 2 async · asyncpg · Pydantic v2 · pytest · OpenAPI 3.1 (spectral + swagger-cli) · Vue 3.5 ` + + + + diff --git a/frontend/src/components/taskdetail/ringMath.ts b/frontend/src/components/taskdetail/ringMath.ts new file mode 100644 index 0000000..5dab0dc --- /dev/null +++ b/frontend/src/components/taskdetail/ringMath.ts @@ -0,0 +1,6 @@ +/** Returns an SVG stroke-dasharray " " for a given percent. */ +export function ringDash(percent: number, circumference: number): string { + const p = Math.min(100, Math.max(0, percent)) + const fill = (p / 100) * circumference + return `${+fill.toFixed(6)} ${+(circumference - fill).toFixed(6)}` +} diff --git a/frontend/tests/unit/AggregateRing.spec.ts b/frontend/tests/unit/AggregateRing.spec.ts new file mode 100644 index 0000000..eedf3b8 --- /dev/null +++ b/frontend/tests/unit/AggregateRing.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import AggregateRing from '@/components/taskdetail/AggregateRing.vue' + +describe('AggregateRing', () => { + test('renders percent + counts', () => { + const w = mount(AggregateRing, { + props: { + percent: 67, filesDone: 108, filesTotal: 163, + bytesDone: 1000, bytesTotal: 2000, + }, + }) + expect(w.text()).toContain('67%') + expect(w.text()).toContain('108') + expect(w.text()).toContain('163') + expect(w.find('circle').exists()).toBe(true) + }) +}) diff --git a/frontend/tests/unit/ringDash.spec.ts b/frontend/tests/unit/ringDash.spec.ts new file mode 100644 index 0000000..93bfe89 --- /dev/null +++ b/frontend/tests/unit/ringDash.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest' +import { ringDash } from '@/components/taskdetail/ringMath' + +describe('ringDash', () => { + const C = 100 + test('0% → no fill', () => { + expect(ringDash(0, C)).toBe('0 100') + }) + test('50% → half', () => { + expect(ringDash(50, C)).toBe('50 50') + }) + test('clamps over/under', () => { + expect(ringDash(150, C)).toBe('100 0') + expect(ringDash(-5, C)).toBe('0 100') + }) +}) From 0bb1d524b683303c81cee5ef1ac165fb84c16bd3 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:40:30 +0800 Subject: [PATCH 15/20] UI-SP2 M3: ChunkBar+chunkSegments + SourceBar (inline SVG) --- .../src/components/taskdetail/ChunkBar.vue | 55 ++++++++++++++ .../src/components/taskdetail/SourceBar.vue | 75 +++++++++++++++++++ frontend/src/components/taskdetail/segMath.ts | 43 +++++++++++ frontend/tests/unit/ChunkBar.spec.ts | 22 ++++++ frontend/tests/unit/chunkSegments.spec.ts | 30 ++++++++ 5 files changed, 225 insertions(+) create mode 100644 frontend/src/components/taskdetail/ChunkBar.vue create mode 100644 frontend/src/components/taskdetail/SourceBar.vue create mode 100644 frontend/src/components/taskdetail/segMath.ts create mode 100644 frontend/tests/unit/ChunkBar.spec.ts create mode 100644 frontend/tests/unit/chunkSegments.spec.ts diff --git a/frontend/src/components/taskdetail/ChunkBar.vue b/frontend/src/components/taskdetail/ChunkBar.vue new file mode 100644 index 0000000..ae3c03b --- /dev/null +++ b/frontend/src/components/taskdetail/ChunkBar.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/components/taskdetail/SourceBar.vue b/frontend/src/components/taskdetail/SourceBar.vue new file mode 100644 index 0000000..e95786d --- /dev/null +++ b/frontend/src/components/taskdetail/SourceBar.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/taskdetail/segMath.ts b/frontend/src/components/taskdetail/segMath.ts new file mode 100644 index 0000000..fba0123 --- /dev/null +++ b/frontend/src/components/taskdetail/segMath.ts @@ -0,0 +1,43 @@ +import type { ChunkSeg } from '@/api/types' + +export interface Seg { + x: number + w: number + fill: number + status: string + source_id: string + chunk_index: number +} + +/** Lay out chunk byte-ranges into [0,totalWidth] px, with fill ratio. */ +export function chunkSegments( + chunks: ChunkSeg[], fileSize: number | null, totalWidth: number, +): Seg[] { + if (chunks.length === 0) return [] + const spanSum = chunks.reduce( + (a, c) => a + (c.byte_end - c.byte_start + 1), 0) + const total = fileSize && fileSize > 0 ? fileSize : spanSum + if (total <= 0) return [] + const out: Seg[] = [] + for (const c of chunks) { + const span = c.byte_end - c.byte_start + 1 + const x = (c.byte_start / total) * totalWidth + const w = (span / total) * totalWidth + const fill = span > 0 ? Math.min(1, Math.max(0, c.bytes_done / span)) : 0 + out.push({ + x, w, fill, status: c.status, source_id: c.source_id, + chunk_index: c.chunk_index, + }) + } + return out +} + +/** Element-Plus status-token color for a chunk status. */ +export function segColor(status: string): string { + if (status === 'succeeded' || status === 'done') { + return 'var(--el-color-success)' + } + if (status === 'failed') return 'var(--el-color-danger)' + if (status === 'pending') return 'var(--el-color-info)' + return 'var(--el-color-primary)' +} diff --git a/frontend/tests/unit/ChunkBar.spec.ts b/frontend/tests/unit/ChunkBar.spec.ts new file mode 100644 index 0000000..dd696e6 --- /dev/null +++ b/frontend/tests/unit/ChunkBar.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import ChunkBar from '@/components/taskdetail/ChunkBar.vue' +import type { ChunkSeg } from '@/api/types' + +const chunks: ChunkSeg[] = [ + { chunk_index: 0, byte_start: 0, byte_end: 49, source_id: 'hf', + status: 'succeeded', bytes_done: 50 }, + { chunk_index: 1, byte_start: 50, byte_end: 99, source_id: 'ms', + status: 'pending', bytes_done: 0 }, +] + +describe('ChunkBar', () => { + test('renders one rect group per chunk', () => { + const w = mount(ChunkBar, { props: { chunks, fileSize: 100 } }) + expect(w.findAll('rect.seg-bg').length).toBe(2) + }) + test('empty chunks → placeholder, no rects', () => { + const w = mount(ChunkBar, { props: { chunks: [], fileSize: null } }) + expect(w.findAll('rect.seg-bg').length).toBe(0) + }) +}) diff --git a/frontend/tests/unit/chunkSegments.spec.ts b/frontend/tests/unit/chunkSegments.spec.ts new file mode 100644 index 0000000..dd88807 --- /dev/null +++ b/frontend/tests/unit/chunkSegments.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest' +import { chunkSegments } from '@/components/taskdetail/segMath' +import type { ChunkSeg } from '@/api/types' + +const seg = (i: number, s: number, e: number, st: string, + done: number): ChunkSeg => ({ + chunk_index: i, byte_start: s, byte_end: e, source_id: 'hf', + status: st, bytes_done: done, +}) + +describe('chunkSegments', () => { + test('empty → []', () => { + expect(chunkSegments([], 100, 200)).toEqual([]) + }) + test('two equal chunks → x/width proportional, fill ratio', () => { + const out = chunkSegments( + [seg(0, 0, 49, 'succeeded', 50), seg(1, 50, 99, 'pending', 25)], + 100, 200) + expect(out).toHaveLength(2) + expect(out[0]?.x).toBeCloseTo(0, 5) + expect(out[0]?.w).toBeCloseTo(100, 5) + expect(out[0]?.fill).toBeCloseTo(1, 5) + expect(out[1]?.x).toBeCloseTo(100, 5) + expect(out[1]?.fill).toBeCloseTo(0.5, 5) + }) + test('fileSize null → falls back to span sum', () => { + const out = chunkSegments([seg(0, 0, 99, 'pending', 0)], null, 200) + expect(out[0]?.w).toBeCloseTo(200, 5) + }) +}) From a9c1c1159309bcc163b38452e218e42797e985fe Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:41:49 +0800 Subject: [PATCH 16/20] UI-SP2 M3: SwimLane + SpeedEta --- .../src/components/taskdetail/SpeedEta.vue | 46 ++++++++++++++ .../src/components/taskdetail/SwimLane.vue | 63 +++++++++++++++++++ frontend/tests/unit/SwimLane.spec.ts | 39 ++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 frontend/src/components/taskdetail/SpeedEta.vue create mode 100644 frontend/src/components/taskdetail/SwimLane.vue create mode 100644 frontend/tests/unit/SwimLane.spec.ts diff --git a/frontend/src/components/taskdetail/SpeedEta.vue b/frontend/src/components/taskdetail/SpeedEta.vue new file mode 100644 index 0000000..6d91826 --- /dev/null +++ b/frontend/src/components/taskdetail/SpeedEta.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/src/components/taskdetail/SwimLane.vue b/frontend/src/components/taskdetail/SwimLane.vue new file mode 100644 index 0000000..82097fa --- /dev/null +++ b/frontend/src/components/taskdetail/SwimLane.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/tests/unit/SwimLane.spec.ts b/frontend/tests/unit/SwimLane.spec.ts new file mode 100644 index 0000000..96481fd --- /dev/null +++ b/frontend/tests/unit/SwimLane.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import SwimLane from '@/components/taskdetail/SwimLane.vue' +import en from '@/locale/en-US.json' +import type { ParticipatingExecutor } from '@/api/types' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +const ex: ParticipatingExecutor = { + executor_id: 'host-1-w1', executor_status: 'healthy', health_score: 90, + last_heartbeat_at: '2026-05-20T12:00:00Z', assigned_subtasks: 3, + active_subtasks: 2, bytes_downloaded: 1048576, +} + +describe('SwimLane', () => { + test('renders id, status, counts, bytes', () => { + const w = mount(SwimLane, { + props: { executor: ex }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('host-1-w1') + expect(w.text()).toContain('healthy') + expect(w.text()).toContain('2') + expect(w.text()).toContain('1.0 MB') + }) + test('null status → unknown badge, no crash', () => { + const w = mount(SwimLane, { + props: { + executor: { ...ex, executor_status: null, health_score: null, + last_heartbeat_at: null }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('host-1-w1') + }) +}) From 1fa6da5a202e7f3fab082d6fe6494e6af9439820 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:43:04 +0800 Subject: [PATCH 17/20] UI-SP2 M3: EventRow + eventLevel classifier --- .../src/components/taskdetail/EventRow.vue | 55 +++++++++++++++++++ .../src/components/taskdetail/eventLevel.ts | 11 ++++ frontend/tests/unit/EventRow.spec.ts | 26 +++++++++ frontend/tests/unit/eventLevel.spec.ts | 16 ++++++ 4 files changed, 108 insertions(+) create mode 100644 frontend/src/components/taskdetail/EventRow.vue create mode 100644 frontend/src/components/taskdetail/eventLevel.ts create mode 100644 frontend/tests/unit/EventRow.spec.ts create mode 100644 frontend/tests/unit/eventLevel.spec.ts diff --git a/frontend/src/components/taskdetail/EventRow.vue b/frontend/src/components/taskdetail/EventRow.vue new file mode 100644 index 0000000..69f37ec --- /dev/null +++ b/frontend/src/components/taskdetail/EventRow.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/components/taskdetail/eventLevel.ts b/frontend/src/components/taskdetail/eventLevel.ts new file mode 100644 index 0000000..beb6c08 --- /dev/null +++ b/frontend/src/components/taskdetail/eventLevel.ts @@ -0,0 +1,11 @@ +export type EventLevel = 'info' | 'warn' | 'error' + +const ERROR_HINTS = ['denied', 'failed', 'error'] +const WARN_HINTS = ['quota', 'paused', 'retry', 'blacklist', 'degraded'] + +export function eventLevel(type: string, message: string): EventLevel { + const hay = `${type} ${message}`.toLowerCase() + if (ERROR_HINTS.some((h) => hay.includes(h))) return 'error' + if (WARN_HINTS.some((h) => hay.includes(h))) return 'warn' + return 'info' +} diff --git a/frontend/tests/unit/EventRow.spec.ts b/frontend/tests/unit/EventRow.spec.ts new file mode 100644 index 0000000..98219f1 --- /dev/null +++ b/frontend/tests/unit/EventRow.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import EventRow from '@/components/taskdetail/EventRow.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('EventRow', () => { + test('renders ts, message, level tag', () => { + const w = mount(EventRow, { + props: { + event: { + ts: '2026-05-20T12:00:00Z', type: 'task.denied', + message: 'task.denied (denied)', details: {}, + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('task.denied (denied)') + expect(w.findComponent({ name: 'ElTag' }).exists()).toBe(true) + }) +}) diff --git a/frontend/tests/unit/eventLevel.spec.ts b/frontend/tests/unit/eventLevel.spec.ts new file mode 100644 index 0000000..052b00c --- /dev/null +++ b/frontend/tests/unit/eventLevel.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest' +import { eventLevel } from '@/components/taskdetail/eventLevel' + +describe('eventLevel', () => { + test('denied / failed → error', () => { + expect(eventLevel('task.denied', 'task.denied (denied)')).toBe('error') + expect(eventLevel('subtask.failed', 'subtask.failed')).toBe('error') + }) + test('quota / paused / retry → warn', () => { + expect(eventLevel('quota.exceeded', 'quota.exceeded')).toBe('warn') + expect(eventLevel('subtask.paused_external', 'x')).toBe('warn') + }) + test('default → info', () => { + expect(eventLevel('task.created', 'task.created')).toBe('info') + }) +}) From dec6cd9a5aa84f448d8f6ecc976395e49c534a10 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:44:18 +0800 Subject: [PATCH 18/20] UI-SP2 M4: i18n tasks.detail.* keys (en/zh parity) --- frontend/src/locale/en-US.json | 17 ++++++++++++++++- frontend/src/locale/zh-CN.json | 17 ++++++++++++++++- frontend/tests/unit/localeParity.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/unit/localeParity.spec.ts diff --git a/frontend/src/locale/en-US.json b/frontend/src/locale/en-US.json index 3e92151..bfac5bf 100644 --- a/frontend/src/locale/en-US.json +++ b/frontend/src/locale/en-US.json @@ -33,7 +33,22 @@ "polling": "Live refreshing…", "completed": "Stopped (terminal)", "back": "Back to list", "notFound": "Task not found or deleted", "subtaskColumns": { "filename": "File", "size": "Size", - "sha256": "SHA256", "status": "Status" } + "sha256": "SHA256", "status": "Status" }, + "detail": { + "tabFiles": "Files & chunks", "tabSources": "Sources", + "tabExecutors": "Executors", "tabEvents": "Events", + "progress": "Progress", "speedNow": "Current", "speedAvg": "Average", + "eta": "ETA", "active": "Active", "health": "Health", + "unknown": "unknown", "noEvents": "No events recorded", + "loadOlder": "Load older", "noSources": "No source allocation yet", + "noExecutors": "No executors participating yet", + "noChunks": "No files yet", "colFile": "File", "colSize": "Size", + "colStatus": "Status", "colChunks": "Chunks", "colProgress": "Progress", + "cancel": "Cancel task", "delete": "Delete task", + "cancelConfirm": "Cancel this task?", + "deleteConfirm": "Delete this terminal task?", + "cancelled": "Cancellation requested", "deleted": "Deleted" + } }, "create": { "heading": "Create download task", "repo": "Repo (org/model)", diff --git a/frontend/src/locale/zh-CN.json b/frontend/src/locale/zh-CN.json index 5ce4995..b9438a6 100644 --- a/frontend/src/locale/zh-CN.json +++ b/frontend/src/locale/zh-CN.json @@ -35,7 +35,22 @@ "polling": "实时刷新中…", "completed": "已停止刷新(终态)", "back": "返回列表", "notFound": "任务不存在或已删除", "subtaskColumns": { "filename": "文件名", "size": "大小", - "sha256": "SHA256", "status": "状态" } + "sha256": "SHA256", "status": "状态" }, + "detail": { + "tabFiles": "文件与分块", "tabSources": "源分配", + "tabExecutors": "执行节点", "tabEvents": "事件", + "progress": "进度", "speedNow": "当前", "speedAvg": "平均", + "eta": "预计剩余", "active": "活跃", "health": "健康分", + "unknown": "未知", "noEvents": "暂无事件记录", + "loadOlder": "加载更早", "noSources": "暂无源分配", + "noExecutors": "暂无执行节点参与", + "noChunks": "暂无文件", "colFile": "文件", "colSize": "大小", + "colStatus": "状态", "colChunks": "分块", "colProgress": "进度", + "cancel": "取消任务", "delete": "删除任务", + "cancelConfirm": "确认取消该任务?", + "deleteConfirm": "确认删除该终态任务?", + "cancelled": "已请求取消", "deleted": "已删除" + } }, "create": { "heading": "新建下载任务", "repo": "仓库 (org/model)", diff --git a/frontend/tests/unit/localeParity.spec.ts b/frontend/tests/unit/localeParity.spec.ts new file mode 100644 index 0000000..facca08 --- /dev/null +++ b/frontend/tests/unit/localeParity.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest' +import en from '@/locale/en-US.json' +import zh from '@/locale/zh-CN.json' + +function keys(o: Record, prefix = ''): string[] { + return Object.entries(o).flatMap(([k, v]) => + v && typeof v === 'object' + ? keys(v as Record, `${prefix}${k}.`) + : [`${prefix}${k}`]) +} + +describe('locale parity', () => { + test('en and zh have identical key sets', () => { + expect(keys(en).sort()).toEqual(keys(zh).sort()) + }) + test('tasks.detail subtree exists', () => { + expect((en as { tasks: { detail?: unknown } }).tasks.detail) + .toBeTruthy() + }) +}) From 66950dc879b0c25d8eacdb3c123e1f5f63a86b1b Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:46:03 +0800 Subject: [PATCH 19/20] UI-SP2 M4: rebuild TaskDetail (header+ring+tabs+DataBoundary+chunk table+events) --- frontend/src/pages/TaskDetail.vue | 488 +++++++++++++++------- frontend/tests/unit/TaskDetailSP2.spec.ts | 108 +++++ 2 files changed, 442 insertions(+), 154 deletions(-) create mode 100644 frontend/tests/unit/TaskDetailSP2.spec.ts diff --git a/frontend/src/pages/TaskDetail.vue b/frontend/src/pages/TaskDetail.vue index a7ed7ce..b8ce1b3 100644 --- a/frontend/src/pages/TaskDetail.vue +++ b/frontend/src/pages/TaskDetail.vue @@ -1,51 +1,119 @@ @@ -57,149 +125,261 @@ function back() { - - - - - {{ t('tasks.back') }} - - - - - - + + + + + +
+
+ {{ r.filename }} +
+ +
+
+
+ + + + + + + + + + +
+ + {{ t('tasks.detail.loadOlder') }} + +
+
+
+
+ +
- diff --git a/frontend/tests/unit/TaskDetailSP2.spec.ts b/frontend/tests/unit/TaskDetailSP2.spec.ts new file mode 100644 index 0000000..22a38bf --- /dev/null +++ b/frontend/tests/unit/TaskDetailSP2.spec.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ElementPlus from 'element-plus' +import { createI18n } from 'vue-i18n' +import en from '@/locale/en-US.json' + +// Pre-review BLOCKER fix: the page relies on Vue template ref auto-unwrap +// (`v-if="data"`, `!data`), so the mocked `useTaskDetail().data` MUST be a +// real ref — a plain `{ value }` object never unwraps. `vi.hoisted` holders +// must stay plain (no `ref()` — TDZ above imports); each `vi.mock` factory +// is self-contained and async-imports `vue` (factory runs lazily, after the +// `vue` import is resolved), creating real refs. +const { detailData } = vi.hoisted(() => ({ + detailData: { value: null as unknown }, +})) +const { mutes } = vi.hoisted(() => ({ + mutes: { cancel: { mutate: vi.fn() }, remove: { mutate: vi.fn() } }, +})) + +vi.mock('@/composables/useTaskDetail', async () => { + const { ref } = await import('vue') + return { + useTaskDetail: () => ({ + data: ref(detailData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) +vi.mock('@/composables/useSubtaskChunks', async () => { + const { ref } = await import('vue') + return { useSubtaskChunks: () => ({ + data: ref(null), isLoading: ref(false), + isError: ref(false), error: ref(null) }) } +}) +vi.mock('@/composables/useSourceAllocation', async () => { + const { ref } = await import('vue') + return { useSourceAllocation: () => ({ + data: ref(null), isLoading: ref(false), + isError: ref(false), error: ref(null) }) } +}) +vi.mock('@/composables/useParticipatingExecutors', async () => { + const { ref } = await import('vue') + return { useParticipatingExecutors: () => ({ + data: ref(null), isLoading: ref(false), + isError: ref(false), error: ref(null) }) } +}) +vi.mock('@/composables/useTaskEvents', async () => { + const { ref } = await import('vue') + return { + useTaskEvents: () => ({ + data: ref(null), isLoading: ref(false), + isError: ref(false), error: ref(null) }), + fetchOlderEvents: vi.fn(), + } +}) +vi.mock('@/composables/useTaskMutations', () => ({ + useTaskMutations: () => mutes, + canCancel: (s: string) => s === 'downloading', + canDelete: (s: string) => s === 'succeeded', +})) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +function mountPage() { + return import('@/pages/TaskDetail.vue').then((m) => + mount(m.default, { + props: { id: 'abc' }, + global: { plugins: [ElementPlus, i18n] }, + })) +} + +describe('TaskDetail (SP2)', () => { + beforeEach(() => { setActivePinia(createPinia()); detailData.value = null }) + + test('no data → DataBoundary empty (not crash)', async () => { + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + + test('data present → tabs render, AggregateRing shown', async () => { + detailData.value = { + id: 'abc', repo_id: 'o/m', revision: 'a'.repeat(40), + status: 'downloading', priority: 1, + created_at: '2026-05-20T00:00:00Z', completed_at: null, + error_message: null, subtasks: [], + } + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'ElTabs' }).exists()).toBe(true) + expect(w.findComponent({ name: 'AggregateRing' }).exists()).toBe(true) + }) + + test('terminal task → cancel hidden, delete shown', async () => { + detailData.value = { + id: 'abc', repo_id: 'o/m', revision: 'a'.repeat(40), + status: 'succeeded', priority: 1, + created_at: '2026-05-20T00:00:00Z', completed_at: null, + error_message: null, subtasks: [], + } + const w = await mountPage() + await flushPromises() + expect(w.text()).toContain(en.tasks.detail.delete) + }) +}) From 0ac67ee57374d728e884598e9107ba37d496d6d9 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 00:58:32 +0800 Subject: [PATCH 20/20] UI-SP2 M4: operator docs for the download-manager Task Detail --- docs/operator/web-ui.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/operator/web-ui.md b/docs/operator/web-ui.md index 934333a..d7dbd7f 100644 --- a/docs/operator/web-ui.md +++ b/docs/operator/web-ui.md @@ -84,3 +84,25 @@ chip is read-only (no tenant switcher); list filtering is client-side (no server-side filter endpoint yet). Cross-ref: `docs/getting-started.md`, `docs/operator/cli-sdk.md`. + +## UI-SP2 — Download-manager Task Detail + +`/tasks/:id` is a full download-accelerator view backed by four additive +read-only endpoints (zero migration): + +- `GET /api/v1/tasks/{id}/subtask-chunks` — per-file chunk segments +- `GET /api/v1/tasks/{id}/source-allocation` — per-source contribution + chunk routing +- `GET /api/v1/tasks/{id}/participating-executors` — executor swimlanes +- `GET /api/v1/tasks/{id}/events` — audit-derived event log (cursor-paginated) + +The page has a header (basic info + aggregate progress ring + client-derived +speed/ETA + cancel/delete) and four tabs (Files & chunks, Sources, Executors, +Events). All polling flows through the single `useLiveResource` seam; only the +active tab polls (others paused via the `enabled` option). + +Known limitations (intentional, deferred): speed/ETA are derived client-side +from successive byte-count polls (no backend speed source); retry/pause/upgrade +actions are not exposed (no endpoints); the file table uses a height-capped +`el-table` (true `el-table-v2` windowing is a documented follow-up); the event +log reads existing `audit_log` rows only; real-time push (SSE/WS) arrives in +UI-SP5 with no view changes.