diff --git a/docs/operator/web-ui.md b/docs/operator/web-ui.md
new file mode 100644
index 0000000..934333a
--- /dev/null
+++ b/docs/operator/web-ui.md
@@ -0,0 +1,86 @@
+# Web UI — Operator/User Guide (UI-SP1)
+
+> UI-SP1 ships the **app shell + auth + Dashboard + Task List + Task Create**.
+> It is **frontend-only** (no backend/API change) and runs on the existing
+> controller. The full 9-page vision is decomposed — see §5.
+> Spec: `docs/superpowers/specs/2026-05-19-ui-sp1-shell-tasks-design.md`.
+> Local deploy of the controller: `docs/operator/local-deployment.md`.
+
+---
+
+## 1. What UI-SP1 delivers
+
+- **App shell**: collapsible sidebar + topbar, tenant/role chip (from JWT),
+ dark-mode toggle, zh/en locale toggle, **command palette (Ctrl/⌘+K)** for
+ nav + "create task" + "open task by id".
+- **Auth**: paste a tenant-user JWT, or "Sign in with OIDC" button
+ (`/api/v1/auth/login`). 401 → auto sign-out.
+- **Dashboard** (`/`): KPI cards (in-progress/completed/failed/total,
+ client-aggregated from the task list), a 24h created-count sparkline,
+ quota summary (`/api/v1/quota/current`), recent tasks.
+- **Task List** (`/tasks`): client-side status filter + repo/id search,
+ per-row actions (view / cancel non-terminal / delete terminal) with
+ optimistic refresh.
+- **Task Create** (`/tasks/new`): repo / revision (40-hex) / storage_id /
+ priority / source-strategy / upgrade-from / trust-non-hf, validation,
+ friendly error mapping (409/422/429/403/5xx), success → task detail.
+- Realtime via a single `useLiveResource` seam (adaptive polling: faster
+ on detail, slower on lists, ×3 when the tab is hidden, stops at terminal,
+ backs off on error). UI-SP5 will swap this to SSE/WS with **zero view
+ changes**.
+
+## 2. Run it
+
+```bash
+# controller (browser-friendly plain-HTTP instance) — see local-deployment.md
+# → http://localhost:8001
+cd frontend && pnpm install && pnpm dev # → http://localhost:5173
+# Vite proxies /api,/health → DLW_API_PROXY (default http://localhost:8001)
+```
+
+Open `http://localhost:5173`, paste a **tenant-user JWT** on the login page.
+
+## 3. The token (important)
+
+Use a **tenant-user JWT** (`user_id` matching a real `users` row), **not
+the system-admin service token**: the admin token is `user_id=0` and
+`download_tasks.owner_user_id` has an FK to `users` — creating a task with
+it fails (HTTP 500). The Task Create page detects a service token and
+**disables submit** with a clear warning. Mint a tenant-user JWT:
+
+```bash
+uv run python -c "from dlw.auth.principal import issue_system_jwt; \
+ print(issue_system_jwt(secret='dev-system-jwt-change-me', user_id=1, \
+ tenant_id=1, role='tenant_admin', project_ids=[], ttl_seconds=2592000))"
+```
+
+(30-day token for convenience during manual testing; production uses OIDC.)
+
+## 4. Keyboard / UX notes
+
+- **Ctrl/⌘+K** — command palette (navigate, create task, open task by id).
+- Dark mode + locale persist (localStorage), default from
+ `prefers-color-scheme`.
+- Every page has uniform loading / empty / error / forbidden states
+ (`DataBoundary`).
+
+## 5. Decomposition — what's deferred (and why)
+
+UI-SP1 is the first of 5 UI sub-projects (the full design needs additive
+backend endpoints that don't exist yet):
+
+| Sub-project | Scope | Backend it needs |
+|---|---|---|
+| **UI-SP1** (this) | shell + auth + dashboard + list + create | none (existing API) |
+| UI-SP2 | download-manager Task Detail (aggregate ring → per-source bar → virtualized chunk-segmented file table → executor swimlanes → event log) + task/file/chunk actions | new read endpoints: subtask-chunks, source-allocation, participating-executors, task-events |
+| UI-SP3 | Executors (host-grouped, drain/restart), Quota metering, Audit log, Settings | `GET /executors`, audit query endpoint |
+| UI-SP4 | AI-Copilot conversational UI (right slide-over, SSE, tool-call/confirm cards, ⌘K) | full AI backend (`/api/ai/chat` SSE, conversation persistence, LLM bridge, MCP→REST tool bridge) |
+| UI-SP5 | realtime upgrade: `useLiveResource` → SSE/WS, zero view change | backend SSE/WS |
+
+**Known UI-SP1 scope limits:** Task Detail is still the simple scaffold view
+(UI-SP2 makes it the download-manager view); no Executors/Search/Quota-mgmt/
+Audit/Settings/Copilot pages; Dashboard aggregates are client-side; tenant
+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`.
diff --git a/docs/superpowers/plans/2026-05-19-ui-sp1-shell-tasks.md b/docs/superpowers/plans/2026-05-19-ui-sp1-shell-tasks.md
new file mode 100644
index 0000000..1d78ed7
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-ui-sp1-shell-tasks.md
@@ -0,0 +1,2275 @@
+# UI-SP1 — App Shell + Auth + Tasks Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (project's validated variant: 2 opus pre-execution reviewers → implementer/controller per task → controller milestone E2E + frontend CI gates → opus final review → PR). Steps use `- [ ]`.
+
+**Goal:** Turn the 3-page read-only scaffold into a usable app shell where a user can create + monitor download tasks from the browser.
+
+**Architecture:** Frontend-only (`frontend/**` only — NO `src/dlw`, `api/openapi.yaml`, `alembic`, `tools`). Reuse the scaffold's conventions. New foundation (`useLiveResource`, `DataBoundary`, `ui`/`session` stores, nav registry, design tokens, en-US locale) → app shell (sidebar+topbar+command palette) → pages (Dashboard, TaskList upgrade, TaskCreate). All realtime via the single `useLiveResource` seam (SP5 swaps to SSE/WS, zero view change).
+
+**Tech Stack:** Vue 3.5 `
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/DataBoundary.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/components/DataBoundary.vue frontend/tests/unit/DataBoundary.spec.ts
+git commit -m "feat(ui-sp1): DataBoundary state wrapper"
+```
+
+---
+
+### Task 7: nav registry + role filtering
+
+**Files:** Create `src/nav/registry.ts`; Test `tests/unit/nav.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/nav.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { NAV_ITEMS, visibleNav } from '@/nav/registry'
+
+describe('nav registry', () => {
+ test('all items have route + labelKey', () => {
+ for (const i of NAV_ITEMS) {
+ expect(i.route).toBeTruthy()
+ expect(i.labelKey).toMatch(/^nav\./)
+ }
+ })
+ test('visibleNav: no roles → visible to everyone', () => {
+ const names = visibleNav('guest').map((i) => i.route)
+ expect(names).toContain('taskList')
+ expect(names).toContain('dashboard')
+ })
+ test('role-gated item hidden for wrong role', () => {
+ const gated = { route: 'x', labelKey: 'nav.x', icon: 'i', roles: ['system_admin'] }
+ expect(visibleNav('tenant_admin', [...NAV_ITEMS, gated]).map((i) => i.route))
+ .not.toContain('x')
+ expect(visibleNav('system_admin', [...NAV_ITEMS, gated]).map((i) => i.route))
+ .toContain('x')
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/nav.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement** `frontend/src/nav/registry.ts`:
+```ts
+export interface NavItem {
+ route: string // route name
+ labelKey: string // i18n key under nav.*
+ icon: string // element-plus icon component name
+ roles?: string[] // if set, visible only to these roles
+}
+
+export const NAV_ITEMS: NavItem[] = [
+ { route: 'dashboard', labelKey: 'nav.dashboard', icon: 'Odometer' },
+ { route: 'taskList', labelKey: 'nav.tasks', icon: 'List' },
+ { route: 'taskCreate', labelKey: 'nav.createTask', icon: 'Plus' },
+]
+
+export function visibleNav(role: string, items: NavItem[] = NAV_ITEMS): NavItem[] {
+ return items.filter((i) => !i.roles || i.roles.includes(role))
+}
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/nav.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/nav/registry.ts frontend/tests/unit/nav.spec.ts
+git commit -m "feat(ui-sp1): nav registry + role filtering"
+```
+
+---
+
+# Milestone M2 — Shell
+
+### Task 8: AppShell (sidebar + topbar) + App.vue conditional layout
+
+**Files:** Create `src/components/shell/AppShell.vue`; Modify `src/App.vue`; Test `tests/unit/AppShell.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/AppShell.spec.ts`:
+```ts
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import { createI18n } from 'vue-i18n'
+import AppShell from '@/components/shell/AppShell.vue'
+import zh from '@/locale/zh-CN.json'
+import { useAuthStore } from '@/stores/auth'
+
+const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } })
+const push = vi.fn()
+vi.mock('vue-router', () => ({
+ useRouter: () => ({ push }),
+ useRoute: () => ({ name: 'taskList' }),
+ RouterView: { template: '
' },
+}))
+
+function mountShell() {
+ return mount(AppShell, { global: { plugins: [ElementPlus, i18n] } })
+}
+
+describe('AppShell', () => {
+ beforeEach(() => { setActivePinia(createPinia()); push.mockClear() })
+
+ test('authenticated → renders nav items', () => {
+ useAuthStore().login('h.' + btoa(JSON.stringify(
+ { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s')
+ const w = mountShell()
+ expect(w.text()).toContain(zh.nav.tasks)
+ expect(w.text()).toContain(zh.nav.dashboard)
+ })
+
+ test('logout calls auth.logout + redirects', async () => {
+ const auth = useAuthStore()
+ auth.login('h.' + btoa(JSON.stringify(
+ { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s')
+ const w = mountShell()
+ await w.find('[data-test=logout]').trigger('click')
+ expect(auth.isAuthenticated).toBe(false)
+ expect(push).toHaveBeenCalledWith('/login')
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/AppShell.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement** `frontend/src/components/shell/AppShell.vue`:
+```vue
+
+
+
+
+
+
+

+
{{ t('app.title') }}
+
+
+
+ {{ t(i.labelKey) }}
+
+
+
+
+
+
+
+ ☰
+
+ {{ t('shell.commandHint') }}
+
+
+ {{ t('shell.tenant') }} {{ session.principal.tenantId }} ·
+ {{ session.principal.role }}
+
+
+ {{ ui.theme === 'dark' ? '🌙' : '☀️' }}
+
+
+ {{ ui.locale === 'zh-CN' ? 'EN' : '中' }}
+
+
+ {{ t('app.logout') }}
+
+
+
+
+
+
+
+
+
+
+```
+Replace `frontend/src/App.vue`:
+```vue
+
+
+
+
+
+
+
+
+
+
+
+```
+(NOTE: `CommandPalette.vue` is created in Task 9; until then `App.vue` won't typecheck. To keep Task 8 self-contained, **Task 8 Step 3 also creates a minimal placeholder** `frontend/src/components/CommandPalette.vue`:
+```vue
+
+
+```
+Task 9 replaces it with the real component.)
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/AppShell.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck && pnpm build`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/components/shell/AppShell.vue frontend/src/components/CommandPalette.vue frontend/src/App.vue frontend/tests/unit/AppShell.spec.ts
+git commit -m "feat(ui-sp1): AppShell (sidebar+topbar) + conditional layout"
+```
+
+---
+
+### Task 9: CommandPalette (⌘/Ctrl+K)
+
+**Files:** Modify `src/components/CommandPalette.vue` (replace placeholder); Test `tests/unit/palette.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/palette.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { buildCommands } from '@/components/palette'
+
+describe('buildCommands', () => {
+ const t = (k: string) => k
+ test('includes nav items + create + open-by-id', () => {
+ const cmds = buildCommands('tenant_admin', t)
+ const ids = cmds.map((c) => c.id)
+ expect(ids).toContain('nav:dashboard')
+ expect(ids).toContain('nav:taskList')
+ expect(ids).toContain('action:createTask')
+ expect(ids).toContain('action:openTaskById')
+ })
+ test('role-gates nav', () => {
+ const cmds = buildCommands('guest', t)
+ expect(cmds.find((c) => c.id === 'nav:dashboard')).toBeTruthy()
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/palette.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement.** Create `frontend/src/components/palette.ts`:
+```ts
+import { visibleNav } from '@/nav/registry'
+
+export interface Command {
+ id: string
+ label: string
+ kind: 'nav' | 'action'
+ routeName?: string
+ action?: 'createTask' | 'openTaskById'
+}
+
+export function buildCommands(role: string, t: (k: string) => string): Command[] {
+ const nav: Command[] = visibleNav(role).map((i) => ({
+ id: `nav:${i.route}`, label: t(i.labelKey), kind: 'nav', routeName: i.route,
+ }))
+ const actions: Command[] = [
+ { id: 'action:createTask', label: t('palette.createTask'),
+ kind: 'action', action: 'createTask' },
+ { id: 'action:openTaskById', label: t('palette.openTaskById'),
+ kind: 'action', action: 'openTaskById' },
+ ]
+ return [...nav, ...actions]
+}
+```
+Replace `frontend/src/components/CommandPalette.vue`:
+```vue
+
+
+
+
+
+
+
+ {{ c.label }}
+ {{ c.kind === 'nav' ? t('palette.navGroup') : t('palette.actionGroup') }}
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/palette.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/components/palette.ts frontend/src/components/CommandPalette.vue frontend/tests/unit/palette.spec.ts
+git commit -m "feat(ui-sp1): command palette (Ctrl/Cmd+K)"
+```
+
+---
+
+### Task 10: router — add Dashboard/TaskList/TaskCreate routes
+
+**Files:** Modify `src/router/index.ts`; Test `tests/unit/router.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/router.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { routes } from '@/router'
+
+describe('routes', () => {
+ test('has dashboard / taskList / taskCreate / taskDetail / login', () => {
+ const byName = Object.fromEntries(
+ routes.filter((r) => r.name).map((r) => [r.name, r]))
+ expect(byName.dashboard?.path).toBe('/')
+ expect(byName.taskList?.path).toBe('/tasks')
+ expect(byName.taskCreate?.path).toBe('/tasks/new')
+ expect(byName.taskDetail?.path).toBe('/tasks/:id')
+ expect(byName.login?.meta?.public).toBe(true)
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/router.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement** `frontend/src/router/index.ts`:
+```ts
+import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+
+export const routes: RouteRecordRaw[] = [
+ {
+ path: '/login', name: 'login',
+ component: () => import('@/pages/Login.vue'),
+ meta: { public: true },
+ },
+ {
+ path: '/', name: 'dashboard',
+ component: () => import('@/pages/Dashboard.vue'),
+ },
+ {
+ path: '/tasks', name: 'taskList',
+ component: () => import('@/pages/TaskList.vue'),
+ },
+ {
+ path: '/tasks/new', name: 'taskCreate',
+ component: () => import('@/pages/TaskCreate.vue'),
+ },
+ {
+ path: '/tasks/:id', name: 'taskDetail',
+ component: () => import('@/pages/TaskDetail.vue'),
+ props: true,
+ },
+ { path: '/:pathMatch(.*)*', redirect: '/' },
+]
+
+const router = createRouter({ history: createWebHistory(), routes })
+
+router.beforeEach((to) => {
+ if (to.meta.public) return true
+ const auth = useAuthStore()
+ if (!auth.isAuthenticated) return { path: '/login' }
+ return true
+})
+
+export { router }
+export default router
+```
+
+- [ ] **Step 2b: Run** `pnpm test:unit -- tests/unit/router.spec.ts` → still FAIL until Dashboard/TaskCreate pages exist (dynamic imports are lazy; the route-config test doesn't import them, so it PASSES now). Expected PASS.
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/router.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck` (typecheck tolerates not-yet-created lazy page imports? No — `vue-tsc` resolves `import('@/pages/Dashboard.vue')`. **Therefore create empty placeholder pages now**: `frontend/src/pages/Dashboard.vue` and `frontend/src/pages/TaskCreate.vue` each:
+```vue
+
+
+```
+Tasks 11/14 replace them.) Re-run `pnpm typecheck` → green.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/router/index.ts frontend/src/pages/Dashboard.vue frontend/src/pages/TaskCreate.vue frontend/tests/unit/router.spec.ts
+git commit -m "feat(ui-sp1): routes for dashboard/tasks/create (+ placeholders)"
+```
+
+---
+
+# Milestone M3 — Pages
+
+### Task 11: Sparkline + Dashboard
+
+**Files:** Create `src/components/Sparkline.vue`, `src/composables/useQuota.ts`, `src/dashboard/aggregate.ts`; Modify `src/pages/Dashboard.vue`; Test `tests/unit/dashboard.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/dashboard.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { aggregateKpis, bucket24h } from '@/dashboard/aggregate'
+import type { TaskRead } from '@/api/types'
+
+const mk = (status: TaskRead['status'], createdAt: string): TaskRead => ({
+ id: Math.random().toString(36), repo_id: 'o/r', revision: 'a',
+ status, priority: 1, created_at: createdAt, completed_at: null,
+ error_message: null,
+})
+
+describe('dashboard aggregate', () => {
+ test('aggregateKpis counts by bucket', () => {
+ const k = aggregateKpis([
+ mk('downloading', '2026-05-19T00:00:00Z'),
+ mk('scheduling', '2026-05-19T00:00:00Z'),
+ mk('succeeded', '2026-05-19T00:00:00Z'),
+ mk('failed', '2026-05-19T00:00:00Z'),
+ ])
+ expect(k).toEqual({ inProgress: 2, completed: 1, failed: 1, total: 4 })
+ })
+ test('bucket24h returns 24 hourly counts within window', () => {
+ const now = new Date('2026-05-19T12:00:00Z')
+ const b = bucket24h([
+ mk('succeeded', '2026-05-19T11:30:00Z'),
+ mk('succeeded', '2026-05-19T11:45:00Z'),
+ mk('succeeded', '2026-05-10T00:00:00Z'), // outside 24h → excluded
+ ], now)
+ expect(b).toHaveLength(24)
+ expect(b.reduce((a, c) => a + c, 0)).toBe(2)
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/dashboard.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement.** Create `frontend/src/dashboard/aggregate.ts`:
+```ts
+import type { TaskRead, TaskStatus } from '@/api/types'
+
+const IN_PROGRESS: ReadonlySet = new Set([
+ 'pending', 'queued', 'scheduling', 'downloading',
+])
+
+export function aggregateKpis(tasks: TaskRead[]) {
+ let inProgress = 0, completed = 0, failed = 0
+ for (const t of tasks) {
+ if (IN_PROGRESS.has(t.status)) inProgress++
+ else if (t.status === 'succeeded') completed++
+ else if (t.status === 'failed') failed++
+ }
+ return { inProgress, completed, failed, total: tasks.length }
+}
+
+export function bucket24h(tasks: TaskRead[], now: Date = new Date()): number[] {
+ const buckets = new Array(24).fill(0)
+ const end = now.getTime()
+ const start = end - 24 * 3600_000
+ for (const t of tasks) {
+ const ts = new Date(t.created_at).getTime()
+ if (ts >= start && ts <= end) {
+ const idx = Math.min(23, Math.floor((ts - start) / 3600_000))
+ buckets[idx]++
+ }
+ }
+ return buckets
+}
+```
+Create `frontend/src/composables/useQuota.ts`:
+```ts
+import { useLiveResource } from '@/composables/useLiveResource'
+import { client } from '@/api/client'
+import type { QuotaCurrent } from '@/api/types'
+
+export function useQuota() {
+ return useLiveResource(
+ ['quota'],
+ async () => (await client.get('/api/v1/quota/current')).data,
+ { baseIntervalMs: 30_000, staleTime: 30_000 },
+ )
+}
+```
+Create `frontend/src/components/Sparkline.vue`:
+```vue
+
+
+
+
+
+```
+Replace `frontend/src/pages/Dashboard.vue`:
+```vue
+
+
+
+
+
{{ t('dashboard.heading') }}
+
+
+ {{ t('dashboard.inProgress') }}{{ kpi.inProgress }}
+ {{ t('dashboard.completed') }}{{ kpi.completed }}
+ {{ t('dashboard.failed') }}{{ kpi.failed }}
+ {{ t('dashboard.total') }}{{ kpi.total }}
+
+
+
+ {{ t('dashboard.trend') }}
+
+
+
+
+ {{ t('dashboard.quota') }}
+ {{ t('dashboard.quotaBytes') }}: {{ quota.bytes_used_month }} /
+ {{ quota.bytes_quota_month }}
+ {{ t('dashboard.quotaConcurrent') }}: {{ quota.concurrent_tasks }} /
+ {{ quota.concurrent_quota }}
+
+
+
+ {{ t('dashboard.recent') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('tasks.view') }}
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/dashboard.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/dashboard frontend/src/composables/useQuota.ts frontend/src/components/Sparkline.vue frontend/src/pages/Dashboard.vue frontend/tests/unit/dashboard.spec.ts
+git commit -m "feat(ui-sp1): Dashboard (KPIs + 24h sparkline + quota + recent)"
+```
+
+---
+
+### Task 12: task mutations (cancel/delete, optimistic)
+
+**Files:** Create `src/composables/useTaskMutations.ts`; Test `tests/unit/taskMutations.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/taskMutations.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { canCancel, canDelete } from '@/composables/useTaskMutations'
+
+describe('task action guards', () => {
+ test('canCancel: only non-terminal', () => {
+ expect(canCancel('downloading')).toBe(true)
+ expect(canCancel('pending')).toBe(true)
+ expect(canCancel('succeeded')).toBe(false)
+ expect(canCancel('failed')).toBe(false)
+ expect(canCancel('cancelled')).toBe(false)
+ })
+ test('canDelete: only terminal', () => {
+ expect(canDelete('succeeded')).toBe(true)
+ expect(canDelete('failed')).toBe(true)
+ expect(canDelete('cancelled')).toBe(true)
+ expect(canDelete('downloading')).toBe(false)
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/taskMutations.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement** `frontend/src/composables/useTaskMutations.ts`:
+```ts
+import { useMutation, useQueryClient } from '@tanstack/vue-query'
+import { client } from '@/api/client'
+import { TERMINAL_STATUSES, type TaskStatus } from '@/api/types'
+
+export function canCancel(status: TaskStatus): boolean {
+ return !TERMINAL_STATUSES.has(status)
+}
+export function canDelete(status: TaskStatus): boolean {
+ return TERMINAL_STATUSES.has(status)
+}
+
+export function useTaskMutations() {
+ const qc = useQueryClient()
+ const invalidate = () => qc.invalidateQueries({ queryKey: ['tasks'] })
+
+ const cancel = useMutation({
+ mutationFn: (id: string) =>
+ client.post(`/api/v1/tasks/${id}/cancel`, {}),
+ onSettled: invalidate,
+ })
+ const remove = useMutation({
+ mutationFn: (id: string) => client.delete(`/api/v1/tasks/${id}`),
+ onSettled: invalidate,
+ })
+ return { cancel, remove }
+}
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/taskMutations.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/composables/useTaskMutations.ts frontend/tests/unit/taskMutations.spec.ts
+git commit -m "feat(ui-sp1): task cancel/delete mutations + guards"
+```
+
+---
+
+### Task 13: TaskList upgrade (filter + actions)
+
+**Files:** Create `src/tasks/filter.ts`; Modify `src/pages/TaskList.vue`; Test `tests/unit/taskFilter.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/taskFilter.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { filterTasks } from '@/tasks/filter'
+import type { TaskRead } from '@/api/types'
+
+const mk = (id: string, repo: string, status: TaskRead['status']): TaskRead => ({
+ id, repo_id: repo, revision: 'abc', status, priority: 1,
+ created_at: '2026-05-19T00:00:00Z', completed_at: null, error_message: null,
+})
+const items = [
+ mk('aaaa1111', 'org/alpha', 'downloading'),
+ mk('bbbb2222', 'org/beta', 'succeeded'),
+]
+
+describe('filterTasks', () => {
+ test('no filter → all', () => {
+ expect(filterTasks(items, { status: '', q: '' })).toHaveLength(2)
+ })
+ test('status filter', () => {
+ expect(filterTasks(items, { status: 'succeeded', q: '' }).map((t) => t.id))
+ .toEqual(['bbbb2222'])
+ })
+ test('q matches repo or id (case-insensitive)', () => {
+ expect(filterTasks(items, { status: '', q: 'ALPHA' }).map((t) => t.id))
+ .toEqual(['aaaa1111'])
+ expect(filterTasks(items, { status: '', q: 'bbbb' }).map((t) => t.id))
+ .toEqual(['bbbb2222'])
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/taskFilter.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement.** Create `frontend/src/tasks/filter.ts`:
+```ts
+import type { TaskRead } from '@/api/types'
+
+export function filterTasks(
+ items: TaskRead[], f: { status: string; q: string },
+): TaskRead[] {
+ const q = f.q.trim().toLowerCase()
+ return items.filter((t) => {
+ if (f.status && t.status !== f.status) return false
+ if (q && !t.repo_id.toLowerCase().includes(q) &&
+ !t.id.toLowerCase().includes(q)) return false
+ return true
+ })
+}
+```
+Replace `frontend/src/pages/TaskList.vue`:
+```vue
+
+
+
+
+
+
{{ t('tasks.listHeading') }}
+
+ {{ t('tasks.create') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tasks.create') }}
+
+
+ open(r.id)"
+ >
+
+
+ {{ row.id.slice(0, 8) }}…
+
+
+
+
+
+
+
+
+
+
+ {{ fmt(row.created_at) }}
+
+
+
+
+
+ {{ t('tasks.view') }}
+
+
+ {{ t('tasks.cancel') }}
+
+
+ {{ t('tasks.delete') }}
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/taskFilter.spec.ts` → PASS; `pnpm test:unit` (full) → all PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/tasks/filter.ts frontend/src/pages/TaskList.vue frontend/tests/unit/taskFilter.spec.ts
+git commit -m "feat(ui-sp1): TaskList filter + cancel/delete actions"
+```
+
+---
+
+### Task 14: TaskCreate page (+ service-token guard)
+
+**Files:** Create `src/tasks/createValidation.ts`; Modify `src/pages/TaskCreate.vue`; Test `tests/unit/createValidation.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/createValidation.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { validateCreate, mapCreateError } from '@/tasks/createValidation'
+
+describe('validateCreate', () => {
+ test('valid', () => {
+ expect(validateCreate({ repo_id: 'org/m', revision: 'a'.repeat(40),
+ storage_id: 1 })).toEqual([])
+ })
+ test('errors', () => {
+ const e = validateCreate({ repo_id: 'bad', revision: 'xyz', storage_id: 0 })
+ expect(e).toContain('repoPattern')
+ expect(e).toContain('revPattern')
+ expect(e).toContain('storageRequired')
+ })
+})
+describe('mapCreateError', () => {
+ test('http status → i18n key', () => {
+ expect(mapCreateError(409)).toBe('errors.conflict')
+ expect(mapCreateError(422)).toBe('errors.validation')
+ expect(mapCreateError(429)).toBe('errors.quota_exceeded')
+ expect(mapCreateError(403)).toBe('errors.forbidden')
+ expect(mapCreateError(503)).toBe('errors.service_unavailable')
+ expect(mapCreateError(500)).toBe('errors.service_unavailable')
+ })
+})
+```
+
+Also add a **mounted wiring test** (pre-review fix B-M3 — the create-submit path + service-token guard are load-bearing; `TaskCreate` uses raw `client`, not vue-query, so no QueryClient needed) — `frontend/tests/unit/TaskCreate.spec.ts`:
+```ts
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import ElementPlus, { ElMessage } from 'element-plus'
+import { createI18n } from 'vue-i18n'
+import zh from '@/locale/zh-CN.json'
+import { useAuthStore } from '@/stores/auth'
+
+const post = vi.fn()
+vi.mock('@/api/client', () => ({ client: { post: (...a: unknown[]) => post(...a) } }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+
+const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } })
+function mountCreate() {
+ return import('@/pages/TaskCreate.vue').then((m) =>
+ mount(m.default, { global: { plugins: [ElementPlus, i18n] } }))
+}
+const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/=+$/, '')
+
+describe('TaskCreate wiring', () => {
+ beforeEach(() => { setActivePinia(createPinia()); post.mockReset() })
+
+ test('429 → quota_exceeded error message', async () => {
+ useAuthStore().login(`h.${b64({ sub: '1', tid: 1, role: 'tenant_admin', pids: [] })}.s`)
+ const spy = vi.spyOn(ElMessage, 'error')
+ post.mockRejectedValueOnce({ response: { status: 429 } })
+ const w = await mountCreate()
+ ;(w.vm as unknown as { form: Record }).form.repo_id = 'o/m'
+ ;(w.vm as unknown as { form: Record }).form.revision = 'a'.repeat(40)
+ await (w.vm as unknown as { submit: () => Promise }).submit()
+ expect(spy).toHaveBeenCalledWith(zh.errors.quota_exceeded)
+ })
+
+ test('service token → submit disabled', async () => {
+ useAuthStore().login(`h.${b64({ sub: '0', tid: 1, role: 'system_admin', pids: [] })}.s`)
+ const w = await mountCreate()
+ const btn = w.findComponent({ name: 'ElButton' })
+ expect(btn.props('disabled')).toBe(true)
+ })
+})
+```
+(TaskCreate's `submit` + `form` must be exposed for the test — add `defineExpose({ submit, form })` at the end of its `
+
+
+
+
{{ t('create.heading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('create.trustNonHf') }}
+
+
+
+ {{ t('create.submit') }}
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/createValidation.spec.ts tests/unit/TaskCreate.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`.
+
+- [ ] **Step 5: Commit**
+```bash
+git add frontend/src/tasks/createValidation.ts frontend/src/pages/TaskCreate.vue frontend/tests/unit/createValidation.spec.ts frontend/tests/unit/TaskCreate.spec.ts
+git commit -m "feat(ui-sp1): TaskCreate form + service-token preflight guard"
+```
+
+---
+
+### Task 15: Login + OIDC button
+
+**Files:** Modify `src/pages/Login.vue`; Test `tests/unit/oidc.spec.ts`
+
+- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/oidc.spec.ts`:
+```ts
+import { describe, expect, test } from 'vitest'
+import { oidcLoginUrl } from '@/pages/oidc'
+
+describe('oidcLoginUrl', () => {
+ test('uses VITE_API_BASE when set', () => {
+ expect(oidcLoginUrl('http://c:8001')).toBe('http://c:8001/api/v1/auth/login')
+ })
+ test('relative when base empty (vite proxy)', () => {
+ expect(oidcLoginUrl('')).toBe('/api/v1/auth/login')
+ expect(oidcLoginUrl(undefined)).toBe('/api/v1/auth/login')
+ })
+})
+```
+
+- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/oidc.spec.ts` → FAIL.
+
+- [ ] **Step 3: Implement.** Create `frontend/src/pages/oidc.ts`:
+```ts
+export function oidcLoginUrl(base: string | undefined): string {
+ return `${base ?? ''}/api/v1/auth/login`
+}
+```
+Modify `frontend/src/pages/Login.vue` — add OIDC button + handler (keep everything else; add to `
-
-
-
+
+
+
+
+
+
+
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index b4be69d..8e9e900 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -44,3 +44,32 @@ export const TERMINAL_STATUSES: ReadonlySet = new Set([
'failed',
'cancelled',
])
+
+export interface Principal {
+ userId: number
+ tenantId: number
+ role: string
+ projectIds: number[]
+ isServiceToken: boolean
+}
+
+export interface QuotaCurrent {
+ tenant_id: number
+ bytes_used_month: number
+ bytes_quota_month: number
+ storage_gb_used: number
+ storage_gb_quota: number
+ concurrent_tasks: number
+ concurrent_quota: number
+}
+
+export interface TaskCreateBody {
+ repo_id: string
+ revision: string
+ storage_id: number
+ priority?: number
+ source_strategy?: string
+ source_blacklist?: string[]
+ trust_non_hf_sha256?: boolean
+ upgrade_from_revision?: string
+}
diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue
deleted file mode 100644
index 5810402..0000000
--- a/frontend/src/components/AppLayout.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/CommandPalette.vue b/frontend/src/components/CommandPalette.vue
new file mode 100644
index 0000000..9b0108d
--- /dev/null
+++ b/frontend/src/components/CommandPalette.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+ {{ c.label }}
+ {{ c.kind === 'nav' ? t('palette.navGroup') : t('palette.actionGroup') }}
+
+
+
+
+
+
diff --git a/frontend/src/components/DataBoundary.vue b/frontend/src/components/DataBoundary.vue
new file mode 100644
index 0000000..c0c6edd
--- /dev/null
+++ b/frontend/src/components/DataBoundary.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Sparkline.vue b/frontend/src/components/Sparkline.vue
new file mode 100644
index 0000000..8d78ec6
--- /dev/null
+++ b/frontend/src/components/Sparkline.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/frontend/src/components/palette.ts b/frontend/src/components/palette.ts
new file mode 100644
index 0000000..bd5024e
--- /dev/null
+++ b/frontend/src/components/palette.ts
@@ -0,0 +1,22 @@
+import { visibleNav } from '@/nav/registry'
+
+export interface Command {
+ id: string
+ label: string
+ kind: 'nav' | 'action'
+ routeName?: string
+ action?: 'createTask' | 'openTaskById'
+}
+
+export function buildCommands(role: string, t: (k: string) => string): Command[] {
+ const nav: Command[] = visibleNav(role).map((i) => ({
+ id: `nav:${i.route}`, label: t(i.labelKey), kind: 'nav', routeName: i.route,
+ }))
+ const actions: Command[] = [
+ { id: 'action:createTask', label: t('palette.createTask'),
+ kind: 'action', action: 'createTask' },
+ { id: 'action:openTaskById', label: t('palette.openTaskById'),
+ kind: 'action', action: 'openTaskById' },
+ ]
+ return [...nav, ...actions]
+}
diff --git a/frontend/src/components/shell/AppShell.vue b/frontend/src/components/shell/AppShell.vue
new file mode 100644
index 0000000..70d7924
--- /dev/null
+++ b/frontend/src/components/shell/AppShell.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+

+
{{ t('app.title') }}
+
+
+
+ {{ t(i.labelKey) }}
+
+
+
+
+
+
+
+ ☰
+
+ {{ t('shell.commandHint') }}
+
+
+ {{ t('shell.tenant') }} {{ session.principal.tenantId }} ·
+ {{ session.principal.role }}
+
+
+ {{ ui.theme === 'dark' ? '🌙' : '☀️' }}
+
+
+ {{ ui.locale === 'zh-CN' ? 'EN' : '中' }}
+
+
+ {{ t('app.logout') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useLiveResource.ts b/frontend/src/composables/useLiveResource.ts
new file mode 100644
index 0000000..f3dec4d
--- /dev/null
+++ b/frontend/src/composables/useLiveResource.ts
@@ -0,0 +1,48 @@
+import { useQuery, type QueryKey } from '@tanstack/vue-query'
+
+const ERROR_BACKOFF_MS = 5_000
+const HIDDEN_MULTIPLIER = 3
+
+export function computeInterval(o: {
+ base: number; terminal: boolean; hidden: boolean; errored: boolean
+}): number | false {
+ if (o.terminal) return false
+ if (o.errored) return ERROR_BACKOFF_MS
+ return o.hidden ? o.base * HIDDEN_MULTIPLIER : o.base
+}
+
+export interface LiveOptions {
+ baseIntervalMs: number
+ isTerminal?: (data: T) => boolean
+ staleTime?: number
+}
+
+/**
+ * Single realtime seam. Today: adaptive polling on vue-query. UI-SP5 swaps
+ * the internals to SSE/WS — consumers (views) never change.
+ *
+ * vue-query v5.59 does NOT accept a getter for `queryKey` — it must be a
+ * QueryKey (array). Reactivity comes from putting refs *inside* the array
+ * (v5 unwraps them), exactly like the scaffold's proven useTaskDetail.
+ */
+export function useLiveResource(
+ key: QueryKey,
+ fetcher: () => Promise,
+ opts: LiveOptions,
+) {
+ return useQuery({
+ queryKey: key,
+ queryFn: fetcher,
+ staleTime: opts.staleTime ?? 0,
+ refetchInterval: (query) => {
+ const data = query.state.data as T | undefined
+ const errored = query.state.status === 'error'
+ const terminal = data !== undefined && !!opts.isTerminal?.(data)
+ const hidden =
+ typeof document !== 'undefined' && document.visibilityState === 'hidden'
+ return computeInterval({
+ base: opts.baseIntervalMs, terminal, hidden, errored,
+ })
+ },
+ })
+}
diff --git a/frontend/src/composables/useQuota.ts b/frontend/src/composables/useQuota.ts
new file mode 100644
index 0000000..0c6358e
--- /dev/null
+++ b/frontend/src/composables/useQuota.ts
@@ -0,0 +1,11 @@
+import { useLiveResource } from '@/composables/useLiveResource'
+import { client } from '@/api/client'
+import type { QuotaCurrent } from '@/api/types'
+
+export function useQuota() {
+ return useLiveResource(
+ ['quota'],
+ async () => (await client.get('/api/v1/quota/current')).data,
+ { baseIntervalMs: 30_000, staleTime: 30_000 },
+ )
+}
diff --git a/frontend/src/composables/useTaskDetail.ts b/frontend/src/composables/useTaskDetail.ts
index a1d0d23..24170d9 100644
--- a/frontend/src/composables/useTaskDetail.ts
+++ b/frontend/src/composables/useTaskDetail.ts
@@ -1,34 +1,12 @@
-import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
-
+import { useLiveResource } from '@/composables/useLiveResource'
import { client } from '@/api/client'
import { TERMINAL_STATUSES, type TaskDetail } from '@/api/types'
-const POLL_INTERVAL_MS = 1_000
-const ERROR_BACKOFF_MS = 5_000
-
-type QueryStatus = 'pending' | 'error' | 'success'
-
-/** Pure helper — exported so tests don't need vue-query plumbing. */
-export function computeRefetchInterval(
- data: TaskDetail | undefined,
- status: QueryStatus,
-): number | false {
- if (!data) {
- return status === 'error' ? ERROR_BACKOFF_MS : POLL_INTERVAL_MS
- }
- return TERMINAL_STATUSES.has(data.status) ? false : POLL_INTERVAL_MS
-}
-
export function useTaskDetail(taskId: Ref) {
- return useQuery({
- queryKey: ['task', taskId],
- queryFn: async () => {
- const r = await client.get(`/api/v1/tasks/${taskId.value}`)
- return r.data
- },
- refetchInterval: (query) =>
- computeRefetchInterval(query.state.data, query.state.status as QueryStatus),
- staleTime: 0,
- })
+ return useLiveResource(
+ ['task', taskId],
+ async () => (await client.get(`/api/v1/tasks/${taskId.value}`)).data,
+ { baseIntervalMs: 1_000, isTerminal: (d) => TERMINAL_STATUSES.has(d.status) },
+ )
}
diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts
index d4cc133..5b537fd 100644
--- a/frontend/src/composables/useTaskList.ts
+++ b/frontend/src/composables/useTaskList.ts
@@ -1,16 +1,11 @@
-import { useQuery } from '@tanstack/vue-query'
-
+import { useLiveResource } from '@/composables/useLiveResource'
import { client } from '@/api/client'
import type { TaskListResponse } from '@/api/types'
export function useTaskList() {
- return useQuery({
- queryKey: ['tasks'],
- queryFn: async () => {
- const r = await client.get('/api/v1/tasks')
- return r.data
- },
- refetchInterval: 5_000,
- staleTime: 5_000,
- })
+ return useLiveResource(
+ ['tasks'],
+ async () => (await client.get('/api/v1/tasks')).data,
+ { baseIntervalMs: 5_000, staleTime: 5_000 },
+ )
}
diff --git a/frontend/src/composables/useTaskMutations.ts b/frontend/src/composables/useTaskMutations.ts
new file mode 100644
index 0000000..c873310
--- /dev/null
+++ b/frontend/src/composables/useTaskMutations.ts
@@ -0,0 +1,25 @@
+import { useMutation, useQueryClient } from '@tanstack/vue-query'
+import { client } from '@/api/client'
+import { TERMINAL_STATUSES, type TaskStatus } from '@/api/types'
+
+export function canCancel(status: TaskStatus): boolean {
+ return !TERMINAL_STATUSES.has(status)
+}
+export function canDelete(status: TaskStatus): boolean {
+ return TERMINAL_STATUSES.has(status)
+}
+
+export function useTaskMutations() {
+ const qc = useQueryClient()
+ const invalidate = () => qc.invalidateQueries({ queryKey: ['tasks'] })
+
+ const cancel = useMutation({
+ mutationFn: (id: string) => client.post(`/api/v1/tasks/${id}/cancel`, {}),
+ onSettled: invalidate,
+ })
+ const remove = useMutation({
+ mutationFn: (id: string) => client.delete(`/api/v1/tasks/${id}`),
+ onSettled: invalidate,
+ })
+ return { cancel, remove }
+}
diff --git a/frontend/src/dashboard/aggregate.ts b/frontend/src/dashboard/aggregate.ts
new file mode 100644
index 0000000..f9dbf32
--- /dev/null
+++ b/frontend/src/dashboard/aggregate.ts
@@ -0,0 +1,29 @@
+import type { TaskRead, TaskStatus } from '@/api/types'
+
+const IN_PROGRESS: ReadonlySet = new Set([
+ 'pending', 'queued', 'scheduling', 'downloading',
+])
+
+export function aggregateKpis(tasks: TaskRead[]) {
+ let inProgress = 0, completed = 0, failed = 0
+ for (const t of tasks) {
+ if (IN_PROGRESS.has(t.status)) inProgress++
+ else if (t.status === 'succeeded') completed++
+ else if (t.status === 'failed') failed++
+ }
+ return { inProgress, completed, failed, total: tasks.length }
+}
+
+export function bucket24h(tasks: TaskRead[], now: Date = new Date()): number[] {
+ const buckets = new Array(24).fill(0)
+ const end = now.getTime()
+ const start = end - 24 * 3600_000
+ for (const t of tasks) {
+ const ts = new Date(t.created_at).getTime()
+ if (ts >= start && ts <= end) {
+ const idx = Math.min(23, Math.floor((ts - start) / 3600_000))
+ buckets[idx] = (buckets[idx] ?? 0) + 1 // noUncheckedIndexedAccess
+ }
+ }
+ return buckets
+}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
new file mode 100644
index 0000000..5dee538
--- /dev/null
+++ b/frontend/src/i18n.ts
@@ -0,0 +1,16 @@
+import { createI18n } from 'vue-i18n'
+import zhCN from './locale/zh-CN.json'
+import enUS from './locale/en-US.json'
+
+export type LocaleCode = 'zh-CN' | 'en-US'
+
+export const i18n = createI18n({
+ legacy: false,
+ locale: (localStorage.getItem('dlw_locale') as LocaleCode) ?? 'zh-CN',
+ fallbackLocale: 'zh-CN',
+ messages: { 'zh-CN': zhCN, 'en-US': enUS },
+})
+
+export function setI18nLocale(l: LocaleCode): void {
+ i18n.global.locale.value = l
+}
diff --git a/frontend/src/locale/en-US.json b/frontend/src/locale/en-US.json
new file mode 100644
index 0000000..3e92151
--- /dev/null
+++ b/frontend/src/locale/en-US.json
@@ -0,0 +1,61 @@
+{
+ "app": { "title": "modelpull", "logout": "Sign out" },
+ "login": {
+ "heading": "Sign in to modelpull", "tokenLabel": "Bearer Token",
+ "tokenPlaceholder": "Paste a tenant-user JWT", "submit": "Sign in",
+ "tokenRequired": "Token is required", "oidc": "Sign in with OIDC"
+ },
+ "nav": { "dashboard": "Overview", "tasks": "Tasks", "createTask": "New task" },
+ "shell": {
+ "theme": "Theme", "language": "Language",
+ "commandHint": "Command palette (Ctrl/⌘+K)", "tenant": "Tenant", "role": "Role"
+ },
+ "palette": {
+ "placeholder": "Jump to or run an action…", "navGroup": "Navigate",
+ "actionGroup": "Actions", "createTask": "New task",
+ "openTaskById": "Open task by ID", "openTaskPrompt": "Enter task ID"
+ },
+ "dashboard": {
+ "heading": "Overview", "inProgress": "In progress", "completed": "Completed",
+ "failed": "Failed", "total": "Total", "recent": "Recent tasks",
+ "quota": "Quota", "trend": "Created (last 24h)", "quotaBytes": "Bytes this month",
+ "quotaStorage": "Storage", "quotaConcurrent": "Concurrent tasks"
+ },
+ "tasks": {
+ "listHeading": "Tasks", "empty": "No tasks yet — click \"New task\" to create one",
+ "create": "New task", "filterStatus": "Status", "filterAll": "All",
+ "search": "Search repo / id", "cancel": "Cancel", "delete": "Delete",
+ "cancelConfirm": "Cancel this task?", "deleteConfirm": "Delete this terminal task?",
+ "cancelled": "Cancellation requested", "deleted": "Deleted",
+ "columns": { "id": "ID", "repo": "Repo", "revision": "Revision",
+ "status": "Status", "createdAt": "Created", "actions": "Actions" },
+ "view": "View", "detailHeading": "Task detail", "subtasksHeading": "Subtasks",
+ "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" }
+ },
+ "create": {
+ "heading": "Create download task", "repo": "Repo (org/model)",
+ "revision": "Revision (40-hex sha)", "storageId": "Storage backend ID",
+ "priority": "Priority", "strategy": "Source strategy",
+ "upgradeFrom": "Upgrade-from revision", "trustNonHf": "Trust non-HF sha256",
+ "submit": "Create task", "repoRequired": "Repo is required",
+ "repoPattern": "Expected org/model", "revRequired": "Revision is required",
+ "revPattern": "Must be a 40-hex sha", "storageRequired": "Storage ID is required",
+ "serviceTokenWarn": "You are using the system-admin service token (user_id=0); task creation fails the owner FK. Sign in with a tenant-user JWT to create tasks.",
+ "success": "Task created"
+ },
+ "status": {
+ "pending": "Pending", "queued": "Queued", "scheduling": "Scheduling",
+ "downloading": "Downloading", "succeeded": "Succeeded", "failed": "Failed",
+ "cancelled": "Cancelled", "assigned": "Assigned", "in_progress": "Downloading"
+ },
+ "errors": {
+ "invalid_token": "Token invalid or expired — please sign in again",
+ "service_unavailable": "Service unavailable, retrying…",
+ "network": "Network error, check your connection",
+ "quota_exceeded": "Quota exceeded", "conflict": "State conflict (task not terminal or duplicate)",
+ "validation": "Invalid request parameters", "forbidden": "Forbidden"
+ }
+}
diff --git a/frontend/src/locale/zh-CN.json b/frontend/src/locale/zh-CN.json
index d5f6340..5ce4995 100644
--- a/frontend/src/locale/zh-CN.json
+++ b/frontend/src/locale/zh-CN.json
@@ -1,54 +1,65 @@
{
- "app": {
- "title": "modelpull",
- "logout": "退出登录"
- },
+ "app": { "title": "modelpull", "logout": "退出登录" },
"login": {
- "heading": "登录 modelpull",
- "tokenLabel": "Bearer Token",
- "tokenPlaceholder": "粘贴 DLW_BEARER_TOKEN 的值",
- "submit": "登录",
- "tokenRequired": "请输入 token"
+ "heading": "登录 modelpull", "tokenLabel": "Bearer Token",
+ "tokenPlaceholder": "粘贴租户用户 JWT", "submit": "登录",
+ "tokenRequired": "请输入 token", "oidc": "使用 OIDC 登录"
+ },
+ "nav": {
+ "dashboard": "概览", "tasks": "任务", "createTask": "新建任务"
+ },
+ "shell": {
+ "theme": "主题", "language": "语言", "commandHint": "命令面板 (Ctrl/⌘+K)",
+ "tenant": "租户", "role": "角色"
+ },
+ "palette": {
+ "placeholder": "跳转或执行操作…", "navGroup": "导航",
+ "actionGroup": "操作", "createTask": "新建任务", "openTaskById": "按 ID 打开任务",
+ "openTaskPrompt": "输入任务 ID"
+ },
+ "dashboard": {
+ "heading": "概览", "inProgress": "进行中", "completed": "已完成",
+ "failed": "失败", "total": "总计", "recent": "最近任务",
+ "quota": "配额", "trend": "近 24h 新建", "quotaBytes": "本月流量",
+ "quotaStorage": "存储", "quotaConcurrent": "并发任务"
},
"tasks": {
- "listHeading": "任务列表",
- "empty": "暂无任务,使用 curl POST /api/v1/tasks 创建一个",
- "columns": {
- "id": "ID",
- "repo": "仓库",
- "revision": "Revision",
- "status": "状态",
- "createdAt": "创建时间",
- "actions": "操作"
- },
- "view": "查看",
- "detailHeading": "任务详情",
- "subtasksHeading": "子任务",
- "polling": "实时刷新中…",
- "completed": "已停止刷新(终态)",
- "back": "返回列表",
- "notFound": "任务不存在或已删除",
- "subtaskColumns": {
- "filename": "文件名",
- "size": "大小",
- "sha256": "SHA256",
- "status": "状态"
- }
+ "listHeading": "任务列表", "empty": "暂无任务,点击「新建任务」创建一个",
+ "create": "新建任务", "filterStatus": "状态筛选", "filterAll": "全部",
+ "search": "搜索仓库/ID", "cancel": "取消", "delete": "删除",
+ "cancelConfirm": "确认取消该任务?", "deleteConfirm": "确认删除该终态任务?",
+ "cancelled": "已请求取消", "deleted": "已删除",
+ "columns": { "id": "ID", "repo": "仓库", "revision": "Revision",
+ "status": "状态", "createdAt": "创建时间", "actions": "操作" },
+ "view": "查看", "detailHeading": "任务详情", "subtasksHeading": "子任务",
+ "polling": "实时刷新中…", "completed": "已停止刷新(终态)",
+ "back": "返回列表", "notFound": "任务不存在或已删除",
+ "subtaskColumns": { "filename": "文件名", "size": "大小",
+ "sha256": "SHA256", "status": "状态" }
+ },
+ "create": {
+ "heading": "新建下载任务", "repo": "仓库 (org/model)",
+ "revision": "Revision (40 位 hex sha)", "storageId": "存储后端 ID",
+ "priority": "优先级", "strategy": "源策略", "upgradeFrom": "增量基线 revision",
+ "trustNonHf": "信任非 HF 源 sha256", "submit": "创建任务",
+ "repoRequired": "请输入仓库", "repoPattern": "格式应为 org/model",
+ "revRequired": "请输入 revision", "revPattern": "应为 40 位十六进制 sha",
+ "storageRequired": "请输入存储后端 ID",
+ "serviceTokenWarn": "当前是 system-admin 服务 token(user_id=0),无法创建任务(owner FK)。请用租户用户 JWT 登录后再创建。",
+ "success": "任务已创建"
},
"status": {
- "pending": "排队中",
- "queued": "排队中",
- "scheduling": "调度中",
- "downloading": "下载中",
- "succeeded": "成功",
- "failed": "失败",
- "cancelled": "已取消",
- "assigned": "已分派",
- "in_progress": "下载中"
+ "pending": "排队中", "queued": "排队中", "scheduling": "调度中",
+ "downloading": "下载中", "succeeded": "成功", "failed": "失败",
+ "cancelled": "已取消", "assigned": "已分派", "in_progress": "下载中"
},
"errors": {
"invalid_token": "Token 无效或已失效,请重新登录",
"service_unavailable": "服务暂不可用,正在重试…",
- "network": "网络错误,请检查连接"
+ "network": "网络错误,请检查连接",
+ "quota_exceeded": "配额已超限",
+ "conflict": "状态冲突(任务可能非终态或重复)",
+ "validation": "请求参数有误",
+ "forbidden": "无权限访问"
}
}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 54b2131..6d2f883 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -4,34 +4,28 @@ import './styles/main.scss'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { VueQueryPlugin } from '@tanstack/vue-query'
-import { createI18n } from 'vue-i18n'
import ElementPlus from 'element-plus'
import App from './App.vue'
import router from './router'
-import zhCN from './locale/zh-CN.json'
-
-const i18n = createI18n({
- legacy: false,
- locale: 'zh-CN',
- fallbackLocale: 'zh-CN',
- messages: { 'zh-CN': zhCN },
-})
+import { i18n } from './i18n'
+import { useUiStore } from './stores/ui'
const app = createApp(App)
-app.use(createPinia())
+const pinia = createPinia()
+app.use(pinia)
app.use(router)
app.use(i18n)
app.use(ElementPlus)
app.use(VueQueryPlugin, {
queryClientConfig: {
defaultOptions: {
- queries: {
- retry: 3,
- refetchOnWindowFocus: true,
- staleTime: 5_000,
- },
+ queries: { retry: 3, refetchOnWindowFocus: true, staleTime: 5_000 },
},
},
})
-app.mount('#app')
+useUiStore().hydrate()
+// Pre-review fix B-I2: mount AFTER the initial route resolves so App.vue's
+// route.name is defined on first render — otherwise an authenticated user
+// briefly sees the bare (login) layout flash.
+router.isReady().then(() => app.mount('#app'))
diff --git a/frontend/src/nav/registry.ts b/frontend/src/nav/registry.ts
new file mode 100644
index 0000000..4d604c3
--- /dev/null
+++ b/frontend/src/nav/registry.ts
@@ -0,0 +1,16 @@
+export interface NavItem {
+ route: string // route name
+ labelKey: string // i18n key under nav.*
+ icon: string // element-plus icon component name
+ roles?: string[] // if set, visible only to these roles
+}
+
+export const NAV_ITEMS: NavItem[] = [
+ { route: 'dashboard', labelKey: 'nav.dashboard', icon: 'Odometer' },
+ { route: 'taskList', labelKey: 'nav.tasks', icon: 'List' },
+ { route: 'taskCreate', labelKey: 'nav.createTask', icon: 'Plus' },
+]
+
+export function visibleNav(role: string, items: NavItem[] = NAV_ITEMS): NavItem[] {
+ return items.filter((i) => !i.roles || i.roles.includes(role))
+}
diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue
new file mode 100644
index 0000000..dca64fb
--- /dev/null
+++ b/frontend/src/pages/Dashboard.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
{{ t('dashboard.heading') }}
+
+
+ {{ t('dashboard.inProgress') }}{{ kpi.inProgress }}
+ {{ t('dashboard.completed') }}{{ kpi.completed }}
+ {{ t('dashboard.failed') }}{{ kpi.failed }}
+ {{ t('dashboard.total') }}{{ kpi.total }}
+
+
+
+
+ {{ t('dashboard.trend') }}
+
+
+
+
+
+
+ {{ t('dashboard.quota') }}
+
+
+ {{ t('dashboard.quotaBytes') }}: {{ quota.bytes_used_month }} /
+ {{ quota.bytes_quota_month }}
+
+
+ {{ t('dashboard.quotaConcurrent') }}: {{ quota.concurrent_tasks }} /
+ {{ quota.concurrent_quota }}
+
+
+
+
+
+ {{ t('dashboard.recent') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tasks.view') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue
index d6cb2b8..918758c 100644
--- a/frontend/src/pages/Login.vue
+++ b/frontend/src/pages/Login.vue
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
+import { oidcLoginUrl } from '@/pages/oidc'
const { t } = useI18n()
const route = useRoute()
@@ -33,6 +34,10 @@ async function onSubmit() {
authStore.login(form.token.trim())
router.push('/')
}
+
+function loginOidc() {
+ window.location.assign(oidcLoginUrl(import.meta.env.VITE_API_BASE))
+}
@@ -69,6 +74,14 @@ async function onSubmit() {
{{ t('login.submit') }}
+
+
+ {{ t('login.oidc') }}
+
+
diff --git a/frontend/src/pages/TaskCreate.vue b/frontend/src/pages/TaskCreate.vue
new file mode 100644
index 0000000..d83dc6f
--- /dev/null
+++ b/frontend/src/pages/TaskCreate.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
{{ t('create.heading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('create.trustNonHf') }}
+
+
+
+ {{ t('create.submit') }}
+
+
+
+
+
diff --git a/frontend/src/pages/TaskList.vue b/frontend/src/pages/TaskList.vue
index 9f13d8e..d804039 100644
--- a/frontend/src/pages/TaskList.vue
+++ b/frontend/src/pages/TaskList.vue
@@ -1,116 +1,168 @@
-
{{ t('tasks.listHeading') }}
+
+
{{ t('tasks.listHeading') }}
+
+ {{ t('tasks.create') }}
+
+
-
open(row.id)"
- >
-
+
-
- {{ shortId(row.id) }}…
-
-
-
+
+
-
-
- {{ shortRevision(row.revision) }}…
-
-
-
-
-
-
-
-
-
- {{ formatDate(row.created_at) }}
-
-
-
-
-
- {{ t('tasks.view') }}
-
-
-
-
-
-
+
-
-
-
+
+
+
+ {{ t('tasks.create') }}
+
+
+ open(r.id)"
+ >
+
+
+ {{ row.id.slice(0, 8) }}…
+
+
+
+
+
+
+
+
+
+
+ {{ fmt(row.created_at) }}
+
+
+
+
+
+ {{ t('tasks.view') }}
+
+
+ {{ t('tasks.cancel') }}
+
+
+ {{ t('tasks.delete') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/oidc.ts b/frontend/src/pages/oidc.ts
new file mode 100644
index 0000000..8ecbe78
--- /dev/null
+++ b/frontend/src/pages/oidc.ts
@@ -0,0 +1,3 @@
+export function oidcLoginUrl(base: string | undefined): string {
+ return `${base ?? ''}/api/v1/auth/login`
+}
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 4973869..8fa9774 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -1,38 +1,38 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
-const routes: RouteRecordRaw[] = [
+export const routes: RouteRecordRaw[] = [
{
- path: '/login',
- name: 'login',
+ path: '/login', name: 'login',
component: () => import('@/pages/Login.vue'),
meta: { public: true },
},
{
- path: '/',
- name: 'taskList',
+ path: '/', name: 'dashboard',
+ component: () => import('@/pages/Dashboard.vue'),
+ },
+ {
+ path: '/tasks', name: 'taskList',
component: () => import('@/pages/TaskList.vue'),
},
{
- path: '/tasks/:id',
- name: 'taskDetail',
+ path: '/tasks/new', name: 'taskCreate',
+ component: () => import('@/pages/TaskCreate.vue'),
+ },
+ {
+ path: '/tasks/:id', name: 'taskDetail',
component: () => import('@/pages/TaskDetail.vue'),
props: true,
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
-const router = createRouter({
- history: createWebHistory(),
- routes,
-})
+const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to) => {
if (to.meta.public) return true
const auth = useAuthStore()
- if (!auth.isAuthenticated) {
- return { path: '/login' }
- }
+ if (!auth.isAuthenticated) return { path: '/login' }
return true
})
diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts
new file mode 100644
index 0000000..c2a3041
--- /dev/null
+++ b/frontend/src/stores/session.ts
@@ -0,0 +1,43 @@
+import { computed } from 'vue'
+import { defineStore } from 'pinia'
+import { useAuthStore } from '@/stores/auth'
+import type { Principal } from '@/api/types'
+
+export function decodePrincipal(token: string | null): Principal | null {
+ if (!token) return null
+ const parts = token.split('.')
+ if (parts.length !== 3) return null
+ const payload = parts[1] // bind (noUncheckedIndexedAccess)
+ if (!payload) return null
+ try {
+ const raw = payload.replace(/-/g, '+').replace(/_/g, '/')
+ // JWT base64url is unpadded — re-pad before atob (atob is
+ // length-dependent / throws on `len % 4 === 1` without padding).
+ const b64 = raw + '='.repeat((4 - (raw.length % 4)) % 4)
+ const json = decodeURIComponent(
+ atob(b64).split('').map(
+ (c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(''),
+ )
+ const c = JSON.parse(json) as Record
+ const userId = Number(c.sub)
+ const role = String(c.role ?? '')
+ if (!Number.isFinite(userId)) return null
+ return {
+ userId,
+ tenantId: Number(c.tid ?? 0),
+ role,
+ projectIds: Array.isArray(c.pids) ? (c.pids as number[]) : [],
+ isServiceToken: userId === 0 || role === 'system_admin',
+ }
+ } catch {
+ return null
+ }
+}
+
+export const useSessionStore = defineStore('session', () => {
+ const auth = useAuthStore()
+ const principal = computed(() => decodePrincipal(auth.accessToken))
+ const role = computed(() => principal.value?.role ?? 'guest')
+ const isServiceToken = computed(() => principal.value?.isServiceToken ?? false)
+ return { principal, role, isServiceToken }
+})
diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts
new file mode 100644
index 0000000..d6a6aab
--- /dev/null
+++ b/frontend/src/stores/ui.ts
@@ -0,0 +1,42 @@
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import { setI18nLocale, type LocaleCode } from '@/i18n'
+
+type Theme = 'light' | 'dark'
+
+export const useUiStore = defineStore('ui', () => {
+ const theme = ref(
+ (localStorage.getItem('dlw_theme') as Theme) ??
+ (window.matchMedia?.('(prefers-color-scheme: dark)').matches
+ ? 'dark' : 'light'),
+ )
+ const sidebarCollapsed = ref(localStorage.getItem('dlw_sidebar') === 'true')
+ const locale = ref(
+ (localStorage.getItem('dlw_locale') as LocaleCode) ?? 'zh-CN',
+ )
+
+ function applyTheme(): void {
+ document.documentElement.classList.toggle('dark', theme.value === 'dark')
+ }
+ function toggleTheme(): void {
+ theme.value = theme.value === 'dark' ? 'light' : 'dark'
+ localStorage.setItem('dlw_theme', theme.value)
+ applyTheme()
+ }
+ function toggleSidebar(): void {
+ sidebarCollapsed.value = !sidebarCollapsed.value
+ localStorage.setItem('dlw_sidebar', String(sidebarCollapsed.value))
+ }
+ function setLocale(l: LocaleCode): void {
+ locale.value = l
+ localStorage.setItem('dlw_locale', l)
+ setI18nLocale(l)
+ }
+ function hydrate(): void {
+ applyTheme()
+ setI18nLocale(locale.value)
+ }
+
+ return { theme, sidebarCollapsed, locale,
+ toggleTheme, toggleSidebar, setLocale, hydrate }
+})
diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss
index 10cffdd..b402d41 100644
--- a/frontend/src/styles/main.scss
+++ b/frontend/src/styles/main.scss
@@ -1,3 +1,5 @@
+@use './tokens';
+
*,
*::before,
*::after {
@@ -14,8 +16,8 @@ body,
}
body {
- background-color: #f5f7fa;
- color: #303133;
+ background-color: var(--dlw-bg);
+ color: var(--dlw-text);
}
a {
@@ -24,7 +26,7 @@ a {
}
.page-container {
- padding: 24px;
+ padding: var(--dlw-space-4);
max-width: 1280px;
margin: 0 auto;
}
diff --git a/frontend/src/styles/tokens.scss b/frontend/src/styles/tokens.scss
new file mode 100644
index 0000000..82d9a6f
--- /dev/null
+++ b/frontend/src/styles/tokens.scss
@@ -0,0 +1,23 @@
+:root {
+ --dlw-space-1: 4px; --dlw-space-2: 8px; --dlw-space-3: 16px;
+ --dlw-space-4: 24px; --dlw-space-5: 32px;
+ --dlw-radius: 8px;
+ --dlw-bg: #f5f7fa; --dlw-surface: #ffffff;
+ --dlw-text: #303133; --dlw-text-soft: #909399;
+ --dlw-border: #ebeef5;
+ --dlw-status-pending: #909399; --dlw-status-queued: #909399;
+ --dlw-status-scheduling: #e6a23c; --dlw-status-downloading: #409eff;
+ --dlw-status-assigned: #409eff; --dlw-status-in_progress: #409eff;
+ --dlw-status-succeeded: #67c23a; --dlw-status-failed: #f56c6c;
+ --dlw-status-cancelled: #909399;
+}
+:root.dark {
+ --dlw-bg: #141414; --dlw-surface: #1d1e1f;
+ --dlw-text: #e5eaf3; --dlw-text-soft: #8d9095;
+ --dlw-border: #363637;
+ --dlw-status-pending: #6b6d71; --dlw-status-queued: #6b6d71;
+ --dlw-status-scheduling: #b88230; --dlw-status-downloading: #409eff;
+ --dlw-status-assigned: #409eff; --dlw-status-in_progress: #409eff;
+ --dlw-status-succeeded: #529b2e; --dlw-status-failed: #c45656;
+ --dlw-status-cancelled: #6b6d71;
+}
diff --git a/frontend/src/tasks/createValidation.ts b/frontend/src/tasks/createValidation.ts
new file mode 100644
index 0000000..36d8d76
--- /dev/null
+++ b/frontend/src/tasks/createValidation.ts
@@ -0,0 +1,22 @@
+import type { TaskCreateBody } from '@/api/types'
+
+const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
+const SHA_RE = /^[0-9a-f]{40}$/
+
+export function validateCreate(b: Partial): string[] {
+ const e: string[] = []
+ if (!b.repo_id) e.push('repoRequired')
+ else if (!REPO_RE.test(b.repo_id)) e.push('repoPattern')
+ if (!b.revision) e.push('revRequired')
+ else if (!SHA_RE.test(b.revision)) e.push('revPattern')
+ if (!b.storage_id || b.storage_id <= 0) e.push('storageRequired')
+ return e
+}
+
+export function mapCreateError(status: number | undefined): string {
+ if (status === 403) return 'errors.forbidden'
+ if (status === 409) return 'errors.conflict'
+ if (status === 422) return 'errors.validation'
+ if (status === 429) return 'errors.quota_exceeded'
+ return 'errors.service_unavailable'
+}
diff --git a/frontend/src/tasks/filter.ts b/frontend/src/tasks/filter.ts
new file mode 100644
index 0000000..e114471
--- /dev/null
+++ b/frontend/src/tasks/filter.ts
@@ -0,0 +1,13 @@
+import type { TaskRead } from '@/api/types'
+
+export function filterTasks(
+ items: TaskRead[], f: { status: string; q: string },
+): TaskRead[] {
+ const q = f.q.trim().toLowerCase()
+ return items.filter((t) => {
+ if (f.status && t.status !== f.status) return false
+ if (q && !t.repo_id.toLowerCase().includes(q) &&
+ !t.id.toLowerCase().includes(q)) return false
+ return true
+ })
+}
diff --git a/frontend/tests/unit/AppShell.spec.ts b/frontend/tests/unit/AppShell.spec.ts
new file mode 100644
index 0000000..a010404
--- /dev/null
+++ b/frontend/tests/unit/AppShell.spec.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import { createI18n } from 'vue-i18n'
+import AppShell from '@/components/shell/AppShell.vue'
+import zh from '@/locale/zh-CN.json'
+import { useAuthStore } from '@/stores/auth'
+
+const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } })
+const push = vi.fn()
+vi.mock('vue-router', () => ({
+ useRouter: () => ({ push }),
+ useRoute: () => ({ name: 'taskList' }),
+ RouterView: { template: '' },
+}))
+
+function mountShell() {
+ return mount(AppShell, { global: { plugins: [ElementPlus, i18n] } })
+}
+
+describe('AppShell', () => {
+ beforeEach(() => { setActivePinia(createPinia()); push.mockClear() })
+
+ test('authenticated → renders nav items', () => {
+ useAuthStore().login('h.' + btoa(JSON.stringify(
+ { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s')
+ const w = mountShell()
+ expect(w.text()).toContain(zh.nav.tasks)
+ expect(w.text()).toContain(zh.nav.dashboard)
+ })
+
+ test('logout calls auth.logout + redirects', async () => {
+ const auth = useAuthStore()
+ auth.login('h.' + btoa(JSON.stringify(
+ { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s')
+ const w = mountShell()
+ await w.find('[data-test=logout]').trigger('click')
+ expect(auth.isAuthenticated).toBe(false)
+ expect(push).toHaveBeenCalledWith('/login')
+ })
+})
diff --git a/frontend/tests/unit/DataBoundary.spec.ts b/frontend/tests/unit/DataBoundary.spec.ts
new file mode 100644
index 0000000..813450e
--- /dev/null
+++ b/frontend/tests/unit/DataBoundary.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ElementPlus from 'element-plus'
+import { createI18n } from 'vue-i18n'
+import DataBoundary from '@/components/DataBoundary.vue'
+import zh from '@/locale/zh-CN.json'
+
+const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } })
+const mountB = (props: Record) =>
+ mount(DataBoundary, {
+ props, slots: { default: 'DATA
' },
+ global: { plugins: [ElementPlus, i18n] },
+ })
+
+describe('DataBoundary', () => {
+ test('loading → skeleton, no content', () => {
+ const w = mountB({ loading: true })
+ expect(w.findComponent({ name: 'ElSkeleton' }).exists()).toBe(true)
+ expect(w.find('.content').exists()).toBe(false)
+ })
+ test('forbidden → forbidden message', () => {
+ const w = mountB({ loading: false, forbidden: true })
+ expect(w.text()).toContain(zh.errors.forbidden)
+ expect(w.find('.content').exists()).toBe(false)
+ })
+ test('error → alert', () => {
+ const w = mountB({ loading: false, error: true })
+ expect(w.findComponent({ name: 'ElAlert' }).exists()).toBe(true)
+ })
+ test('empty → EmptyState', () => {
+ const w = mountB({ loading: false, isEmpty: true })
+ expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true)
+ })
+ test('ok → renders default slot', () => {
+ const w = mountB({ loading: false })
+ expect(w.find('.content').text()).toBe('DATA')
+ })
+})
diff --git a/frontend/tests/unit/TaskCreate.spec.ts b/frontend/tests/unit/TaskCreate.spec.ts
new file mode 100644
index 0000000..db2c2c1
--- /dev/null
+++ b/frontend/tests/unit/TaskCreate.spec.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import ElementPlus, { ElMessage } from 'element-plus'
+import { createI18n } from 'vue-i18n'
+import zh from '@/locale/zh-CN.json'
+import { useAuthStore } from '@/stores/auth'
+
+// vi.mock factories are hoisted — use vi.hoisted so they don't reference
+// an outer const (TDZ).
+const { post } = vi.hoisted(() => ({ post: vi.fn() }))
+vi.mock('@/api/client', () => ({ client: { post } }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+
+const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } })
+function mountCreate() {
+ return import('@/pages/TaskCreate.vue').then((m) =>
+ mount(m.default, { global: { plugins: [ElementPlus, i18n] } }))
+}
+const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/=+$/, '')
+
+describe('TaskCreate wiring', () => {
+ beforeEach(() => { setActivePinia(createPinia()); post.mockReset() })
+
+ test('429 → quota_exceeded error message', async () => {
+ useAuthStore().login(`h.${b64({ sub: '1', tid: 1, role: 'tenant_admin', pids: [] })}.s`)
+ const spy = vi.spyOn(ElMessage, 'error')
+ post.mockRejectedValueOnce({ response: { status: 429 } })
+ const w = await mountCreate()
+ ;(w.vm as unknown as { form: Record }).form.repo_id = 'o/m'
+ ;(w.vm as unknown as { form: Record }).form.revision = 'a'.repeat(40)
+ await (w.vm as unknown as { submit: () => Promise }).submit()
+ expect(spy).toHaveBeenCalledWith(zh.errors.quota_exceeded)
+ })
+
+ test('service token → submit disabled', async () => {
+ useAuthStore().login(`h.${b64({ sub: '0', tid: 1, role: 'system_admin', pids: [] })}.s`)
+ const w = await mountCreate()
+ const btn = w.findComponent({ name: 'ElButton' })
+ expect(btn.props('disabled')).toBe(true)
+ })
+})
diff --git a/frontend/tests/unit/createValidation.spec.ts b/frontend/tests/unit/createValidation.spec.ts
new file mode 100644
index 0000000..8855049
--- /dev/null
+++ b/frontend/tests/unit/createValidation.spec.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from 'vitest'
+import { validateCreate, mapCreateError } from '@/tasks/createValidation'
+
+describe('validateCreate', () => {
+ test('valid', () => {
+ expect(validateCreate({ repo_id: 'org/m', revision: 'a'.repeat(40),
+ storage_id: 1 })).toEqual([])
+ })
+ test('errors', () => {
+ const e = validateCreate({ repo_id: 'bad', revision: 'xyz', storage_id: 0 })
+ expect(e).toContain('repoPattern')
+ expect(e).toContain('revPattern')
+ expect(e).toContain('storageRequired')
+ })
+})
+describe('mapCreateError', () => {
+ test('http status → i18n key', () => {
+ expect(mapCreateError(409)).toBe('errors.conflict')
+ expect(mapCreateError(422)).toBe('errors.validation')
+ expect(mapCreateError(429)).toBe('errors.quota_exceeded')
+ expect(mapCreateError(403)).toBe('errors.forbidden')
+ expect(mapCreateError(503)).toBe('errors.service_unavailable')
+ expect(mapCreateError(500)).toBe('errors.service_unavailable')
+ })
+})
diff --git a/frontend/tests/unit/dashboard.spec.ts b/frontend/tests/unit/dashboard.spec.ts
new file mode 100644
index 0000000..55dac93
--- /dev/null
+++ b/frontend/tests/unit/dashboard.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from 'vitest'
+import { aggregateKpis, bucket24h } from '@/dashboard/aggregate'
+import type { TaskRead } from '@/api/types'
+
+const mk = (status: TaskRead['status'], createdAt: string): TaskRead => ({
+ id: Math.random().toString(36), repo_id: 'o/r', revision: 'a',
+ status, priority: 1, created_at: createdAt, completed_at: null,
+ error_message: null,
+})
+
+describe('dashboard aggregate', () => {
+ test('aggregateKpis counts by bucket', () => {
+ const k = aggregateKpis([
+ mk('downloading', '2026-05-19T00:00:00Z'),
+ mk('scheduling', '2026-05-19T00:00:00Z'),
+ mk('succeeded', '2026-05-19T00:00:00Z'),
+ mk('failed', '2026-05-19T00:00:00Z'),
+ ])
+ expect(k).toEqual({ inProgress: 2, completed: 1, failed: 1, total: 4 })
+ })
+ test('bucket24h returns 24 hourly counts within window', () => {
+ const now = new Date('2026-05-19T12:00:00Z')
+ const b = bucket24h([
+ mk('succeeded', '2026-05-19T11:30:00Z'),
+ mk('succeeded', '2026-05-19T11:45:00Z'),
+ mk('succeeded', '2026-05-10T00:00:00Z'),
+ ], now)
+ expect(b).toHaveLength(24)
+ expect(b.reduce((a, c) => a + c, 0)).toBe(2)
+ })
+})
diff --git a/frontend/tests/unit/locale.spec.ts b/frontend/tests/unit/locale.spec.ts
new file mode 100644
index 0000000..d5f1ed4
--- /dev/null
+++ b/frontend/tests/unit/locale.spec.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'vitest'
+import zh from '@/locale/zh-CN.json'
+import en from '@/locale/en-US.json'
+
+function keys(o: unknown, p = ''): string[] {
+ if (o && typeof o === 'object' && !Array.isArray(o)) {
+ return Object.entries(o as Record)
+ .flatMap(([k, v]) => keys(v, p ? `${p}.${k}` : k))
+ }
+ return [p]
+}
+
+describe('locale parity', () => {
+ test('en-US and zh-CN have identical key sets', () => {
+ expect(keys(en).sort()).toEqual(keys(zh).sort())
+ })
+ test('new keys present', () => {
+ for (const k of ['nav.dashboard', 'nav.tasks', 'tasks.create',
+ 'tasks.filterStatus', 'create.heading', 'create.serviceTokenWarn',
+ 'shell.theme', 'shell.language', 'palette.placeholder',
+ 'dashboard.heading', 'errors.quota_exceeded'])
+ expect(keys(zh)).toContain(k)
+ })
+})
diff --git a/frontend/tests/unit/nav.spec.ts b/frontend/tests/unit/nav.spec.ts
new file mode 100644
index 0000000..03b3320
--- /dev/null
+++ b/frontend/tests/unit/nav.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from 'vitest'
+import { NAV_ITEMS, visibleNav } from '@/nav/registry'
+
+describe('nav registry', () => {
+ test('all items have route + labelKey', () => {
+ for (const i of NAV_ITEMS) {
+ expect(i.route).toBeTruthy()
+ expect(i.labelKey).toMatch(/^nav\./)
+ }
+ })
+ test('visibleNav: no roles → visible to everyone', () => {
+ const names = visibleNav('guest').map((i) => i.route)
+ expect(names).toContain('taskList')
+ expect(names).toContain('dashboard')
+ })
+ test('role-gated item hidden for wrong role', () => {
+ const gated = { route: 'x', labelKey: 'nav.x', icon: 'i', roles: ['system_admin'] }
+ expect(visibleNav('tenant_admin', [...NAV_ITEMS, gated]).map((i) => i.route))
+ .not.toContain('x')
+ expect(visibleNav('system_admin', [...NAV_ITEMS, gated]).map((i) => i.route))
+ .toContain('x')
+ })
+})
diff --git a/frontend/tests/unit/oidc.spec.ts b/frontend/tests/unit/oidc.spec.ts
new file mode 100644
index 0000000..ce6aa3b
--- /dev/null
+++ b/frontend/tests/unit/oidc.spec.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from 'vitest'
+import { oidcLoginUrl } from '@/pages/oidc'
+
+describe('oidcLoginUrl', () => {
+ test('uses VITE_API_BASE when set', () => {
+ expect(oidcLoginUrl('http://c:8001')).toBe('http://c:8001/api/v1/auth/login')
+ })
+ test('relative when base empty (vite proxy)', () => {
+ expect(oidcLoginUrl('')).toBe('/api/v1/auth/login')
+ expect(oidcLoginUrl(undefined)).toBe('/api/v1/auth/login')
+ })
+})
diff --git a/frontend/tests/unit/palette.spec.ts b/frontend/tests/unit/palette.spec.ts
new file mode 100644
index 0000000..42658d6
--- /dev/null
+++ b/frontend/tests/unit/palette.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from 'vitest'
+import { buildCommands } from '@/components/palette'
+
+describe('buildCommands', () => {
+ const t = (k: string) => k
+ test('includes nav items + create + open-by-id', () => {
+ const cmds = buildCommands('tenant_admin', t)
+ const ids = cmds.map((c) => c.id)
+ expect(ids).toContain('nav:dashboard')
+ expect(ids).toContain('nav:taskList')
+ expect(ids).toContain('action:createTask')
+ expect(ids).toContain('action:openTaskById')
+ })
+ test('role-gates nav', () => {
+ const cmds = buildCommands('guest', t)
+ expect(cmds.find((c) => c.id === 'nav:dashboard')).toBeTruthy()
+ })
+})
diff --git a/frontend/tests/unit/router.spec.ts b/frontend/tests/unit/router.spec.ts
new file mode 100644
index 0000000..398bea7
--- /dev/null
+++ b/frontend/tests/unit/router.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from 'vitest'
+import { routes } from '@/router'
+
+describe('routes', () => {
+ test('has dashboard / taskList / taskCreate / taskDetail / login', () => {
+ const byName = Object.fromEntries(
+ routes.filter((r) => r.name).map((r) => [r.name, r]))
+ expect(byName.dashboard?.path).toBe('/')
+ expect(byName.taskList?.path).toBe('/tasks')
+ expect(byName.taskCreate?.path).toBe('/tasks/new')
+ expect(byName.taskDetail?.path).toBe('/tasks/:id')
+ expect(byName.login?.meta?.public).toBe(true)
+ })
+})
diff --git a/frontend/tests/unit/session.spec.ts b/frontend/tests/unit/session.spec.ts
new file mode 100644
index 0000000..fc1e014
--- /dev/null
+++ b/frontend/tests/unit/session.spec.ts
@@ -0,0 +1,33 @@
+import { beforeEach, describe, expect, test } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { decodePrincipal } from '@/stores/session'
+
+// JWT = header..sig ; only payload matters. tok() strips
+// padding (like real JWTs) — exercises decodePrincipal's re-pad path.
+function tok(payload: Record): string {
+ const b64 = (o: unknown) =>
+ btoa(JSON.stringify(o)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_')
+ return `${b64({ alg: 'HS256' })}.${b64(payload)}.sig`
+}
+
+describe('decodePrincipal', () => {
+ beforeEach(() => setActivePinia(createPinia()))
+ test('tenant user', () => {
+ const p = decodePrincipal(tok({ sub: '1', tid: 1, role: 'tenant_admin', pids: [] }))
+ expect(p).toEqual({ userId: 1, tenantId: 1, role: 'tenant_admin',
+ projectIds: [], isServiceToken: false })
+ })
+ test('service token (sub=0) → isServiceToken', () => {
+ const p = decodePrincipal(tok({ sub: '0', tid: 1, role: 'system_admin', pids: [] }))
+ expect(p?.isServiceToken).toBe(true)
+ })
+ test('role system_admin → isServiceToken even if sub != 0', () => {
+ const p = decodePrincipal(tok({ sub: '5', tid: 1, role: 'system_admin', pids: [] }))
+ expect(p?.isServiceToken).toBe(true)
+ })
+ test('null / malformed → null', () => {
+ expect(decodePrincipal(null)).toBeNull()
+ expect(decodePrincipal('garbage')).toBeNull()
+ expect(decodePrincipal('a.notbase64!.c')).toBeNull()
+ })
+})
diff --git a/frontend/tests/unit/taskFilter.spec.ts b/frontend/tests/unit/taskFilter.spec.ts
new file mode 100644
index 0000000..6173fe5
--- /dev/null
+++ b/frontend/tests/unit/taskFilter.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'vitest'
+import { filterTasks } from '@/tasks/filter'
+import type { TaskRead } from '@/api/types'
+
+const mk = (id: string, repo: string, status: TaskRead['status']): TaskRead => ({
+ id, repo_id: repo, revision: 'abc', status, priority: 1,
+ created_at: '2026-05-19T00:00:00Z', completed_at: null, error_message: null,
+})
+const items = [
+ mk('aaaa1111', 'org/alpha', 'downloading'),
+ mk('bbbb2222', 'org/beta', 'succeeded'),
+]
+
+describe('filterTasks', () => {
+ test('no filter → all', () => {
+ expect(filterTasks(items, { status: '', q: '' })).toHaveLength(2)
+ })
+ test('status filter', () => {
+ expect(filterTasks(items, { status: 'succeeded', q: '' }).map((t) => t.id))
+ .toEqual(['bbbb2222'])
+ })
+ test('q matches repo or id (case-insensitive)', () => {
+ expect(filterTasks(items, { status: '', q: 'ALPHA' }).map((t) => t.id))
+ .toEqual(['aaaa1111'])
+ expect(filterTasks(items, { status: '', q: 'bbbb' }).map((t) => t.id))
+ .toEqual(['bbbb2222'])
+ })
+})
diff --git a/frontend/tests/unit/taskMutations.spec.ts b/frontend/tests/unit/taskMutations.spec.ts
new file mode 100644
index 0000000..6b7785b
--- /dev/null
+++ b/frontend/tests/unit/taskMutations.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from 'vitest'
+import { canCancel, canDelete } from '@/composables/useTaskMutations'
+
+describe('task action guards', () => {
+ test('canCancel: only non-terminal', () => {
+ expect(canCancel('downloading')).toBe(true)
+ expect(canCancel('pending')).toBe(true)
+ expect(canCancel('succeeded')).toBe(false)
+ expect(canCancel('failed')).toBe(false)
+ expect(canCancel('cancelled')).toBe(false)
+ })
+ test('canDelete: only terminal', () => {
+ expect(canDelete('succeeded')).toBe(true)
+ expect(canDelete('failed')).toBe(true)
+ expect(canDelete('cancelled')).toBe(true)
+ expect(canDelete('downloading')).toBe(false)
+ })
+})
diff --git a/frontend/tests/unit/tokens.spec.ts b/frontend/tests/unit/tokens.spec.ts
new file mode 100644
index 0000000..8302422
--- /dev/null
+++ b/frontend/tests/unit/tokens.spec.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from 'vitest'
+import { readFileSync } from 'node:fs'
+
+// vitest runs with cwd = frontend/ (CI working-directory + no vitest root
+// override), so a cwd-relative path is robust. Reading via import.meta.url +
+// fileURLToPath fails under vitest/happy-dom (import.meta.url is http://).
+const css = readFileSync('src/styles/tokens.scss', 'utf-8')
+
+describe('design tokens', () => {
+ test('defines status color tokens for all 9 task statuses', () => {
+ for (const s of ['pending', 'queued', 'scheduling', 'downloading',
+ 'succeeded', 'failed', 'cancelled', 'assigned', 'in_progress'])
+ expect(css).toContain(`--dlw-status-${s}`)
+ })
+ test('defines a dark theme block', () => {
+ expect(css).toMatch(/(:root\.dark|html\.dark|\.dark)\s*\{/)
+ })
+ test('defines spacing + radius tokens', () => {
+ expect(css).toContain('--dlw-space-')
+ expect(css).toContain('--dlw-radius')
+ })
+})
diff --git a/frontend/tests/unit/ui.spec.ts b/frontend/tests/unit/ui.spec.ts
new file mode 100644
index 0000000..20e3551
--- /dev/null
+++ b/frontend/tests/unit/ui.spec.ts
@@ -0,0 +1,43 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+
+// vi.mock is hoisted — its factory must not reference an outer const
+// (TDZ). Use vi.hoisted so the mock fn is created in the hoisted scope.
+const { setLocaleMock } = vi.hoisted(() => ({ setLocaleMock: vi.fn() }))
+vi.mock('@/i18n', () => ({ setI18nLocale: setLocaleMock }))
+
+import { useUiStore } from '@/stores/ui'
+
+describe('ui store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ localStorage.clear()
+ document.documentElement.classList.remove('dark')
+ setLocaleMock.mockClear()
+ })
+
+ test('toggleTheme flips + persists + sets html.dark', () => {
+ const ui = useUiStore()
+ expect(ui.theme).toBe('light')
+ ui.toggleTheme()
+ expect(ui.theme).toBe('dark')
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+ expect(localStorage.getItem('dlw_theme')).toBe('dark')
+ })
+
+ test('setLocale persists + calls i18n setter', () => {
+ const ui = useUiStore()
+ ui.setLocale('en-US')
+ expect(ui.locale).toBe('en-US')
+ expect(localStorage.getItem('dlw_locale')).toBe('en-US')
+ expect(setLocaleMock).toHaveBeenCalledWith('en-US')
+ })
+
+ test('toggleSidebar persists', () => {
+ const ui = useUiStore()
+ const before = ui.sidebarCollapsed
+ ui.toggleSidebar()
+ expect(ui.sidebarCollapsed).toBe(!before)
+ expect(localStorage.getItem('dlw_sidebar')).toBe(String(!before))
+ })
+})
diff --git a/frontend/tests/unit/useLiveResource.spec.ts b/frontend/tests/unit/useLiveResource.spec.ts
new file mode 100644
index 0000000..b6841f0
--- /dev/null
+++ b/frontend/tests/unit/useLiveResource.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from 'vitest'
+import { computeInterval } from '@/composables/useLiveResource'
+
+describe('computeInterval', () => {
+ const base = 2000
+ test('active + visible → base', () => {
+ expect(computeInterval({ base, terminal: false, hidden: false, errored: false })).toBe(2000)
+ })
+ test('terminal → false (stop)', () => {
+ expect(computeInterval({ base, terminal: true, hidden: false, errored: false })).toBe(false)
+ })
+ test('hidden → base × 3', () => {
+ expect(computeInterval({ base, terminal: false, hidden: true, errored: false })).toBe(6000)
+ })
+ test('errored (no data) → 5000 backoff', () => {
+ expect(computeInterval({ base, terminal: false, hidden: false, errored: true })).toBe(5000)
+ })
+ test('terminal beats hidden/errored', () => {
+ expect(computeInterval({ base, terminal: true, hidden: true, errored: true })).toBe(false)
+ })
+})
diff --git a/frontend/tests/unit/useTaskDetail.spec.ts b/frontend/tests/unit/useTaskDetail.spec.ts
index 9f0d4db..55a35c3 100644
--- a/frontend/tests/unit/useTaskDetail.spec.ts
+++ b/frontend/tests/unit/useTaskDetail.spec.ts
@@ -1,43 +1,14 @@
import { describe, expect, test } from 'vitest'
+import { computeInterval } from '@/composables/useLiveResource'
-import { computeRefetchInterval } from '@/composables/useTaskDetail'
-import type { TaskDetail } from '@/api/types'
-
-function fakeTask(status: TaskDetail['status']): TaskDetail {
- return {
- id: 'x',
- repo_id: 'o/r',
- revision: 'a',
- status,
- priority: 1,
- created_at: '2026-01-01T00:00:00Z',
- completed_at: null,
- error_message: null,
- subtasks: [],
- }
-}
-
-describe('computeRefetchInterval', () => {
- test('non-terminal status → 1000ms', () => {
- expect(computeRefetchInterval(fakeTask('downloading'), 'success')).toBe(1000)
- expect(computeRefetchInterval(fakeTask('queued'), 'success')).toBe(1000)
- expect(computeRefetchInterval(fakeTask('scheduling'), 'success')).toBe(1000)
- expect(computeRefetchInterval(fakeTask('pending'), 'success')).toBe(1000)
- })
-
- test('terminal status → false (stop polling)', () => {
- expect(computeRefetchInterval(fakeTask('succeeded'), 'success')).toBe(false)
- expect(computeRefetchInterval(fakeTask('failed'), 'success')).toBe(false)
- expect(computeRefetchInterval(fakeTask('cancelled'), 'success')).toBe(false)
+describe('task-detail polling via computeInterval', () => {
+ test('non-terminal → 1000', () => {
+ expect(computeInterval({ base: 1000, terminal: false, hidden: false, errored: false })).toBe(1000)
})
-
- test('undefined data + pending status (first fetch in flight) → 1000ms', () => {
- expect(computeRefetchInterval(undefined, 'pending')).toBe(1000)
+ test('terminal → false', () => {
+ expect(computeInterval({ base: 1000, terminal: true, hidden: false, errored: false })).toBe(false)
})
-
- test('undefined data + error status → 5000ms backoff', () => {
- // 5xx / network error during initial fetch — back off so we don't hammer
- // a struggling backend at 1 req/sec.
- expect(computeRefetchInterval(undefined, 'error')).toBe(5000)
+ test('errored first fetch → 5000', () => {
+ expect(computeInterval({ base: 1000, terminal: false, hidden: false, errored: true })).toBe(5000)
})
})
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 171ad0d..d844814 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -9,9 +9,20 @@ export default defineConfig({
},
server: {
port: 5173,
+ // A browser SPA can only talk plain HTTP through the dev proxy, so the
+ // default targets a plain-HTTP controller. In the shipped deployment
+ // the executor-facing controller is HTTPS+mTLS (e.g. :8000) and a
+ // separate browser-friendly plain-HTTP instance is run (e.g. :8001).
+ // Override with DLW_API_PROXY when your controller is elsewhere.
proxy: {
- '/api': { target: 'http://localhost:8000', changeOrigin: false },
- '/health': { target: 'http://localhost:8000', changeOrigin: false },
+ '/api': {
+ target: process.env.DLW_API_PROXY ?? 'http://localhost:8001',
+ changeOrigin: false,
+ },
+ '/health': {
+ target: process.env.DLW_API_PROXY ?? 'http://localhost:8001',
+ changeOrigin: false,
+ },
},
},
})