diff --git a/api/openapi.yaml b/api/openapi.yaml index bda45b3..160f046 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -529,6 +529,34 @@ paths: items: {$ref: '#/components/schemas/TaskEvent'} next_cursor: {type: string, nullable: true} + /tasks/{taskId}/subtask-chunks: + parameters: + - $ref: '#/components/parameters/TaskId' + get: + tags: [tasks] + summary: Per-file chunk segments (download-manager visualization) + operationId: getSubtaskChunks + responses: + '200': + description: Subtask chunk report + content: + application/json: + schema: {$ref: '#/components/schemas/SubtaskChunkReport'} + + /tasks/{taskId}/participating-executors: + parameters: + - $ref: '#/components/parameters/TaskId' + get: + tags: [tasks] + summary: Executors participating in this task (swimlanes) + operationId: getParticipatingExecutors + responses: + '200': + description: Participating executors + content: + application/json: + schema: {$ref: '#/components/schemas/ParticipatingExecutors'} + # ========== Models ========== /models/search: get: @@ -1867,6 +1895,55 @@ components: type: object additionalProperties: true + SubtaskChunkReport: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [subtask_id, filename, status, bytes_downloaded, is_chunked, chunks_completed, chunks] + properties: + subtask_id: {type: string, format: uuid} + filename: {type: string} + file_size: {type: integer, format: int64, nullable: true} + status: {type: string} + bytes_downloaded: {type: integer, format: int64} + is_chunked: {type: boolean} + chunks_total: {type: integer, nullable: true} + chunks_completed: {type: integer} + chunks: + type: array + items: + type: object + required: [chunk_index, byte_start, byte_end, source_id, status, bytes_done] + properties: + chunk_index: {type: integer} + byte_start: {type: integer, format: int64} + byte_end: {type: integer, format: int64} + source_id: {type: string} + status: {type: string} + bytes_done: {type: integer, format: int64} + + ParticipatingExecutors: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [executor_id, assigned_subtasks, active_subtasks, bytes_downloaded] + properties: + executor_id: {type: string} + executor_status: {type: string, nullable: true} + health_score: {type: integer, nullable: true} + last_heartbeat_at: {type: string, format: date-time, nullable: true} + assigned_subtasks: {type: integer} + active_subtasks: {type: integer} + bytes_downloaded: {type: integer, format: int64} + # ===== Models ===== ModelSummary: type: object 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. 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..b08cc59 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-ui-sp2-task-detail.md @@ -0,0 +1,3223 @@ +# 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 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) + }) +}) +``` + +- [ ] **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. 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. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8e9e900..cd0b0b4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -73,3 +73,72 @@ export interface TaskCreateBody { trust_non_hf_sha256?: boolean upgrade_from_revision?: string } + +export interface ChunkSeg { + chunk_index: number + byte_start: number + byte_end: number + source_id: string + status: string + bytes_done: number +} + +export interface SubtaskChunkRow { + subtask_id: string + filename: string + file_size: number | null + status: string + bytes_downloaded: number + is_chunked: boolean + chunks_total: number | null + chunks_completed: number + chunks: ChunkSeg[] +} + +export interface SubtaskChunkReport { + items: SubtaskChunkRow[] +} + +export interface SourceUsed { + source_id: string + bytes_assigned: number + percent: number + measured_speed_bps: number +} + +export interface ChunkRouting { + filename: string + chunks: ChunkSeg[] +} + +export interface SourceAllocation { + task_id: string + sources_used: SourceUsed[] + chunk_level_routing: ChunkRouting[] +} + +export interface ParticipatingExecutor { + executor_id: string + executor_status: string | null + health_score: number | null + last_heartbeat_at: string | null + assigned_subtasks: number + active_subtasks: number + bytes_downloaded: number +} + +export interface ParticipatingExecutors { + items: ParticipatingExecutor[] +} + +export interface TaskEventItem { + ts: string + type: string + message: string + details: Record +} + +export interface TaskEventsResponse { + items: TaskEventItem[] + next_cursor: string | null +} diff --git a/frontend/src/components/taskdetail/AggregateRing.vue b/frontend/src/components/taskdetail/AggregateRing.vue new file mode 100644 index 0000000..2ff752a --- /dev/null +++ b/frontend/src/components/taskdetail/AggregateRing.vue @@ -0,0 +1,77 @@ + + + + + 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/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/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/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/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/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/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/src/composables/useDownloadRate.ts b/frontend/src/composables/useDownloadRate.ts new file mode 100644 index 0000000..c9ef211 --- /dev/null +++ b/frontend/src/composables/useDownloadRate.ts @@ -0,0 +1,67 @@ +import { onUnmounted, ref, watch, type Ref } from 'vue' + +export interface RateSample { t: number; bytes: number } +export interface RateResult { + currentBps: number + avgBps: number + etaSeconds: number | null +} + +const MAX_SAMPLES = 30 + +/** Pure: derive current (last-window) + average B/s and ETA from samples. */ +export function computeRate( + samples: RateSample[], bytesTotal: number | null, +): RateResult { + if (samples.length < 2) { + return { currentBps: 0, avgBps: 0, etaSeconds: null } + } + const first = samples[0] + const last = samples[samples.length - 1] + if (!first || !last) { + return { currentBps: 0, avgBps: 0, etaSeconds: null } + } + const spanSec = (last.t - first.t) / 1000 + const avgBps = spanSec > 0 + ? Math.max(0, (last.bytes - first.bytes) / spanSec) : 0 + + const tail = samples.slice(-5) + const tf = tail[0] + const tl = tail[tail.length - 1] + let currentBps = 0 + if (tf && tl && tl.t > tf.t) { + currentBps = Math.max(0, (tl.bytes - tf.bytes) / ((tl.t - tf.t) / 1000)) + } + + let etaSeconds: number | null = null + if (bytesTotal !== null && currentBps > 0) { + const remaining = Math.max(0, bytesTotal - last.bytes) + etaSeconds = remaining / currentBps + } + return { currentBps, avgBps, etaSeconds } +} + +/** Composable: sample a reactive byte counter over time → reactive RateResult. */ +export function useDownloadRate( + bytesDone: Ref, + bytesTotal: Ref, +) { + const samples = ref([]) + const result = ref({ + currentBps: 0, avgBps: 0, etaSeconds: null, + }) + + function recompute() { + result.value = computeRate(samples.value, bytesTotal.value ?? null) + } + + const stop = watch(bytesDone, (v) => { + if (v === null || v === undefined) return + samples.value.push({ t: Date.now(), bytes: v }) + if (samples.value.length > MAX_SAMPLES) samples.value.shift() + recompute() + }, { immediate: true }) + + onUnmounted(stop) + return { rate: result } +} diff --git a/frontend/src/composables/useLiveResource.ts b/frontend/src/composables/useLiveResource.ts index f3dec4d..b7f3309 100644 --- a/frontend/src/composables/useLiveResource.ts +++ b/frontend/src/composables/useLiveResource.ts @@ -1,4 +1,5 @@ import { useQuery, type QueryKey } from '@tanstack/vue-query' +import type { Ref } from 'vue' const ERROR_BACKOFF_MS = 5_000 const HIDDEN_MULTIPLIER = 3 @@ -15,6 +16,7 @@ export interface LiveOptions { baseIntervalMs: number isTerminal?: (data: T) => boolean staleTime?: number + enabled?: Ref | boolean } /** @@ -33,6 +35,7 @@ export function useLiveResource( return useQuery({ queryKey: key, queryFn: fetcher, + enabled: opts.enabled, staleTime: opts.staleTime ?? 0, refetchInterval: (query) => { const data = query.state.data as T | undefined diff --git a/frontend/src/composables/useParticipatingExecutors.ts b/frontend/src/composables/useParticipatingExecutors.ts new file mode 100644 index 0000000..681a80d --- /dev/null +++ b/frontend/src/composables/useParticipatingExecutors.ts @@ -0,0 +1,15 @@ +import type { Ref } from 'vue' +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { ParticipatingExecutors } from '@/api/types' + +export function useParticipatingExecutors( + taskId: Ref, enabled: Ref, terminal: Ref, +) { + return useLiveResource( + ['task-executors', taskId], + async () => (await client.get( + `/api/v1/tasks/${taskId.value}/participating-executors`)).data, + { baseIntervalMs: 2_000, enabled, isTerminal: () => terminal.value }, + ) +} diff --git a/frontend/src/composables/useSourceAllocation.ts b/frontend/src/composables/useSourceAllocation.ts new file mode 100644 index 0000000..57ee7de --- /dev/null +++ b/frontend/src/composables/useSourceAllocation.ts @@ -0,0 +1,15 @@ +import type { Ref } from 'vue' +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { SourceAllocation } from '@/api/types' + +export function useSourceAllocation( + taskId: Ref, enabled: Ref, terminal: Ref, +) { + return useLiveResource( + ['task-source-alloc', taskId], + async () => (await client.get( + `/api/v1/tasks/${taskId.value}/source-allocation`)).data, + { baseIntervalMs: 2_000, enabled, isTerminal: () => terminal.value }, + ) +} diff --git a/frontend/src/composables/useSubtaskChunks.ts b/frontend/src/composables/useSubtaskChunks.ts new file mode 100644 index 0000000..54c68f3 --- /dev/null +++ b/frontend/src/composables/useSubtaskChunks.ts @@ -0,0 +1,15 @@ +import type { Ref } from 'vue' +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { SubtaskChunkReport } from '@/api/types' + +export function useSubtaskChunks( + taskId: Ref, enabled: Ref, terminal: Ref, +) { + return useLiveResource( + ['task-chunks', taskId], + async () => (await client.get( + `/api/v1/tasks/${taskId.value}/subtask-chunks`)).data, + { baseIntervalMs: 1_500, enabled, isTerminal: () => terminal.value }, + ) +} diff --git a/frontend/src/composables/useTaskEvents.ts b/frontend/src/composables/useTaskEvents.ts new file mode 100644 index 0000000..9952ed1 --- /dev/null +++ b/frontend/src/composables/useTaskEvents.ts @@ -0,0 +1,24 @@ +import type { Ref } from 'vue' +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { TaskEventsResponse } from '@/api/types' + +export function useTaskEvents( + taskId: Ref, enabled: Ref, terminal: Ref, +) { + return useLiveResource( + ['task-events', taskId], + async () => (await client.get( + `/api/v1/tasks/${taskId.value}/events?limit=50`)).data, + { baseIntervalMs: 5_000, enabled, isTerminal: () => terminal.value }, + ) +} + +/** One-shot "load older" page (not live; appended in the page). */ +export async function fetchOlderEvents( + taskId: string, cursor: string, +): Promise { + return (await client.get( + `/api/v1/tasks/${taskId}/events?limit=50&cursor=${encodeURIComponent(cursor)}`, + )).data +} 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/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/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..bd14a92 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,29 @@ +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + +export function formatBytes(n: number | null | undefined): string { + if (n === null || n === undefined) return '—' + let v = n + let i = 0 + while (v >= 1024 && i < UNITS.length - 1) { + v /= 1024 + i++ + } + return `${v.toFixed(i === 0 ? 0 : 1)} ${UNITS[i] ?? 'B'}` +} + +export function formatRate(bytesPerSec: number | null | undefined): string { + if (!bytesPerSec || bytesPerSec <= 0) return '—' + return `${formatBytes(bytesPerSec)}/s` +} + +export function formatDuration(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined) return '—' + const s = Math.max(0, Math.floor(seconds)) + if (s === 0) return '0s' + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = s % 60 + if (h > 0) return `${h}h ${m}m` + if (m > 0) return `${m}m ${sec}s` + return `${sec}s` +} 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/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/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/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') + }) +}) 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) + }) +}) 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) + }) +}) diff --git a/frontend/tests/unit/computeRate.spec.ts b/frontend/tests/unit/computeRate.spec.ts new file mode 100644 index 0000000..fcbfa43 --- /dev/null +++ b/frontend/tests/unit/computeRate.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest' +import { computeRate } from '@/composables/useDownloadRate' + +describe('computeRate', () => { + test('empty / single sample → zero rate, null eta', () => { + expect(computeRate([], 100)).toEqual({ + currentBps: 0, avgBps: 0, etaSeconds: null, + }) + expect(computeRate([{ t: 0, bytes: 10 }], 100)).toEqual({ + currentBps: 0, avgBps: 0, etaSeconds: null, + }) + }) + test('linear progress → rate + eta', () => { + const r = computeRate( + [{ t: 0, bytes: 0 }, { t: 1000, bytes: 100 }, + { t: 2000, bytes: 200 }], 400) + expect(r.avgBps).toBeCloseTo(100, 5) + expect(r.currentBps).toBeGreaterThan(0) + expect(r.etaSeconds).not.toBeNull() + expect(r.etaSeconds as number).toBeGreaterThan(0) + }) + test('no total → null eta but rate still computed', () => { + const r = computeRate( + [{ t: 0, bytes: 0 }, { t: 1000, bytes: 50 }], null) + expect(r.avgBps).toBeCloseTo(50, 5) + expect(r.etaSeconds).toBeNull() + }) +}) 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') + }) +}) diff --git a/frontend/tests/unit/format.spec.ts b/frontend/tests/unit/format.spec.ts new file mode 100644 index 0000000..ebd78ef --- /dev/null +++ b/frontend/tests/unit/format.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest' +import { formatBytes, formatRate, formatDuration } from '@/utils/format' + +describe('format utils', () => { + test('formatBytes', () => { + expect(formatBytes(null)).toBe('—') + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(1024)).toBe('1.0 KB') + expect(formatBytes(1024 * 1024 * 3)).toBe('3.0 MB') + }) + test('formatRate', () => { + expect(formatRate(0)).toBe('—') + expect(formatRate(2048)).toBe('2.0 KB/s') + }) + test('formatDuration', () => { + expect(formatDuration(null)).toBe('—') + expect(formatDuration(0)).toBe('0s') + expect(formatDuration(65)).toBe('1m 5s') + expect(formatDuration(3661)).toBe('1h 1m') + }) +}) 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() + }) +}) 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') + }) +}) diff --git a/frontend/tests/unit/sp2Composables.spec.ts b/frontend/tests/unit/sp2Composables.spec.ts new file mode 100644 index 0000000..dd1a0b9 --- /dev/null +++ b/frontend/tests/unit/sp2Composables.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from 'vitest' +import { ref } from 'vue' + +const { get } = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('@/api/client', () => ({ client: { get } })) + +const { captured } = vi.hoisted(() => ({ + captured: [] as Array<{ key: unknown; opts: unknown }>, +})) +vi.mock('@/composables/useLiveResource', () => ({ + useLiveResource: (key: unknown, fetcher: () => unknown, opts: unknown) => { + captured.push({ key, opts }) + return { __fetcher: fetcher } + }, +})) + +import { useSubtaskChunks } from '@/composables/useSubtaskChunks' +import { useTaskEvents } from '@/composables/useTaskEvents' + +describe('SP2 live composables', () => { + test('useSubtaskChunks wires key, path, enabled, terminal', async () => { + captured.length = 0 + get.mockResolvedValueOnce({ data: { items: [] } }) + const id = ref('abc') + const enabled = ref(true) + const terminal = ref(false) + const q = useSubtaskChunks(id, enabled, terminal) as unknown as { + __fetcher: () => Promise + } + const last = captured[captured.length - 1] + expect(last?.key).toEqual(['task-chunks', id]) + expect((last?.opts as { enabled: unknown }).enabled).toBe(enabled) + await q.__fetcher() + expect(get).toHaveBeenCalledWith('/api/v1/tasks/abc/subtask-chunks') + }) + + test('useTaskEvents path + limit', async () => { + captured.length = 0 + get.mockResolvedValueOnce({ data: { items: [], next_cursor: null } }) + const id = ref('xyz') + const q = useTaskEvents(id, ref(true), ref(false)) as unknown as { + __fetcher: () => Promise + } + await q.__fetcher() + expect(get).toHaveBeenCalledWith('/api/v1/tasks/xyz/events?limit=50') + }) +}) diff --git a/frontend/tests/unit/sp2Types.spec.ts b/frontend/tests/unit/sp2Types.spec.ts new file mode 100644 index 0000000..0222855 --- /dev/null +++ b/frontend/tests/unit/sp2Types.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest' +import type { + SubtaskChunkReport, SourceAllocation, + ParticipatingExecutors, TaskEventsResponse, +} from '@/api/types' + +describe('SP2 DTO types', () => { + test('shapes compile and round-trip', () => { + const chunks: SubtaskChunkReport = { + items: [{ + subtask_id: 's', filename: 'f', file_size: 10, status: 'pending', + bytes_downloaded: 0, is_chunked: true, chunks_total: 1, + chunks_completed: 0, + chunks: [{ + chunk_index: 0, byte_start: 0, byte_end: 9, source_id: 'hf', + status: 'pending', bytes_done: 0, + }], + }], + } + const alloc: SourceAllocation = { + task_id: 't', sources_used: [{ + source_id: 'hf', bytes_assigned: 10, percent: 100, + measured_speed_bps: 0, + }], chunk_level_routing: [], + } + const ex: ParticipatingExecutors = { + items: [{ + executor_id: 'e', executor_status: 'healthy', health_score: 90, + last_heartbeat_at: null, assigned_subtasks: 1, active_subtasks: 1, + bytes_downloaded: 5, + }], + } + const ev: TaskEventsResponse = { + items: [{ ts: 'now', type: 'task.note', message: 'm', details: {} }], + next_cursor: null, + } + expect(chunks.items[0]?.chunks[0]?.source_id).toBe('hf') + expect(alloc.sources_used[0]?.percent).toBe(100) + expect(ex.items[0]?.executor_status).toBe('healthy') + expect(ev.items[0]?.type).toBe('task.note') + }) +}) diff --git a/frontend/tests/unit/useLiveResourceEnabled.spec.ts b/frontend/tests/unit/useLiveResourceEnabled.spec.ts new file mode 100644 index 0000000..aabaf5e --- /dev/null +++ b/frontend/tests/unit/useLiveResourceEnabled.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'vitest' +import type { LiveOptions } from '@/composables/useLiveResource' +import { computeInterval } from '@/composables/useLiveResource' + +describe('useLiveResource enabled option', () => { + test('LiveOptions accepts enabled: boolean', () => { + const o: LiveOptions = { baseIntervalMs: 1000, enabled: false } + expect(o.enabled).toBe(false) + }) + test('computeInterval still pure & unchanged', () => { + expect(computeInterval({ + base: 1000, terminal: false, hidden: false, errored: false, + })).toBe(1000) + }) +}) diff --git a/src/dlw/api/tasks.py b/src/dlw/api/tasks.py index c1fc454..99e33ae 100644 --- a/src/dlw/api/tasks.py +++ b/src/dlw/api/tasks.py @@ -3,7 +3,7 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.orm import selectinload @@ -15,6 +15,13 @@ from dlw.db.session import get_engine from dlw.db.tenant_scope import tenant_filtered from dlw.schemas.task import TaskCreate, TaskDetail, TaskList, TaskRead +from dlw.schemas.task_detail import ( + ParticipatingExecutors, + SourceAllocation, + SubtaskChunkReport, + TaskEventsResponse, +) +from dlw.services import task_detail as _td from dlw.services.audit import write_audit from dlw.services.hf_metadata import ( HfNetworkError, @@ -179,3 +186,65 @@ async def delete_task( await deref_subtask(session, sub.id) await session.delete(row) # FK cascade → subtasks → object refs await session.commit() + + +async def _task_in_tenant( + session: AsyncSession, task_id: uuid.UUID, principal: Principal, +) -> bool: + owned = await session.scalar( + tenant_filtered(select(DownloadTask.id) + .where(DownloadTask.id == task_id), + DownloadTask, principal)) + return owned is not None + + +@router.get("/{task_id}/subtask-chunks") +async def get_subtask_chunks( + task_id: uuid.UUID, + principal: Principal = Depends(require_perm("/api/v1/tasks*", "GET")), + session: AsyncSession = Depends(_session), +) -> SubtaskChunkReport: + if not await _task_in_tenant(session, task_id, principal): + raise HTTPException(status_code=404, detail="task not found") + return SubtaskChunkReport( + items=await _td.chunks_for_task(session, task_id, principal.tenant_id)) + + +@router.get("/{task_id}/source-allocation") +async def get_source_allocation( + task_id: uuid.UUID, + principal: Principal = Depends(require_perm("/api/v1/tasks*", "GET")), + session: AsyncSession = Depends(_session), +) -> SourceAllocation: + if not await _task_in_tenant(session, task_id, principal): + raise HTTPException(status_code=404, detail="task not found") + return await _td.source_allocation_for_task( + session, task_id, principal.tenant_id) + + +@router.get("/{task_id}/participating-executors") +async def get_participating_executors( + task_id: uuid.UUID, + principal: Principal = Depends(require_perm("/api/v1/tasks*", "GET")), + session: AsyncSession = Depends(_session), +) -> ParticipatingExecutors: + if not await _task_in_tenant(session, task_id, principal): + raise HTTPException(status_code=404, detail="task not found") + return ParticipatingExecutors( + items=await _td.executors_for_task( + session, task_id, principal.tenant_id)) + + +@router.get("/{task_id}/events") +async def get_task_events( + task_id: uuid.UUID, + limit: int = Query(50, ge=1, le=200), + cursor: str | None = Query(default=None), + principal: Principal = Depends(require_perm("/api/v1/tasks*", "GET")), + session: AsyncSession = Depends(_session), +) -> TaskEventsResponse: + if not await _task_in_tenant(session, task_id, principal): + raise HTTPException(status_code=404, detail="task not found") + items, next_cursor = await _td.events_for_task( + session, task_id, principal.tenant_id, limit, cursor) + return TaskEventsResponse(items=items, next_cursor=next_cursor) diff --git a/src/dlw/schemas/task_detail.py b/src/dlw/schemas/task_detail.py new file mode 100644 index 0000000..8fbc060 --- /dev/null +++ b/src/dlw/schemas/task_detail.py @@ -0,0 +1,78 @@ +"""UI-SP2 Task-Detail read-only DTOs (additive; mirrors api/openapi.yaml).""" +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ChunkSeg(BaseModel): + model_config = ConfigDict(from_attributes=True) + chunk_index: int + byte_start: int + byte_end: int + source_id: str + status: str + bytes_done: int + + +class SubtaskChunkRow(BaseModel): + subtask_id: uuid.UUID + filename: str + file_size: int | None + status: str + bytes_downloaded: int + is_chunked: bool + chunks_total: int | None + chunks_completed: int + chunks: list[ChunkSeg] + + +class SubtaskChunkReport(BaseModel): + items: list[SubtaskChunkRow] + + +class SourceUsed(BaseModel): + source_id: str + bytes_assigned: int + percent: float + measured_speed_bps: float + + +class ChunkRouting(BaseModel): + filename: str + chunks: list[ChunkSeg] + + +class SourceAllocation(BaseModel): + task_id: uuid.UUID + sources_used: list[SourceUsed] + chunk_level_routing: list[ChunkRouting] + + +class ParticipatingExecutor(BaseModel): + executor_id: str + executor_status: str | None + health_score: int | None + last_heartbeat_at: datetime | None + assigned_subtasks: int + active_subtasks: int + bytes_downloaded: int + + +class ParticipatingExecutors(BaseModel): + items: list[ParticipatingExecutor] + + +class TaskEvent(BaseModel): + ts: datetime + type: str + message: str + details: dict[str, Any] + + +class TaskEventsResponse(BaseModel): + items: list[TaskEvent] + next_cursor: str | None = None diff --git a/src/dlw/services/task_detail.py b/src/dlw/services/task_detail.py new file mode 100644 index 0000000..34f365d --- /dev/null +++ b/src/dlw/services/task_detail.py @@ -0,0 +1,204 @@ +"""UI-SP2 read-only aggregation helpers (additive; no writes, no state).""" +from __future__ import annotations + +import base64 +import uuid +from datetime import datetime + +from sqlalchemy import and_, false, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from dlw.db.models.audit import AuditLog +from dlw.db.models.executor import Executor +from dlw.db.models.source import SubtaskChunk +from dlw.db.models.task import FileSubTask +from dlw.schemas.task_detail import ( + ChunkRouting, + ChunkSeg, + ParticipatingExecutor, + SourceAllocation, + SourceUsed, + SubtaskChunkRow, + TaskEvent, +) + +_TERMINAL_SUBTASK = {"succeeded", "failed", "cancelled"} + + +async def chunks_for_task( + session: AsyncSession, task_id: uuid.UUID, tenant_id: int, +) -> list[SubtaskChunkRow]: + subs = (await session.execute( + select(FileSubTask) + .where(FileSubTask.task_id == task_id, + FileSubTask.tenant_id == tenant_id) + .order_by(FileSubTask.filename))).scalars().all() + if not subs: + return [] + sub_ids = [s.id for s in subs] + chunk_rows = (await session.execute( + select(SubtaskChunk) + .where(SubtaskChunk.subtask_id.in_(sub_ids)) + .order_by(SubtaskChunk.subtask_id, SubtaskChunk.chunk_index) + )).scalars().all() + by_sub: dict[uuid.UUID, list[ChunkSeg]] = {} + for c in chunk_rows: + by_sub.setdefault(c.subtask_id, []).append(ChunkSeg.model_validate(c)) + return [ + SubtaskChunkRow( + subtask_id=s.id, filename=s.filename, file_size=s.file_size, + status=s.status, bytes_downloaded=s.bytes_downloaded, + is_chunked=s.is_chunked, chunks_total=s.chunks_total, + chunks_completed=s.chunks_completed, + chunks=by_sub.get(s.id, []), + ) + for s in subs + ] + + +async def source_allocation_for_task( + session: AsyncSession, task_id: uuid.UUID, tenant_id: int, +) -> SourceAllocation: + subs = (await session.execute( + select(FileSubTask) + .where(FileSubTask.task_id == task_id, + FileSubTask.tenant_id == tenant_id) + .order_by(FileSubTask.filename))).scalars().all() + sub_ids = [s.id for s in subs] + chunk_rows = (await session.execute( + select(SubtaskChunk).where(SubtaskChunk.subtask_id.in_(sub_ids)) + .order_by(SubtaskChunk.subtask_id, SubtaskChunk.chunk_index) + )).scalars().all() if sub_ids else [] + + chunked_sub_ids = {c.subtask_id for c in chunk_rows} + by_source: dict[str, int] = {} + for s in subs: + if s.id in chunked_sub_ids: + continue # chunked files counted at chunk granularity below + if s.source_id: + by_source[s.source_id] = ( + by_source.get(s.source_id, 0) + int(s.file_size or 0)) + for c in chunk_rows: + by_source[c.source_id] = ( + by_source.get(c.source_id, 0) + + int(c.byte_end - c.byte_start + 1)) + + total = sum(by_source.values()) + sources_used = [ + SourceUsed( + source_id=sid, bytes_assigned=b, + percent=round(b / total * 100.0, 2) if total else 0.0, + measured_speed_bps=0.0, # no live speed source; client-derived + ) + for sid, b in sorted(by_source.items()) + ] + + routing_by_sub: dict[uuid.UUID, list[ChunkSeg]] = {} + for c in chunk_rows: + routing_by_sub.setdefault(c.subtask_id, []).append( + ChunkSeg.model_validate(c)) + name_by_id = {s.id: s.filename for s in subs} + chunk_level_routing = [ + ChunkRouting(filename=name_by_id[sid], chunks=segs) + for sid, segs in sorted( + routing_by_sub.items(), key=lambda kv: name_by_id[kv[0]]) + ] + return SourceAllocation( + task_id=task_id, sources_used=sources_used, + chunk_level_routing=chunk_level_routing) + + +async def executors_for_task( + session: AsyncSession, task_id: uuid.UUID, tenant_id: int, +) -> list[ParticipatingExecutor]: + subs = (await session.execute( + select(FileSubTask) + .where(FileSubTask.task_id == task_id, + FileSubTask.tenant_id == tenant_id, + FileSubTask.executor_id.isnot(None)))).scalars().all() + if not subs: + return [] + agg: dict[str, dict[str, int]] = {} + for s in subs: + eid = s.executor_id + if eid is None: + continue + a = agg.setdefault( + eid, {"assigned": 0, "active": 0, "bytes": 0}) + a["assigned"] += 1 + if s.status not in _TERMINAL_SUBTASK: + a["active"] += 1 + a["bytes"] += int(s.bytes_downloaded or 0) + ex_rows = (await session.execute( + select(Executor).where(Executor.id.in_(list(agg.keys())))) + ).scalars().all() + ex_by_id = {e.id: e for e in ex_rows} + out: list[ParticipatingExecutor] = [] + for eid, a in sorted(agg.items()): + e = ex_by_id.get(eid) + out.append(ParticipatingExecutor( + executor_id=eid, + executor_status=e.status if e else None, + health_score=e.health_score if e else None, + last_heartbeat_at=e.last_heartbeat_at if e else None, + assigned_subtasks=a["assigned"], + active_subtasks=a["active"], + bytes_downloaded=a["bytes"], + )) + return out + + +def _encode_cursor(occurred_at: datetime, row_id: int) -> str: + raw = f"{occurred_at.isoformat()}|{row_id}" + return base64.urlsafe_b64encode(raw.encode()).decode() + + +def _decode_cursor(cursor: str) -> tuple[datetime, int]: + raw = base64.urlsafe_b64decode(cursor.encode()).decode() + ts_str, id_str = raw.rsplit("|", 1) + return datetime.fromisoformat(ts_str), int(id_str) + + +async def events_for_task( + session: AsyncSession, task_id: uuid.UUID, tenant_id: int, + limit: int, cursor: str | None, +) -> tuple[list[TaskEvent], str | None]: + sub_ids = (await session.execute( + select(FileSubTask.id).where( + FileSubTask.task_id == task_id, + FileSubTask.tenant_id == tenant_id))).scalars().all() + sub_clause = ( + and_(AuditLog.resource_type == "subtask", + AuditLog.resource_id.in_([str(x) for x in sub_ids])) + if sub_ids else false() + ) + scope = or_( + and_(AuditLog.resource_type == "task", + AuditLog.resource_id == str(task_id)), + sub_clause, + ) + stmt = (select(AuditLog) + .where(AuditLog.tenant_id == tenant_id, scope) + .order_by(AuditLog.occurred_at.desc(), AuditLog.id.desc())) + if cursor: + c_ts, c_id = _decode_cursor(cursor) + stmt = stmt.where(or_( + AuditLog.occurred_at < c_ts, + and_(AuditLog.occurred_at == c_ts, AuditLog.id < c_id))) + rows = (await session.execute(stmt.limit(limit + 1))).scalars().all() + has_more = len(rows) > limit + rows = rows[:limit] + items = [ + TaskEvent( + ts=r.occurred_at, + type=r.action, + message=(f"{r.action} (denied)" if r.outcome == "denied" + else r.action), + details=r.payload or {}, + ) + for r in rows + ] + next_cursor = ( + _encode_cursor(rows[-1].occurred_at, rows[-1].id) + if has_more and rows else None) + return items, next_cursor diff --git a/tests/api/test_task_detail_chunks.py b/tests/api/test_task_detail_chunks.py new file mode 100644 index 0000000..fb4fcdb --- /dev/null +++ b/tests/api/test_task_detail_chunks.py @@ -0,0 +1,132 @@ +"""Tests for GET /api/v1/tasks/{id}/subtask-chunks (UI-SP2).""" +from __future__ import annotations + +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from dlw.config import get_settings +from dlw.db.base import Base +from tests.conftest import make_app_with_state, principal_headers + +SECRET = "unit-secret" + + +@pytest.fixture(scope="module", autouse=True) +async def _bootstrap(engine): + from dlw.db.models.storage import StorageBackend + from dlw.db.models.tenant import Project, Tenant, User + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + session.add(Tenant(id=1, slug="default", display_name="Default")) + await session.flush() + session.add(Project(id=1, tenant_id=1, name="default")) + session.add(User(id=1, tenant_id=1, oidc_subject="dev", + email="d@l", role="tenant_admin")) + session.add(StorageBackend(id=1, tenant_id=1, name="default", + backend_type="s3", config_encrypted=b"")) + await session.commit() + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(autouse=True) +def _set_token(monkeypatch: pytest.MonkeyPatch): + get_settings.cache_clear() + monkeypatch.setenv("DLW_SYSTEM_JWT_SECRET", SECRET) + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture(autouse=True) +def _patch_hf(monkeypatch: pytest.MonkeyPatch): + from dlw.services.hf_metadata import RepoFile + + async def fake(*args, **kwargs): + return [ + RepoFile(path="config.json", size=4096, sha256=None), + RepoFile(path="model.safetensors", size=64 * 1024, sha256="a" * 64), + ] + monkeypatch.setattr("dlw.services.task_service.list_repo_tree", fake) + + +@pytest.fixture +def auth() -> dict[str, str]: + return principal_headers(secret=SECRET, role="tenant_admin") + + +@pytest.fixture +async def client(ephemeral_ca): + app = make_app_with_state(ephemeral_ca, enrollment_token="e") + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://test") as c: + yield c + + +async def _make_task(client, auth) -> str: + r = await client.post("/api/v1/tasks", json={ + "repo_id": "o/chunks", "revision": "3" * 40, "storage_id": 1, + }, headers=auth) + assert r.status_code == 201, r.text + return r.json()["id"] + + +@pytest.mark.slow +async def test_subtask_chunks_unauthenticated_401(client: AsyncClient) -> None: + r = await client.get(f"/api/v1/tasks/{uuid.uuid4()}/subtask-chunks") + assert r.status_code == 401 + + +@pytest.mark.slow +async def test_subtask_chunks_cross_tenant_404(client: AsyncClient, auth) -> None: + tid = await _make_task(client, auth) + other = principal_headers(secret=SECRET, role="tenant_admin", + user_id=9, tenant_id=2) + r = await client.get(f"/api/v1/tasks/{tid}/subtask-chunks", headers=other) + assert r.status_code == 404 + + +@pytest.mark.slow +async def test_subtask_chunks_happy_and_aggregation( + client: AsyncClient, auth, engine, +) -> None: + tid = await _make_task(client, auth) + from dlw.db.models.source import SubtaskChunk + from dlw.db.models.task import FileSubTask + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as s: + subs = (await s.execute( + select(FileSubTask).where(FileSubTask.task_id == uuid.UUID(tid)) + .order_by(FileSubTask.filename))).scalars().all() + assert len(subs) == 2 + big = subs[1] + big.is_chunked = True + big.file_size = 1000 + big.bytes_downloaded = 600 + s.add(SubtaskChunk(subtask_id=big.id, chunk_index=0, byte_start=0, + byte_end=499, source_id="hf", status="succeeded", + bytes_done=500)) + s.add(SubtaskChunk(subtask_id=big.id, chunk_index=1, byte_start=500, + byte_end=999, source_id="modelscope", + status="pending", bytes_done=100)) + await s.commit() + + r = await client.get(f"/api/v1/tasks/{tid}/subtask-chunks", headers=auth) + assert r.status_code == 200, r.text + body = r.json() + assert len(body["items"]) == 2 + by_name = {i["filename"]: i for i in body["items"]} + chunked = by_name["model.safetensors"] + assert chunked["is_chunked"] is True + assert len(chunked["chunks"]) == 2 + assert chunked["chunks"][0]["source_id"] == "hf" + assert chunked["chunks"][1]["bytes_done"] == 100 + assert by_name["config.json"]["chunks"] == [] diff --git a/tests/api/test_task_detail_events.py b/tests/api/test_task_detail_events.py new file mode 100644 index 0000000..1071f71 --- /dev/null +++ b/tests/api/test_task_detail_events.py @@ -0,0 +1,135 @@ +"""Tests for GET /api/v1/tasks/{id}/events (UI-SP2, audit-derived).""" +from __future__ import annotations + +import datetime as dt +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker + +from dlw.config import get_settings +from dlw.db.base import Base +from tests.conftest import make_app_with_state, principal_headers + +SECRET = "unit-secret" + + +@pytest.fixture(scope="module", autouse=True) +async def _bootstrap(engine): + from dlw.db.models.storage import StorageBackend + from dlw.db.models.tenant import Project, Tenant, User + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + session.add(Tenant(id=1, slug="default", display_name="Default")) + await session.flush() + session.add(Project(id=1, tenant_id=1, name="default")) + session.add(User(id=1, tenant_id=1, oidc_subject="dev", + email="d@l", role="tenant_admin")) + session.add(StorageBackend(id=1, tenant_id=1, name="default", + backend_type="s3", config_encrypted=b"")) + await session.commit() + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(autouse=True) +def _set_token(monkeypatch: pytest.MonkeyPatch): + get_settings.cache_clear() + monkeypatch.setenv("DLW_SYSTEM_JWT_SECRET", SECRET) + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture(autouse=True) +def _patch_hf(monkeypatch: pytest.MonkeyPatch): + from dlw.services.hf_metadata import RepoFile + + async def fake(*args, **kwargs): + return [ + RepoFile(path="config.json", size=4096, sha256=None), + RepoFile(path="model.safetensors", size=64 * 1024, sha256="a" * 64), + ] + monkeypatch.setattr("dlw.services.task_service.list_repo_tree", fake) + + +@pytest.fixture +def auth() -> dict[str, str]: + return principal_headers(secret=SECRET, role="tenant_admin") + + +@pytest.fixture +async def client(ephemeral_ca): + app = make_app_with_state(ephemeral_ca, enrollment_token="e") + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://test") as c: + yield c + + +async def _make_task(client, auth) -> str: + r = await client.post("/api/v1/tasks", json={ + "repo_id": "o/events", "revision": "3" * 40, "storage_id": 1, + }, headers=auth) + assert r.status_code == 201, r.text + return r.json()["id"] + + +@pytest.mark.slow +async def test_events_unauthenticated_401(client: AsyncClient) -> None: + r = await client.get(f"/api/v1/tasks/{uuid.uuid4()}/events") + assert r.status_code == 401 + + +@pytest.mark.slow +async def test_events_cross_tenant_404(client: AsyncClient, auth) -> None: + tid = await _make_task(client, auth) + other = principal_headers(secret=SECRET, role="tenant_admin", + user_id=9, tenant_id=2) + r = await client.get(f"/api/v1/tasks/{tid}/events", headers=other) + assert r.status_code == 404 + + +@pytest.mark.slow +async def test_events_returns_audit_rows_and_paginates( + client: AsyncClient, auth, engine, +) -> None: + from dlw.db.models.audit import AuditLog + tid = await _make_task(client, auth) + factory = async_sessionmaker(engine, expire_on_commit=False) + base = dt.datetime(2026, 5, 20, 12, 0, 0, tzinfo=dt.UTC) + async with factory() as s: + for i in range(3): + s.add(AuditLog( + occurred_at=base + dt.timedelta(seconds=i), + tenant_id=1, actor_user_id=1, + action="task.note", resource_type="task", + resource_id=tid, outcome="success", + payload={"i": i}, self_hash="0" * 64)) + s.add(AuditLog( + occurred_at=base + dt.timedelta(seconds=9), + tenant_id=1, actor_user_id=1, action="task.denied", + resource_type="task", resource_id=tid, outcome="denied", + payload=None, self_hash="0" * 64)) + await s.commit() + + r = await client.get(f"/api/v1/tasks/{tid}/events?limit=2", headers=auth) + assert r.status_code == 200, r.text + body = r.json() + assert len(body["items"]) == 2 + assert body["next_cursor"] + assert body["items"][0]["type"] == "task.denied" + assert "denied" in body["items"][0]["message"] + assert body["items"][0]["details"] == {} + + r2 = await client.get( + f"/api/v1/tasks/{tid}/events?limit=2&cursor={body['next_cursor']}", + headers=auth) + assert r2.status_code == 200 + page2 = r2.json()["items"] + assert len(page2) == 2 + assert page2[0]["ts"] != body["items"][0]["ts"] diff --git a/tests/api/test_task_detail_executors.py b/tests/api/test_task_detail_executors.py new file mode 100644 index 0000000..e0fc472 --- /dev/null +++ b/tests/api/test_task_detail_executors.py @@ -0,0 +1,127 @@ +"""Tests for GET /api/v1/tasks/{id}/participating-executors (UI-SP2).""" +from __future__ import annotations + +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from dlw.config import get_settings +from dlw.db.base import Base +from tests.conftest import make_app_with_state, principal_headers + +SECRET = "unit-secret" + + +@pytest.fixture(scope="module", autouse=True) +async def _bootstrap(engine): + from dlw.db.models.storage import StorageBackend + from dlw.db.models.tenant import Project, Tenant, User + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + session.add(Tenant(id=1, slug="default", display_name="Default")) + await session.flush() + session.add(Project(id=1, tenant_id=1, name="default")) + session.add(User(id=1, tenant_id=1, oidc_subject="dev", + email="d@l", role="tenant_admin")) + session.add(StorageBackend(id=1, tenant_id=1, name="default", + backend_type="s3", config_encrypted=b"")) + await session.commit() + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(autouse=True) +def _set_token(monkeypatch: pytest.MonkeyPatch): + get_settings.cache_clear() + monkeypatch.setenv("DLW_SYSTEM_JWT_SECRET", SECRET) + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture(autouse=True) +def _patch_hf(monkeypatch: pytest.MonkeyPatch): + from dlw.services.hf_metadata import RepoFile + + async def fake(*args, **kwargs): + return [ + RepoFile(path="config.json", size=4096, sha256=None), + RepoFile(path="model.safetensors", size=64 * 1024, sha256="a" * 64), + ] + monkeypatch.setattr("dlw.services.task_service.list_repo_tree", fake) + + +@pytest.fixture +def auth() -> dict[str, str]: + return principal_headers(secret=SECRET, role="tenant_admin") + + +@pytest.fixture +async def client(ephemeral_ca): + app = make_app_with_state(ephemeral_ca, enrollment_token="e") + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://test") as c: + yield c + + +async def _make_task(client, auth) -> str: + r = await client.post("/api/v1/tasks", json={ + "repo_id": "o/exec", "revision": "3" * 40, "storage_id": 1, + }, headers=auth) + assert r.status_code == 201, r.text + return r.json()["id"] + + +@pytest.mark.slow +async def test_executors_unauthenticated_401(client: AsyncClient) -> None: + r = await client.get( + f"/api/v1/tasks/{uuid.uuid4()}/participating-executors") + assert r.status_code == 401 + + +@pytest.mark.slow +async def test_executors_cross_tenant_404(client: AsyncClient, auth) -> None: + tid = await _make_task(client, auth) + other = principal_headers(secret=SECRET, role="tenant_admin", + user_id=9, tenant_id=2) + r = await client.get( + f"/api/v1/tasks/{tid}/participating-executors", headers=other) + assert r.status_code == 404 + + +@pytest.mark.slow +async def test_executors_happy_and_join( + client: AsyncClient, auth, engine, +) -> None: + from dlw.db.models.executor import Executor + from dlw.db.models.task import FileSubTask + tid = await _make_task(client, auth) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as s: + s.add(Executor(id="exec-1", host_id="h1", cert_fingerprint="fp", + status="healthy", epoch=1, health_score=88)) + subs = (await s.execute( + select(FileSubTask).where(FileSubTask.task_id == uuid.UUID(tid)) + )).scalars().all() + for sub in subs: + sub.executor_id = "exec-1" + sub.bytes_downloaded = 1000 + await s.commit() + r = await client.get( + f"/api/v1/tasks/{tid}/participating-executors", headers=auth) + assert r.status_code == 200, r.text + items = r.json()["items"] + assert len(items) == 1 + e = items[0] + assert e["executor_id"] == "exec-1" + assert e["executor_status"] == "healthy" + assert e["health_score"] == 88 + assert e["assigned_subtasks"] == 2 + assert e["bytes_downloaded"] == 2000 diff --git a/tests/api/test_task_detail_source_alloc.py b/tests/api/test_task_detail_source_alloc.py new file mode 100644 index 0000000..de03a9c --- /dev/null +++ b/tests/api/test_task_detail_source_alloc.py @@ -0,0 +1,122 @@ +"""Tests for GET /api/v1/tasks/{id}/source-allocation (UI-SP2).""" +from __future__ import annotations + +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from dlw.config import get_settings +from dlw.db.base import Base +from tests.conftest import make_app_with_state, principal_headers + +SECRET = "unit-secret" + + +@pytest.fixture(scope="module", autouse=True) +async def _bootstrap(engine): + from dlw.db.models.storage import StorageBackend + from dlw.db.models.tenant import Project, Tenant, User + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + session.add(Tenant(id=1, slug="default", display_name="Default")) + await session.flush() + session.add(Project(id=1, tenant_id=1, name="default")) + session.add(User(id=1, tenant_id=1, oidc_subject="dev", + email="d@l", role="tenant_admin")) + session.add(StorageBackend(id=1, tenant_id=1, name="default", + backend_type="s3", config_encrypted=b"")) + await session.commit() + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(autouse=True) +def _set_token(monkeypatch: pytest.MonkeyPatch): + get_settings.cache_clear() + monkeypatch.setenv("DLW_SYSTEM_JWT_SECRET", SECRET) + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture(autouse=True) +def _patch_hf(monkeypatch: pytest.MonkeyPatch): + from dlw.services.hf_metadata import RepoFile + + async def fake(*args, **kwargs): + return [ + RepoFile(path="config.json", size=4096, sha256=None), + RepoFile(path="model.safetensors", size=64 * 1024, sha256="a" * 64), + ] + monkeypatch.setattr("dlw.services.task_service.list_repo_tree", fake) + + +@pytest.fixture +def auth() -> dict[str, str]: + return principal_headers(secret=SECRET, role="tenant_admin") + + +@pytest.fixture +async def client(ephemeral_ca): + app = make_app_with_state(ephemeral_ca, enrollment_token="e") + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://test") as c: + yield c + + +async def _make_task(client, auth) -> str: + r = await client.post("/api/v1/tasks", json={ + "repo_id": "o/alloc", "revision": "3" * 40, "storage_id": 1, + }, headers=auth) + assert r.status_code == 201, r.text + return r.json()["id"] + + +@pytest.mark.slow +async def test_source_alloc_unauthenticated_401(client: AsyncClient) -> None: + r = await client.get(f"/api/v1/tasks/{uuid.uuid4()}/source-allocation") + assert r.status_code == 401 + + +@pytest.mark.slow +async def test_source_alloc_cross_tenant_404(client: AsyncClient, auth) -> None: + tid = await _make_task(client, auth) + other = principal_headers(secret=SECRET, role="tenant_admin", + user_id=9, tenant_id=2) + r = await client.get(f"/api/v1/tasks/{tid}/source-allocation", + headers=other) + assert r.status_code == 404 + + +@pytest.mark.slow +async def test_source_alloc_percent_sums_100( + client: AsyncClient, auth, engine, +) -> None: + from dlw.db.models.task import FileSubTask + tid = await _make_task(client, auth) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as s: + subs = (await s.execute( + select(FileSubTask).where(FileSubTask.task_id == uuid.UUID(tid)) + .order_by(FileSubTask.filename))).scalars().all() + subs[0].source_id = "hf" + subs[0].file_size = 400 + subs[1].source_id = "modelscope" + subs[1].file_size = 600 + await s.commit() + r = await client.get(f"/api/v1/tasks/{tid}/source-allocation", + headers=auth) + assert r.status_code == 200, r.text + body = r.json() + assert body["task_id"] == tid + pct = sum(x["percent"] for x in body["sources_used"]) + assert 99.0 <= pct <= 101.0 + ids = {x["source_id"] for x in body["sources_used"]} + assert ids == {"hf", "modelscope"}