From 0571360bce072ab38ca733f80d3be4c645ba335e Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:12:48 +0800 Subject: [PATCH 01/19] =?UTF-8?q?UI-SP3=20spec=20=E2=80=94=20Infrastructur?= =?UTF-8?q?e=20&=20Governance=20(Executors=20/=20Audit=20/=20Quota=20/=20S?= =?UTF-8?q?ettings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive: 2 read endpoints (searchAuditLog already declared; new listExecutors in new file to satisfy lint_no_bearer_on_executor_routes) + 4 frontend pages (Settings is frontend-only). Zero migration; all 'designed-only' features (drain/restart, executor metrics history, HF-token rotate, license CRUD, quota usage history, real-time tail, ML forecast, chargeback PDF) explicitly deferred & documented. Inherits all UI-SP1/SP2 locked decisions. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-20-ui-sp3-infra-governance-design.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-ui-sp3-infra-governance-design.md diff --git a/docs/superpowers/specs/2026-05-20-ui-sp3-infra-governance-design.md b/docs/superpowers/specs/2026-05-20-ui-sp3-infra-governance-design.md new file mode 100644 index 0000000..532162d --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-ui-sp3-infra-governance-design.md @@ -0,0 +1,308 @@ +# UI-SP3 — Infrastructure & Governance (Design) + +> Sub-project 3 of the Web UI decomposition (UI-SP1 = PR #19; UI-SP2 = PR #20 `de9573a`). +> Status: design self-approved per project Rule #1 (autonomous; recommended/conservative at every fork). +> Branch: `feat/ui-sp3-infra-governance`. + +## 1. Context & Scope + +SP3 covers the **governance / infrastructure** pages from the UI decomposition: Executors, Audit log, +Quota, Settings. Following the SP2 cycle and the project's "additive = lowest blast radius" lesson, +SP3 ships **only what real backend tables can support** and **defers everything that needs +write paths, new tables, or designed-only data** (drain/restart, executor metrics history, HF-token +rotation, license-policy CRUD, ML forecast, chargeback PDF, real-time tail, etc.). + +**In scope (additive, zero Alembic migration):** + +1. **Backend — 2 read-only endpoints over the existing schema:** + - `GET /api/v1/audit/log` — implements the **already-declared** `searchAuditLog` + (`api/openapi.yaml:1266-1295`) → response `{ items: [AuditEntry] }`, params + `actor_user_id` / `action` (prefix match) / `from` / `to` / `cursor`. Tenant-scoped + (`AuditLog.tenant_id == principal.tenant_id`). Cursor = opaque base64 of + `occurred_at.isoformat() + "|" + id`, same shape as SP2's `events`. NEW Python file + `src/dlw/api/audit.py` (`require_perm` — not allowed in mTLS files). + - `GET /api/v1/executors` — **NEW path** added to `openapi.yaml` (`listExecutors`, + `tags: [executors]`); **must live in a new file `src/dlw/api/executors_read.py`** + because `tools/lint_invariants.py:check_no_bearer_on_executor_routes` forbids + non-mTLS deps on `src/dlw/api/executors.py`. Returns + `{ items: [ExecutorRead] }` filtered by tenant visibility: + `Executor.tenant_id IS NULL OR Executor.tenant_id == principal.tenant_id` + (own-tenant + shared-infra view; matches the security invariant + "tenant_admin cannot list executors outside their tenant"). System admins + (`principal.role == "system_admin"` or `principal.is_service`) bypass the + filter and see all executors. + +2. **Frontend — 4 new pages, all reusing SP1/SP2 conventions:** + - **`/executors`** — host-grouped list (group by `Executor.host_id`), per-executor row + (status badge mapped onto the 9 status tokens, health score, last-heartbeat-ago, + disk free/total, NIC speed if present, capabilities tags). Status filter + (all / healthy / degraded / suspect / faulty). Polled via `useLiveResource` + (5 s) — terminal predicate never fires (executors are long-lived), polling stops + only when the tab is hidden via the SP2-added `document.visibilityState` slow-down. + - **`/audit`** — paginated, filterable audit log. Filters: action prefix (free-text), + actor (user_id), date range (from/to). Cursor-paginated via `Load older` (same + pattern as SP2 event log). Polled at 10 s (terminal: never; pause when hidden). + - **`/quota`** — richer client-side display of the **existing** `GET /api/v1/quota/current` + (no new endpoint). Three usage cards (bytes / storage / concurrent), each with a + progress bar, formatted values, and a threshold chip (`≥85%` warn, `≥100%` over). + - **`/settings`** — **frontend-only**. Reuses `stores/session.ts` (principal: user_id / + tenant_id / role / project_ids / `isServiceToken`) + `stores/ui.ts` (theme / locale) + + `/health/active` widget showing the controller's leader status. No backend changes. + +3. **Nav + router**: 4 new routes (`executors`, `audit`, `quota`, `settings`), 4 new + nav-registry items (icons from Element Plus, optional `roles` chip). Auth guard + unchanged. + +**Out of scope (deferred & documented):** + +- Drain/restart actions on executors (no endpoints; only mTLS register/renew/heartbeat/poll exist). +- Executor metrics history, heartbeat history, NIC utilization chart (no historical tables). +- `GET /quota/usage` (declared in the contract but the backing `usage_records`/`quota_snapshots` + tables **do not exist on disk** — implementing faithfully would require a migration, which + the inherited "no Alembic" rule forbids). +- HF token rotation, source-driver registration, license-policy CRUD, maintenance mode + (all write-side, would need new tables and admin endpoints). +- Real-time audit tail (would need SSE/WS — deferred to UI-SP5). +- ML quota forecast, chargeback PDF, per-region cost breakdown (designed-only). +- ECharts; canvas matrix. + +## 2. Inherited Locked Decisions (binding on SP3) + +All from UI-SP1 §8 and UI-SP2 §2: + +- `useLiveResource` is the **only** realtime seam (with the SP2-added optional `enabled`). +- `DataBoundary` wraps every view (loading skeleton / empty / error / forbidden). +- Element Plus 2.x — **no new runtime dep**. `el-table` with `max-height` + cursor pagination + for large lists (SP2 contingency stays; `el-table-v2` remains untested in this codebase). +- 9 status semantic colours from `styles/tokens.scss`; never colour-only (icon + label + colour); + ≥ 4.5:1 contrast. +- `stores/session.ts` for principal; tenant chip stays read-only. +- Additive backend only: new files + new routes + `openapi.yaml` + Pydantic DTOs; **no schema + changes, no Alembic migration, no edits to existing routes**. +- Pass existing CI only (`pytest`, `OpenAPI` lint, `Invariant` lint, `lint_no_direct_status_write`, + `frontend-lint`, `frontend-build`, `markdown lint` — `docs/operator/**` is **not** globbed by + markdownlint). +- i18n: `en-US.json` + `zh-CN.json` at exact key parity (verified by `localeParity.spec.ts`). +- `noUncheckedIndexedAccess` is on — guard every `arr[i]`. +- Frontend tests: `vi.hoisted` for plain holders + async `vi.mock(async () => { const { ref } = await import('vue'); ... })` for any mock that must return a real Vue ref (the SP2 BLOCKER-fix pattern). +- Bash cwd persists across calls → all `git` commands use explicit `cd /d/download_weights && git …`; + frontend tooling uses `cd /d/download_weights/frontend && pnpm …`. + +## 3. Backend Design + +### 3.1 Contract (`api/openapi.yaml`) + +- Implement `searchAuditLog` to **exactly match** the on-disk schema (response is + `{ items: [AuditEntry] }` — no `next_cursor` in the declared response; the cursor is + the page boundary handle, but the contract doesn't expose it. We return `next_cursor` + inside `items[]`? No — we keep the response shape exactly as declared and signal + "more pages exist" by returning `len(items) == limit`. The client passes the last + row's encoded cursor back via the `cursor` query parameter. This matches the contract + byte-faithfully.). +- **Add** one new path (in the existing `# ========== Executors ==========` section + style, with `$ref` to existing component params/responses): + + ```yaml + /executors: + get: + tags: [executors] + summary: List executors visible to the principal + operationId: listExecutors + parameters: + - in: query + name: status + schema: {type: string, enum: [joining, healthy, degraded, suspect, faulty]} + responses: + '200': + description: Executors + content: + application/json: + schema: {$ref: '#/components/schemas/ExecutorListResponse'} + ``` + +- **Add** one new schema `ExecutorListResponse` containing `items: [ExecutorRead]` (the existing + `ExecutorRead` schema in `api/openapi.yaml` is **declared but currently unused** — + `spectral` reports it as an unused component; SP3 uses it, eliminating the warning). + If `ExecutorRead`'s declared shape does not contain all the fields the UI needs + (e.g. `host_id`, `capabilities`, `disk_free_gb`, `disk_total_gb`, `nic_speed_gbps`), + the spec is to **extend** `ExecutorRead` additively with the missing fields rather + than introduce a parallel schema (keeps the contract canonical). Verified at plan time. + +### 3.2 Endpoints (router placement) + +| Path | File | Auth | Tenant gate | Notes | +|---|---|---|---|---| +| `GET /api/v1/audit/log` | **new** `src/dlw/api/audit.py` (`prefix="/api/v1/audit"`) | `Depends(require_perm("/api/v1/audit*", "GET"))` | `AuditLog.tenant_id == principal.tenant_id` (single-column filter; no need for the cancel-pattern gate because there's no parent resource) | Returns `{ items: [AuditEntry] }`. Params: `actor_user_id`, `action` (prefix `LIKE`), `from_: datetime | None = Query(default=None, alias="from")`, `to: datetime | None = Query(...)`, `cursor: str | None = Query(...)`. Default page = 50, hard cap 200. Order: `occurred_at DESC, id DESC`. Cursor encode/decode reuses SP2's helpers (extract into `src/dlw/services/_pagination.py` to share with SP2 if cheap; otherwise duplicate the two helpers — they're 6 lines). | +| `GET /api/v1/executors` | **new** `src/dlw/api/executors_read.py` (`prefix="/api/v1/executors"`) | `Depends(require_perm("/api/v1/executors*", "GET"))` | `Executor.tenant_id IS NULL OR Executor.tenant_id == principal.tenant_id`; bypassed for `system_admin` / `is_service` | Returns `{ items: [ExecutorRead] }`. Optional `status` query (validated by enum). Order: `host_id ASC, id ASC` (stable host grouping). No pagination (executor count is bounded — small per environment). | + +Both routes follow the existing `require_perm` + `_session` dep pattern. Neither uses +`require_bearer` — that's mTLS-only and forbidden by `lint_no_bearer_on_executor_routes` +on `executors.py`/`subtasks.py`, but the rule does not scan our new file names. + +### 3.3 Service layer + +New `src/dlw/services/audit_query.py` (single function `search_audit_log(session, tenant_id, *, actor_user_id, action_prefix, from_, to_, cursor, limit) -> tuple[list[AuditLog], str | None]`) +and `src/dlw/services/executors_read.py` (single function +`list_executors_for_principal(session, principal, status_filter) -> list[Executor]`). + +Keeping each ≤ 60 lines and self-contained. + +### 3.4 DTOs (`src/dlw/schemas/audit.py`, `src/dlw/schemas/executor_read.py`) + +New small files. `AuditEntryRead` and `ExecutorRead` Pydantic DTOs with +`model_config = ConfigDict(from_attributes=True)` matching the OpenAPI shapes +exactly (`actor_ip` and `trace_id` fall back to `""` when ORM column is `None`, +because the contract declares them non-nullable; `payload` falls back to `{}`; +`prev_hash`/`tenant_id`/`actor_user_id`/`resource_id` declared nullable already). + +### 3.5 Tests (`tests/api/`) + +One file per endpoint, mirroring SP2 fixture-block pattern: + +- `test_audit_search.py` — happy (insert 3 audit rows; query default page; assert + shape + newest-first order) + cross-tenant 404 isn't applicable (no parent resource) + → **cross-tenant filter** test (insert tenant 2 row, query as tenant 1, must not + appear) + unauth 401 + filter by `action` prefix + filter by `actor_user_id` + + cursor pagination (next page non-overlapping). +- `test_executors_list.py` — happy (insert 2 executors with `tenant_id=1` and + `tenant_id=NULL`; auth as tenant 1 → both appear) + tenant isolation (tenant 2's + executor must not appear for tenant 1) + unauth 401 + `status` query filter. + +Each test file: ~120 lines, copy-paste fixture block from SP2's `test_task_detail_*.py` +(module bootstrap drops/creates all + seeds Tenant/Project/User/StorageBackend id=1). + +## 4. Frontend Design + +### 4.1 Pages & components (`frontend/src/pages/`, `frontend/src/components/infra/`) + +New SFCs: + +- `pages/Executors.vue` — top: status filter (el-select). Body: `DataBoundary` → grouped list, + each group = host card with header (`host_id`, executor count, NIC speed sum) and a list of + `ExecutorRow` children. `useExecutors` composable (live). +- `pages/Audit.vue` — top: filter bar (`action` text input, `actor_user_id` number, `from` / + `to` date pickers, "重置筛选" button). Body: `DataBoundary` → `AuditRow` list + "Load older" + button. `useAuditLog` composable (live). +- `pages/QuotaPage.vue` — three `QuotaCard` components fed by `useQuota` (already exists). No + backend changes. +- `pages/Settings.vue` — three sections: **Profile** (principal fields), **Preferences** + (theme/locale via `stores/ui.ts`), **System** (controller leader state widget via a + new `useSystemHealth` composable hitting `GET /health/active`). + +New components in `frontend/src/components/infra/`: + +- `ExecutorRow.vue` — props `executor: ExecutorRead`. Renders id, status badge, health + score, last-heartbeat ago (formatted), disk usage bar (inline SVG), NIC speed chip. +- `AuditRow.vue` — props `entry: AuditEntry`. Renders `occurred_at` (formatted), actor (id or + "system"), `outcome` chip (success / denied / error colour), `action` code, `resource_type`, + `resource_id` (truncated to 16 chars with full-id tooltip), `trace_id` (clickable to + `details.trace_id` if any — no external link; just a copyable code). +- `QuotaCard.vue` — props `label`, `used`, `quota`, `format` (bytes|gb|count). Stacked bar with + threshold chip. +- `HealthPill.vue` — props `state: string`. Pill mapped onto status tokens + (`active` → success, `recovering` → warning, `standby` → info, else danger). + +### 4.2 Composables + +- `useExecutors(statusRef: Ref)` — `useLiveResource(['executors', statusRef], …, { baseIntervalMs: 5_000, enabled: ref(true) })`. +- `useAuditLog(filters: ReactiveFilters)` — wraps useLiveResource at 10 s polling; `Load older` + is a one-shot `client.get(.../audit/log?cursor=…)` matching SP2's event-log pattern. +- `useSystemHealth()` — `useLiveResource(['health-active'], () => client.get('/health/active'), { baseIntervalMs: 10_000 })`. + +### 4.3 Utilities + +`frontend/src/utils/format.ts` — extend with `formatDateTime(iso: string | null): string` +(locale-aware) and `formatTimeAgo(iso: string | null): string` (returns "5 min ago" / +"1 h ago" / "2 d ago" / "—"). Add `formatPercent(num, total)` helper if useful (otherwise +inline). + +### 4.4 Router + Nav + +`frontend/src/router/index.ts` — append 4 routes (`executors`, `audit`, `quota`, `settings`), +all with `props: false` (no params). No new `meta` keys. + +`frontend/src/nav/registry.ts` — append 4 `NAV_ITEMS` entries (`Monitor`, `Document`, `Histogram`, +`Setting` icons from Element Plus). Each has a `labelKey` under `nav.*`. No `roles` filter for +this SP (server enforces; tenant_admin sees own data; tenant_operator users hitting +admin-only endpoints get 403 surfaced via `DataBoundary`'s `forbidden` slot — already supported). + +### 4.5 i18n + +Three new top-level blocks added to **both** `en-US.json` and `zh-CN.json` +(at exact parity, verified by `localeParity.spec.ts`): + +- `nav.executors`, `nav.audit`, `nav.quota`, `nav.settings` +- `executors.*` (heading, statusAll, healthy/degraded/suspect/faulty/joining, columns, + empty, lastHeartbeat, diskUsage, capabilities, etc.) +- `audit.*` (heading, filterAction, filterActor, filterFrom, filterTo, reset, columns, + outcome.success/denied/error, empty, loadOlder) +- `quotaPage.*` (heading, byteUsage, storageUsage, concurrentUsage, threshold.warn, + threshold.over) +- `settings.*` (heading, profile, preferences, system, theme.light/dark/auto, locale, + controllerState, principal.user/tenant/role/projects/serviceToken) + +### 4.6 Tests + +Mirror SP2 patterns exactly: + +- Pure-fn specs: `formatDateTime.spec.ts`, `formatTimeAgo.spec.ts`. +- Component specs: `ExecutorRow.spec.ts`, `AuditRow.spec.ts`, `QuotaCard.spec.ts`, + `HealthPill.spec.ts`. +- Composable wiring specs: `sp3Composables.spec.ts` (mock `@/api/client` + `@/composables/useLiveResource` + via `vi.hoisted` + `vi.mock`; assert key, path, polling interval). +- Page specs (full mount, vi.hoisted plain holders + async `vi.mock(async () => { const { ref } = await import('vue'); ... })` for `useExecutors`/`useAuditLog`/`useQuota`/`useSystemHealth`/`useTaskMutations`-equivalents-not-needed; assert `DataBoundary` empty state when no data, filtered rendering when data, filters trigger refetch). +- Locale parity spec already exists (catches additions automatically). + +## 5. Data Flow & Error Handling + +Identical to SP2: + +- `useLiveResource` is the only seam (`enabled` for visibility-paused polling). +- Per-pane `DataBoundary` (`forbidden` covers 403; `error` covers 5xx; `empty` covers 200-no-rows). +- axios 401 interceptor (existing) → logout + `/login?reason=invalid_token`. +- No new Pinia store; server state is query state. + +## 6. Milestones (preview for writing-plans) + +- **M1 — Backend**: `audit.py` + service + DTO + 6 tests; `executors_read.py` + service + DTO + 4 tests; + openapi.yaml additions (1 new path + extend `ExecutorRead` if needed); pass full `pytest` + + `spectral` + `swagger-cli` + `lint_invariants` + `lint_no_direct_status_write`. +- **M2 — Frontend foundation**: api/types additions; 3 composables; format helpers (`formatDateTime`, + `formatTimeAgo`); pure-fn specs. +- **M3 — Components**: `ExecutorRow`, `AuditRow`, `QuotaCard`, `HealthPill` + their specs. +- **M4 — Pages + i18n + smoke + docs**: 4 pages, router/nav additions, both locales, + page specs, headed-Playwright smoke against a fresh `:8011`-style controller (using the + same recipe SP2 codified), update `docs/operator/web-ui.md`. + +## 7. Risks & contingencies + +- **`AuditEntry` non-nullable contract fields with nullable ORM columns** (`actor_ip`, + `trace_id`): Pydantic DTO coerces `None → ""` to match the contract byte-faithfully. + Documented; OpenAPI lint passes. +- **`ExecutorRead` schema mismatch**: if the on-disk `ExecutorRead` schema lacks fields + the UI wants, extend it additively in `openapi.yaml`. If extending is non-trivial, the + contingency is to keep the contract minimal (`id`, `status`, `health_score`, + `last_heartbeat_at`, `host_id`, `tenant_id`) and have the UI render only those — + acceptable scope reduction documented in the plan. +- **Tenant filter for executors**: the chosen rule (`tenant_id IS NULL OR == principal.tenant_id`, + bypass for `system_admin` / `is_service`) honours the documented invariant; a tenant_admin + test asserts cross-tenant executors don't appear. +- **`searchAuditLog` response lacks `next_cursor`**: client detects "more available" by + `items.length === limit`; documented. If the UX needs a cursor handle exposed, that's a + contract addition in a follow-up — out of scope for SP3. +- **Audit table indexes**: there's no explicit `(tenant_id, occurred_at)` index. At dev/test + scale the planner handles it; at production scale this becomes a documented follow-up + (creating an index is an Alembic migration, which the locked decision forbids in SP3). + +## 8. Self-Review + +- **Placeholder scan**: none — every endpoint, DTO, service, page, component, and test has a + concrete shape and contract reference. +- **Consistency**: single `useLiveResource` seam (§2/§4.2), DataBoundary everywhere + (§4.1/§5), additive backend (§1/§3), contract-faithful response shapes (§3.1/§3.2). No + contradictions. +- **Scope**: one plan — 2 backend endpoints + 4 frontend pages — matches SP2's shape. + Designed-only items explicitly deferred (§1 out-of-scope list). +- **Ambiguity**: endpoint paths, DTO field nullability, tenant filter clause, cursor encode + shape, polling intervals all pinned. Router/nav additions enumerated. i18n blocks listed. +- **Risks** all accompanied by an explicit mitigation or documented follow-up (§7). From 54ac39bb13dfe51c717f3dfde9e420601cc36f9e Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:20:37 +0800 Subject: [PATCH 02/19] =?UTF-8?q?UI-SP3=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=2016=20bite-sized=20TDD=20tasks=20across=20M1-M4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 backend (1 openapi extend+add, 2 endpoints w/ tests, gate), M2 frontend foundation (types, format utils, 3 composables, gate), M3 components (4 visual), M4 i18n parity + 4 pages + router/nav + full gate + headed smoke + docs. Complete code, no placeholders; grounded in verified on-disk contract + lint-rule placement constraints. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-20-ui-sp3-infra-governance.md | 2764 +++++++++++++++++ 1 file changed, 2764 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md diff --git a/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md b/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md new file mode 100644 index 0000000..1625fd9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md @@ -0,0 +1,2764 @@ +# UI-SP3 — Infrastructure & Governance 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 2 additive read-only backend endpoints (audit search, executors list) + 4 new frontend pages (Executors, Audit, Quota, Settings) — all reusing SP1+SP2 conventions byte-faithfully. + +**Architecture:** Backend = 2 new Python modules (`api/audit.py`, `api/executors_read.py`) — placed in NEW files because `tools/lint_invariants.py:check_no_bearer_on_executor_routes` forbids non-mTLS deps in the existing `api/executors.py`; matching tenant filters in service layer; openapi.yaml extends `ExecutorRead` additively + adds `listExecutors` path + `ExecutorListResponse` schema; `searchAuditLog` implements the already-declared shape exactly. Frontend = 4 pages, 3 composables (all wrap the single `useLiveResource` seam with the SP2-added `enabled` option), 4 visual components, 5 new i18n blocks at exact en/zh parity. Zero Alembic migration. + +**Tech Stack:** FastAPI · SQLAlchemy 2 async · asyncpg · Pydantic v2 · pytest · OpenAPI 3.1 (spectral + swagger-cli) · Vue 3.5 ` + + +``` + +`frontend/src/components/infra/ExecutorRow.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Verify + lint + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- HealthPill ExecutorRow` → PASS. `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/components/infra/HealthPill.vue frontend/src/components/infra/ExecutorRow.vue frontend/tests/unit/HealthPill.spec.ts frontend/tests/unit/ExecutorRow.spec.ts && git commit -q -m "UI-SP3 M3: HealthPill + ExecutorRow"` + +--- + +### Task 10: `AuditRow` + +**Files:** +- Create: `frontend/src/components/infra/AuditRow.vue` +- Test: `frontend/tests/unit/AuditRow.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import AuditRow from '@/components/infra/AuditRow.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('AuditRow', () => { + test('success outcome → success tag, action visible', () => { + const w = mount(AuditRow, { + props: { + entry: { + id: 1, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: 7, actor_ip: '10.0.0.1', action: 'task.created', + resource_type: 'task', resource_id: 'abcdef1234567890abcdef', + outcome: 'success', payload: {}, trace_id: 't1', + prev_hash: null, self_hash: 's', + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('task.created') + expect(w.text()).toContain('7') + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('success') + expect(w.text()).toContain('abcdef1234567890') // first 16 chars at least + }) + test('denied → danger', () => { + const w = mount(AuditRow, { + props: { + entry: { + id: 2, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: null, actor_ip: '', action: 'task.denied', + resource_type: 'task', resource_id: null, outcome: 'denied', + payload: {}, trace_id: '', prev_hash: null, self_hash: 's', + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('danger') + expect(w.text()).toContain('system') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +`cd /d/download_weights/frontend && pnpm test:unit -- AuditRow` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the component** + +```vue + + + + + +``` + +- [ ] **Step 4: Verify + lint + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- AuditRow` → PASS. `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/components/infra/AuditRow.vue frontend/tests/unit/AuditRow.spec.ts && git commit -q -m "UI-SP3 M3: AuditRow"` + +--- + +### Task 11: `QuotaCard` + +**Files:** +- Create: `frontend/src/components/infra/QuotaCard.vue` +- Test: `frontend/tests/unit/QuotaCard.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import QuotaCard from '@/components/infra/QuotaCard.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('QuotaCard', () => { + test('renders label, formatted used/quota, percent', () => { + const w = mount(QuotaCard, { + props: { label: 'bytes', used: 1024 * 1024, quota: 2 * 1024 * 1024, + format: 'bytes' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('1.0 MB') + expect(w.text()).toContain('2.0 MB') + expect(w.text()).toContain('50%') + }) + test('over-threshold → warning chip', () => { + const w = mount(QuotaCard, { + props: { label: 'concurrent', used: 9, quota: 10, format: 'count' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('90%') + expect(w.text()).toContain(en.quotaPage.threshold.warn) + }) + test('over-cap → over chip + 100%', () => { + const w = mount(QuotaCard, { + props: { label: 'bytes', used: 200, quota: 100, format: 'bytes' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('100%') + expect(w.text()).toContain(en.quotaPage.threshold.over) + }) + test('zero quota → renders 0% (no NaN)', () => { + const w = mount(QuotaCard, { + props: { label: 'x', used: 0, quota: 0, format: 'count' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('0%') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +`cd /d/download_weights/frontend && pnpm test:unit -- QuotaCard` +Expected: FAIL. + +- [ ] **Step 3: Create the component** + +```vue + + + + + +``` + +- [ ] **Step 4: Verify + lint + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- QuotaCard` → PASS. `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/components/infra/QuotaCard.vue frontend/tests/unit/QuotaCard.spec.ts && git commit -q -m "UI-SP3 M3: QuotaCard"` + +--- + +# Milestone M4 — Pages + i18n + nav + smoke + docs + +### Task 12: i18n — add 5 blocks to both locales (parity) + +**Files:** +- Modify: `frontend/src/locale/en-US.json`, `frontend/src/locale/zh-CN.json` + +- [ ] **Step 1: Append to `en-US.json` `nav` block** + +Replace the existing `"nav"` block contents with: + +```json + "nav": { "dashboard": "Overview", "tasks": "Tasks", "createTask": "New task", + "executors": "Executors", "audit": "Audit log", + "quota": "Quota", "settings": "Settings" }, +``` + +(Add the 4 new keys after `createTask`, keeping the existing 3.) + +- [ ] **Step 2: Append the 4 new top-level blocks to `en-US.json`** + +After the `"errors"` block (the last top-level block, near the end of the file), add a comma and: + +```json + "executors": { + "heading": "Executors", "empty": "No executors visible", + "filterStatus": "Status", "all": "All", + "joining": "joining", "healthy": "healthy", "degraded": "degraded", + "suspect": "suspect", "faulty": "faulty", + "health": "Health", "lastHeartbeat": "Heartbeat", + "disk": "Disk", "gbps": "Gbps", + "host": "Host", "tenant": "Tenant", "shared": "shared infra" + }, + "audit": { + "heading": "Audit log", "empty": "No audit entries", + "filterAction": "Action prefix", "filterActor": "Actor user id", + "filterFrom": "From", "filterTo": "To", "reset": "Reset filters", + "loadOlder": "Load older", "systemActor": "system" + }, + "quotaPage": { + "heading": "Quota", "byteUsage": "Bytes this month", + "storageUsage": "Storage", "concurrentUsage": "Concurrent tasks", + "threshold": { "warn": "WARN", "over": "OVER" } + }, + "settings": { + "heading": "Settings", "profile": "Profile", "preferences": "Preferences", + "system": "System", + "principal": { "user": "User", "tenant": "Tenant", "role": "Role", + "projects": "Projects", "serviceToken": "Service token" }, + "theme": "Theme", "themeLight": "Light", "themeDark": "Dark", + "localeLabel": "Language", "controllerState": "Controller state" + } +``` + +- [ ] **Step 3: Mirror exactly in `zh-CN.json` (same nesting, same keys)** + +Same structure with Chinese strings: + +```json + "nav": { "dashboard": "概览", "tasks": "任务", "createTask": "新建任务", + "executors": "执行节点", "audit": "审计日志", + "quota": "配额", "settings": "设置" }, +``` + +And the 4 new top-level blocks: + +```json + "executors": { + "heading": "执行节点", "empty": "无可见执行节点", + "filterStatus": "状态", "all": "全部", + "joining": "加入中", "healthy": "健康", "degraded": "降级", + "suspect": "可疑", "faulty": "故障", + "health": "健康分", "lastHeartbeat": "心跳", + "disk": "磁盘", "gbps": "Gbps", + "host": "主机", "tenant": "租户", "shared": "共享基础设施" + }, + "audit": { + "heading": "审计日志", "empty": "暂无审计记录", + "filterAction": "动作前缀", "filterActor": "操作者 user id", + "filterFrom": "起始时间", "filterTo": "结束时间", "reset": "重置筛选", + "loadOlder": "加载更早", "systemActor": "系统" + }, + "quotaPage": { + "heading": "配额", "byteUsage": "本月流量", + "storageUsage": "存储", "concurrentUsage": "并发任务", + "threshold": { "warn": "警告", "over": "超限" } + }, + "settings": { + "heading": "设置", "profile": "个人资料", "preferences": "偏好设置", + "system": "系统", + "principal": { "user": "用户", "tenant": "租户", "role": "角色", + "projects": "项目", "serviceToken": "服务 token" }, + "theme": "主题", "themeLight": "浅色", "themeDark": "深色", + "localeLabel": "语言", "controllerState": "控制器状态" + } +``` + +- [ ] **Step 4: Verify parity + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- localeParity` → PASS (key sets identical). `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/locale/en-US.json frontend/src/locale/zh-CN.json && git commit -q -m "UI-SP3 M4: i18n — nav + executors + audit + quotaPage + settings (en/zh parity)"` + +--- + +### Task 13: Executors page + +**Files:** +- Create: `frontend/src/pages/Executors.vue`, `frontend/tests/unit/ExecutorsPage.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```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' + +const { execData } = vi.hoisted(() => ({ + execData: { value: null as unknown }, +})) + +vi.mock('@/composables/useExecutors', async () => { + const { ref } = await import('vue') + return { + useExecutors: () => ({ + data: ref(execData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +function mountPage() { + return import('@/pages/Executors.vue').then((m) => + mount(m.default, { global: { plugins: [ElementPlus, i18n] } })) +} + +describe('Executors page', () => { + beforeEach(() => { setActivePinia(createPinia()); execData.value = null }) + test('no data → empty', async () => { + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('data present → host-grouped rows render', async () => { + execData.value = { + items: [ + { id: 'h1-w1', status: 'healthy', health_score: 95, epoch: 1, + host_id: 'h1', tenant_id: 1, last_heartbeat_at: null, + nic_speed_gbps: 10, disk_free_gb: null, disk_total_gb: null, + created_at: null }, + { id: 'h1-w2', status: 'degraded', health_score: 60, epoch: 1, + host_id: 'h1', tenant_id: 1, last_heartbeat_at: null, + nic_speed_gbps: 10, disk_free_gb: null, disk_total_gb: null, + created_at: null }, + { id: 'h2-w1', status: 'healthy', health_score: 100, epoch: 1, + host_id: 'h2', tenant_id: null, last_heartbeat_at: null, + nic_speed_gbps: null, disk_free_gb: null, disk_total_gb: null, + created_at: null }, + ], + } + const w = await mountPage() + await flushPromises() + expect(w.findAllComponents({ name: 'ExecutorRow' }).length).toBe(3) + expect(w.text()).toContain('h1') + expect(w.text()).toContain('h2') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +`cd /d/download_weights/frontend && pnpm test:unit -- ExecutorsPage` +Expected: FAIL. + +- [ ] **Step 3: Create the page** + +`frontend/src/pages/Executors.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Verify + lint + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- ExecutorsPage` → PASS. `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/pages/Executors.vue frontend/tests/unit/ExecutorsPage.spec.ts && git commit -q -m "UI-SP3 M4: Executors page (host-grouped, status filter)"` + +--- + +### Task 14: Audit page + +**Files:** +- Create: `frontend/src/pages/Audit.vue`, `frontend/tests/unit/AuditPage.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```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' + +const { auditData } = vi.hoisted(() => ({ + auditData: { value: null as unknown }, +})) + +vi.mock('@/composables/useAuditLog', async () => { + const { ref } = await import('vue') + return { + useAuditLog: () => ({ + data: ref(auditData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + fetchOlderAudit: vi.fn(), + } +}) + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +function mountPage() { + return import('@/pages/Audit.vue').then((m) => + mount(m.default, { global: { plugins: [ElementPlus, i18n] } })) +} + +describe('Audit page', () => { + beforeEach(() => { setActivePinia(createPinia()); auditData.value = null }) + test('no data → empty', async () => { + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('data present → audit rows render + load older shown', async () => { + auditData.value = { + items: [ + { id: 1, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: 7, actor_ip: '', action: 'task.created', + resource_type: 'task', resource_id: 'r', outcome: 'success', + payload: {}, trace_id: '', prev_hash: null, self_hash: 's' }, + ], + next_cursor: 'NEXT', + } + const w = await mountPage() + await flushPromises() + expect(w.findAllComponents({ name: 'AuditRow' }).length).toBe(1) + expect(w.text()).toContain(en.audit.loadOlder) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +`cd /d/download_weights/frontend && pnpm test:unit -- AuditPage` +Expected: FAIL. + +- [ ] **Step 3: Create the page** + +`frontend/src/pages/Audit.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Verify + lint + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- AuditPage` → PASS. `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/pages/Audit.vue frontend/tests/unit/AuditPage.spec.ts && git commit -q -m "UI-SP3 M4: Audit page (filters + cursor-paginated)"` + +--- + +### Task 15: Quota + Settings pages + router/nav additions + +**Files:** +- Create: `frontend/src/pages/QuotaPage.vue`, `frontend/src/pages/Settings.vue` +- Modify: `frontend/src/router/index.ts`, `frontend/src/nav/registry.ts` +- Test: `frontend/tests/unit/QuotaSettingsPage.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```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' + +const { quotaData, healthData } = vi.hoisted(() => ({ + quotaData: { value: null as unknown }, + healthData: { value: null as unknown }, +})) + +vi.mock('@/composables/useQuota', async () => { + const { ref } = await import('vue') + return { + useQuota: () => ({ + data: ref(quotaData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) +vi.mock('@/composables/useSystemHealth', async () => { + const { ref } = await import('vue') + return { + useSystemHealth: () => ({ + data: ref(healthData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) + +const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/=+$/, '') +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('Quota + Settings pages', () => { + beforeEach(() => { + setActivePinia(createPinia()) + quotaData.value = null + healthData.value = null + }) + test('QuotaPage with data → 3 QuotaCards', async () => { + quotaData.value = { + tenant_id: 1, bytes_used_month: 1024, bytes_quota_month: 2048, + storage_gb_used: 1, storage_gb_quota: 10, + concurrent_tasks: 1, concurrent_quota: 5, + } + const m = await import('@/pages/QuotaPage.vue') + const w = mount(m.default, { + global: { plugins: [ElementPlus, i18n] }, + }) + await flushPromises() + expect(w.findAllComponents({ name: 'QuotaCard' }).length).toBe(3) + }) + test('QuotaPage no data → empty', async () => { + const m = await import('@/pages/QuotaPage.vue') + const w = mount(m.default, { + global: { plugins: [ElementPlus, i18n] }, + }) + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('Settings shows principal + system state', async () => { + const { useAuthStore } = await import('@/stores/auth') + useAuthStore().login(`h.${b64({ sub: '7', tid: 3, role: 'tenant_admin', + pids: [9, 11] })}.s`) + healthData.value = { status: 'active', controller_state: 'active' } + const m = await import('@/pages/Settings.vue') + const w = mount(m.default, { + global: { plugins: [ElementPlus, i18n] }, + }) + await flushPromises() + expect(w.text()).toContain('7') // user id + expect(w.text()).toContain('3') // tenant id + expect(w.text()).toContain('tenant_admin') + expect(w.findComponent({ name: 'HealthPill' }).exists()).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +`cd /d/download_weights/frontend && pnpm test:unit -- QuotaSettingsPage` +Expected: FAIL. + +- [ ] **Step 3: Create `QuotaPage.vue`** + +```vue + + + + + +``` + +- [ ] **Step 4: Create `Settings.vue`** + +```vue + + + + + +``` + +- [ ] **Step 5: Add 4 routes to `frontend/src/router/index.ts`** + +After the existing `taskDetail` route (the one with `path: '/tasks/:id'`) and **before** the catch-all `'/:pathMatch(.*)*'`, insert: + +```ts + { + path: '/executors', name: 'executors', + component: () => import('@/pages/Executors.vue'), + }, + { + path: '/audit', name: 'audit', + component: () => import('@/pages/Audit.vue'), + }, + { + path: '/quota', name: 'quota', + component: () => import('@/pages/QuotaPage.vue'), + }, + { + path: '/settings', name: 'settings', + component: () => import('@/pages/Settings.vue'), + }, +``` + +- [ ] **Step 6: Add 4 nav items to `frontend/src/nav/registry.ts`** + +Replace the `NAV_ITEMS` constant with: + +```ts +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' }, + { route: 'executors', labelKey: 'nav.executors', icon: 'Monitor' }, + { route: 'audit', labelKey: 'nav.audit', icon: 'Document' }, + { route: 'quota', labelKey: 'nav.quota', icon: 'DataLine' }, + { route: 'settings', labelKey: 'nav.settings', icon: 'Setting' }, +] +``` + +- [ ] **Step 7: Verify + lint + commit** + +`cd /d/download_weights/frontend && pnpm test:unit -- QuotaSettingsPage` → PASS. `pnpm typecheck` → 0. `pnpm lint:fix && pnpm lint` → OK. +`cd /d/download_weights && git add frontend/src/pages/QuotaPage.vue frontend/src/pages/Settings.vue frontend/src/router/index.ts frontend/src/nav/registry.ts frontend/tests/unit/QuotaSettingsPage.spec.ts && git commit -q -m "UI-SP3 M4: Quota + Settings pages + router/nav additions"` + +--- + +### Task 16: M4 full gate + headed Playwright smoke + docs + +**Files:** `docs/operator/web-ui.md` (modify). + +- [ ] **Step 1: Full backend suite** + +`uv run pytest tests/ -q` → 0 failures (prior 439 + 8 new). + +- [ ] **Step 2: Full frontend gate** + +`cd /d/download_weights/frontend && pnpm test:unit && pnpm typecheck && pnpm lint && pnpm build` → all green. + +- [ ] **Step 3: OpenAPI + invariant + status-write lint** + +```bash +npx --yes @stoplight/spectral-cli lint api/openapi.yaml --fail-severity=error +npx --yes @apidevtools/swagger-cli validate api/openapi.yaml +python tools/lint_invariants.py +python tools/lint_no_direct_status_write.py +``` +All exit 0. + +- [ ] **Step 4: Headed Playwright smoke** + +Per SP2-codified recipe (record in memory): start a fresh ephemeral controller on `:8011` (current SP3 code, same dev DB, plain HTTP): + +```bash +(DLW_AUTH_DEV_MODE=true DLW_SYSTEM_ADMIN_TOKEN=local-admin-token \ + DLW_ENROLLMENT_TOKEN=local-enroll-token \ + DLW_SYSTEM_JWT_SECRET=dev-system-jwt-change-me \ + DLW_CONTROLLER_HOSTNAME=localhost \ + nohup uv run uvicorn dlw.main:create_app --factory \ + --host 127.0.0.1 --port 8011 \ + > .run/logs/controller-8011-sp3.log 2>&1 &) +``` + +Wait for `curl -s -o /dev/null -w '%{http_code}' http://localhost:8011/health/live` = 200. + +Restart Vite proxying to `:8011`: + +```bash +PID=$(netstat -ano | grep ":5173" | grep LISTENING | head -1 | awk '{print $NF}') +[ -n "$PID" ] && taskkill //F //PID $PID +cd /d/download_weights/frontend +# Then launch with Bash run_in_background:true: +# DLW_API_PROXY=http://localhost:8011 pnpm dev > /tmp/vite-sp3.log 2>&1 +# Wait until http://localhost:5173 returns 200. +``` + +Mint a tenant token: + +```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=[]))" > .run/sp3-token.txt +``` + +Create `.run/pw/sp3-smoke.mjs`: + +```js +import { chromium } from 'playwright' +import { readFileSync } from 'node:fs' + +const TOKEN = readFileSync('.run/sp3-token.txt', 'utf8').trim() +const errors = [] +const b = await chromium.launch({ headless: false }) +const pg = await b.newPage() +pg.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) }) +pg.on('pageerror', (e) => errors.push(String(e))) + +await pg.goto('http://localhost:5173/login') +await pg.fill('input', TOKEN) +await pg.click('button[type="submit"]') +await pg.waitForURL('**/') + +for (const [name, path] of [ + ['executors', '/executors'], + ['audit', '/audit'], + ['quota', '/quota'], + ['settings', '/settings'], +]) { + await pg.goto(`http://localhost:5173${path}`) + await pg.waitForTimeout(1500) + await pg.screenshot({ path: `.run/pw/sp3-${name}.png` }) +} + +await b.close() +if (errors.length) { + console.log('SP3 smoke: console/page errors:\n' + errors.join('\n')) + process.exit(1) +} +console.log('SP3 smoke OK') +``` + +Run: `node .run/pw/sp3-smoke.mjs` → prints `SP3 smoke OK`. Record the outcome in the task notes (does not block on local-stack issues — note explicitly). + +- [ ] **Step 5: Append docs to `docs/operator/web-ui.md`** + +After the SP2 section, append: + +```markdown + +## UI-SP3 — Infrastructure & Governance + +Four new pages backed by **two additive read-only endpoints** (zero migration): + +- `GET /api/v1/audit/log` — tenant-scoped audit search (filters: action prefix, + actor_user_id, from/to time range; cursor-paginated; matches the on-disk + `searchAuditLog` contract). +- `GET /api/v1/executors` — browser-facing executor list (own-tenant + + shared-infra view; `system_admin` sees all). Lives in a new module + (`src/dlw/api/executors_read.py`) because the existing `api/executors.py` is + mTLS-only per `tools/lint_invariants.py:check_no_bearer_on_executor_routes`. + +Pages: **/executors** (host-grouped list + status filter), **/audit** (filterable + +cursor pagination), **/quota** (3 cards over the existing `/quota/current`), +**/settings** (frontend-only: principal info from `stores/session.ts`, +theme/locale from `stores/ui.ts`, controller state from `/health/active`). + +**Known deferrals** (intentional, no backend support today): executor +drain/restart, metrics history, heartbeat history; HF-token rotation; +license-policy CRUD; source-driver registration; maintenance mode; +`/quota/usage` (declared but no backing tables); ML forecast; chargeback PDF; +real-time audit tail (UI-SP5). +``` + +- [ ] **Step 6: Commit (only if fixups needed; docs commit always)** + +```bash +cd /d/download_weights && git add docs/operator/web-ui.md && git commit -q -m "UI-SP3 M4: operator docs for the infrastructure & governance pages" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §3.2 endpoints → Tasks 1-3 (audit search, executors list). ✓ +- §3.3 service layer (`audit_query.py`, `executors_read.py`) → Tasks 2,3. ✓ +- §3.4 DTOs (`schemas/audit.py`, `schemas/executor_read.py`) → Tasks 2,3. ✓ +- §3.5 backend tests (happy + tenant isolation + unauth + filters/pagination) → Tasks 2,3. ✓ +- §4.1 pages + DataBoundary → Tasks 13,14,15. ✓ §4.2 components → Tasks 9,10,11. ✓ §4.3 composables → Task 7. ✓ +- §4.4 `formatDateTime`/`formatTimeAgo` → Task 6. ✓ §4.5 i18n parity → Task 12. ✓ +- §4.6 frontend tests (pure + component + page) → Tasks 6,9-15. ✓ +- §5 data flow / per-pane DataBoundary → Tasks 13-15. ✓ §6 milestones M1-M4 → tasks grouped with gates (Tasks 4,8,16). ✓ +- §1 deferrals (drain/restart, HF token, /quota/usage, etc.) → not implemented, documented in Task 16 docs. ✓ +- §7 risks: contract-faithful nullable coercion in DTOs (Task 2, AuditEntryRead); ExecutorRead extended additively (Task 1); tenant filter (Task 3 test asserts cross-tenant exclusion + system_admin bypass); response cursor handle (additive `next_cursor` on the contract — kept additive to support pagination UX). ✓ + +**2. Placeholder scan:** No "TBD/handle edge cases/similar to Task N". Every code step has complete code. + +**3. Type consistency:** +- DTO field names identical across backend (`schemas/audit.py`, `schemas/executor_read.py`), OpenAPI (Task 1 extension), and frontend `types.ts` (Task 5): `AuditEntry/AuditSearchResponse/ExecutorRead/ExecutorListResponse/HealthActive`. +- Composable signatures consistent: `useExecutors(status: Ref)`, `useAuditLog({action, actor, from, to})`, `useSystemHealth()` — match across Task 7 + page consumers (Tasks 13,14,15). +- `formatTimeAgo` / `formatDateTime` signatures match between Task 6 and consumers (ExecutorRow Task 9, AuditRow Task 10). +- Backend tenant filter clauses pinned: `AuditLog.tenant_id == principal.tenant_id` (single-column, no parent resource → no 404 path); `Executor.tenant_id IS NULL OR == principal.tenant_id` (with `system_admin`/`is_service` bypass). +- Router 4 new routes (`executors/audit/quota/settings`) align with nav 4 new items in Task 15. +- i18n keys added in Task 12 are referenced by Tasks 9-15 components/pages: `nav.{executors,audit,quota,settings}`, `executors.*`, `audit.*`, `quotaPage.*`, `settings.*`. Parity test in Task 12 catches any drift. From a2428156c21d509d904fbe9adbf9717f7a6e08a2 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:33:10 +0800 Subject: [PATCH 03/19] UI-SP3 plan: fix 2 BLOCKERs + 2 IMPORTANT + 1 contract-sync from pre-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend B1: policy.csv grants for /api/v1/audit* + /api/v1/executors* (tenant_admin/operator/viewer GET) — without these all auth'd tests 403 - Backend B2: src/dlw/main.py router-include is inside create_app() with `from dlw.api.X import router as X_router` — plan now spells exact patches - Contract sync: also extend openapi.yaml searchAuditLog response with the additive `next_cursor` field so static contract matches runtime DTO - Frontend BL-1: QuotaCard.spec.ts ran before i18n add → en.quotaPage was undefined → TypeError; inline expected key-path strings - Frontend IM-1: widen el-radio-group @update:model-value param to the full emit union to satisfy vue-tsc strict - Frontend IM-3: add :step=1 :precision=0 to el-input-number actor filter Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-20-ui-sp3-infra-governance.md | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md b/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md index 1625fd9..3ece713 100644 --- a/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md +++ b/docs/superpowers/plans/2026-05-20-ui-sp3-infra-governance.md @@ -153,17 +153,56 @@ Then add the new response schema just before `StorageConfig:` (which currently f ``` -- [ ] **Step 3: Validate the contract** +- [ ] **Step 3: Extend the audit search response with `next_cursor` (additive)** + +In `api/openapi.yaml`, in the `/audit/log` GET block (lines 1266–1295), replace the response `schema:` block: + +```yaml + '200': + description: Audit entries + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + next_cursor: + type: string + nullable: true + description: Opaque cursor for the next page; null when no more rows. +``` + +(Strictly additive — keeps the existing `items` shape, only adds the optional `next_cursor` so the static contract and the runtime DTO are consistent.) + +- [ ] **Step 4: Extend `src/dlw/authz/policy.csv` with grants for the 2 new resources** + +(Pre-review BLOCKER fix: without these, all tenant_admin tests would 403 because `make_app_with_state` builds the casbin enforcer from `policy.csv` and there are no matching rows for `/api/v1/audit*` or `/api/v1/executors*` yet.) + +Append after the existing `tenant_viewer ... /api/v1/quota*` line (i.e. after line 7 of `policy.csv`, BEFORE the `g, role:* …` group rows): + +``` +p, role:tenant_admin, /api/v1/audit*, ^GET$, tenant_match +p, role:tenant_admin, /api/v1/executors*, ^GET$, tenant_match +p, role:tenant_operator, /api/v1/audit*, ^GET$, tenant_match +p, role:tenant_operator, /api/v1/executors*, ^GET$, tenant_match +p, role:tenant_viewer, /api/v1/audit*, ^GET$, tenant_match +p, role:tenant_viewer, /api/v1/executors*, ^GET$, tenant_match +``` + +- [ ] **Step 5: Validate the contract** Run: `npx --yes @stoplight/spectral-cli lint api/openapi.yaml --fail-severity=error` Expected: 0 errors (warnings allowed; the existing `oas3-unused-component ExecutorRead` warning should DISAPPEAR because it's now referenced by `ExecutorListResponse`). Run: `npx --yes @apidevtools/swagger-cli validate api/openapi.yaml` Expected: `api/openapi.yaml is valid`. -- [ ] **Step 4: Commit** +- [ ] **Step 6: Commit** ```bash -cd /d/download_weights && git add api/openapi.yaml && git commit -q -m "UI-SP3 M1: openapi — listExecutors path + extend ExecutorRead + ExecutorListResponse" +cd /d/download_weights && git add api/openapi.yaml src/dlw/authz/policy.csv && git commit -q -m "UI-SP3 M1: openapi (listExecutors+extend ExecutorRead+ExecutorListResponse+searchAuditLog next_cursor) + policy.csv grants for audit/executors" ``` --- @@ -389,10 +428,8 @@ class AuditEntryRead(BaseModel): class AuditSearchResponse(BaseModel): """Response for GET /api/v1/audit/log. - The on-disk contract (`searchAuditLog`) declares only {items}; we - additively expose `next_cursor` because pagination is otherwise opaque - to the client. The extra field is forward-compatible (existing JSON - consumers ignore unknown fields) and is needed by the UI-SP3 audit page. + Matches the on-disk contract (`searchAuditLog`) which UI-SP3 extends in + Task 1 Step 3 to include `next_cursor` (nullable) for client pagination. """ items: list[AuditEntryRead] next_cursor: str | None = None @@ -521,15 +558,15 @@ async def get_audit_log( - [ ] **Step 6: Register the router in `src/dlw/main.py`** -Read `src/dlw/main.py` first; find the block where existing routers are included (look for `app.include_router(tasks.router)` or similar). Append: +The existing pattern (verified `src/dlw/main.py:285-307`): every router is **lazy-imported inside `create_app()`** using `from dlw.api.X import router as X_router; app.include_router(X_router)`. The last existing line is `app.include_router(source_proxy_router)` at line 307. + +Add **two lines** immediately after line 307 (inside `create_app()`, same 4-space indent): ```python -from dlw.api import audit as audit_api -app.include_router(audit_api.router) + from dlw.api.audit import router as audit_router + app.include_router(audit_router) ``` -(Place the `from dlw.api import audit as audit_api` line near the other `from dlw.api import …` imports; place `app.include_router(audit_api.router)` next to the other `include_router` calls. If the file uses a different idiom, follow it byte-faithfully — the goal is "appended like the existing routers".) - - [ ] **Step 7: Run the test to verify it passes** Run: `uv run pytest tests/api/test_audit_search.py -v` @@ -808,11 +845,11 @@ async def list_executors( - [ ] **Step 6: Register the router in `src/dlw/main.py`** -Append next to the audit router from Task 2: +Add **two lines** immediately after the audit-router lines added in Task 2 (inside `create_app()`, same 4-space indent): ```python -from dlw.api import executors_read as executors_read_api -app.include_router(executors_read_api.router) + from dlw.api.executors_read import router as executors_read_router + app.include_router(executors_read_router) ``` - [ ] **Step 7: Run the test to verify it passes** @@ -1685,7 +1722,11 @@ describe('QuotaCard', () => { global: { plugins: [ElementPlus, i18n] }, }) expect(w.text()).toContain('90%') - expect(w.text()).toContain(en.quotaPage.threshold.warn) + // Pre-review BLOCKER fix: Task 11 (this component) runs BEFORE Task 12 + // adds the i18n keys, so we cannot deref `en.quotaPage.threshold.warn` + // (would throw at test-collection time). Assert against the literal + // i18n key path that vue-i18n returns on a missing key. + expect(w.text()).toContain('quotaPage.threshold.warn') }) test('over-cap → over chip + 100%', () => { const w = mount(QuotaCard, { @@ -1693,7 +1734,7 @@ describe('QuotaCard', () => { global: { plugins: [ElementPlus, i18n] }, }) expect(w.text()).toContain('100%') - expect(w.text()).toContain(en.quotaPage.threshold.over) + expect(w.text()).toContain('quotaPage.threshold.over') }) test('zero quota → renders 0% (no NaN)', () => { const w = mount(QuotaCard, { @@ -2236,6 +2277,8 @@ function reset() { :placeholder="t('audit.filterActor')" size="small" :min="1" + :step="1" + :precision="0" controls-position="right" style="width: 180px" /> @@ -2525,7 +2568,8 @@ const { data: health } = useSystemHealth() {{ t('settings.localeLabel') }} English From cc695b35805b1e479cdc4673a54e3a36ad67b2fc Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:35:27 +0800 Subject: [PATCH 04/19] UI-SP3 M1: openapi (listExecutors+extend ExecutorRead+ExecutorListResponse+searchAuditLog next_cursor) + policy.csv grants for audit/executors --- api/openapi.yaml | 65 +++++++++++++++++++++++++++++++++++++++- src/dlw/authz/policy.csv | 6 ++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 160f046..01ca044 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -644,6 +644,24 @@ paths: controller_state: { type: string, enum: [standby] } # ========== Executors ========== + /executors: + get: + tags: [executors] + summary: List executors visible to the principal (UI-SP3) + operationId: listExecutors + parameters: + - in: query + name: status + schema: + type: string + enum: [joining, healthy, degraded, suspect, faulty] + responses: + '200': + description: Executors visible to the principal + content: + application/json: + schema: {$ref: '#/components/schemas/ExecutorListResponse'} + /executors/register: post: tags: [executors] @@ -1289,10 +1307,15 @@ paths: application/json: schema: type: object + required: [items] properties: items: type: array items: {$ref: '#/components/schemas/AuditEntry'} + next_cursor: + type: string + nullable: true + description: Opaque cursor for the next page; null when no more rows. # ========== Storage Backends ========== /storage-backends: @@ -2499,7 +2522,11 @@ components: ExecutorRead: type: object required: [id, status, health_score, epoch] - description: Returned by /join and /heartbeat to confirm registration. + description: | + Returned by /join, /heartbeat, and the browser-facing /executors list. + UI-SP3 extends this with optional/nullable fields needed by the + Executors page; pre-existing consumers (mTLS /join, /heartbeat) are + unaffected because all new fields are nullable / optional. properties: id: type: string @@ -2515,6 +2542,42 @@ components: type: integer minimum: 0 description: Current epoch (fence token). Increments on every /join. + host_id: + type: string + nullable: true + description: Physical host identifier (groups co-located executors). + tenant_id: + type: integer + format: int64 + nullable: true + description: Owning tenant; null for shared-infra executors. + last_heartbeat_at: + type: string + format: date-time + nullable: true + nic_speed_gbps: + type: integer + nullable: true + disk_free_gb: + type: integer + format: int64 + nullable: true + disk_total_gb: + type: integer + format: int64 + nullable: true + created_at: + type: string + format: date-time + nullable: true + + ExecutorListResponse: + type: object + required: [items] + properties: + items: + type: array + items: {$ref: '#/components/schemas/ExecutorRead'} StorageConfig: type: object diff --git a/src/dlw/authz/policy.csv b/src/dlw/authz/policy.csv index ba16202..9f42adb 100644 --- a/src/dlw/authz/policy.csv +++ b/src/dlw/authz/policy.csv @@ -5,6 +5,12 @@ p, role:tenant_operator, /api/v1/tasks*, ^(GET|POST|DELETE)$, tenant_match p, role:tenant_operator, /api/v1/quota*, ^GET$, tenant_match p, role:tenant_viewer, /api/v1/tasks*, ^GET$, tenant_match p, role:tenant_viewer, /api/v1/quota*, ^GET$, tenant_match +p, role:tenant_admin, /api/v1/audit*, ^GET$, tenant_match +p, role:tenant_admin, /api/v1/executors*, ^GET$, tenant_match +p, role:tenant_operator, /api/v1/audit*, ^GET$, tenant_match +p, role:tenant_operator, /api/v1/executors*, ^GET$, tenant_match +p, role:tenant_viewer, /api/v1/audit*, ^GET$, tenant_match +p, role:tenant_viewer, /api/v1/executors*, ^GET$, tenant_match g, role:system_admin, role:system_admin g, role:tenant_admin, role:tenant_admin g, role:tenant_operator, role:tenant_operator From 7f1afedfb5f84679022d6c2f0e8e71ee6660760d Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:38:54 +0800 Subject: [PATCH 05/19] UI-SP3 M1: audit-log search endpoint (tenant-scoped, cursor-paginated) --- src/dlw/api/audit.py | 39 ++++++++ src/dlw/main.py | 2 + src/dlw/schemas/audit.py | 40 ++++++++ src/dlw/services/audit_query.py | 68 ++++++++++++++ tests/api/test_audit_search.py | 160 ++++++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+) create mode 100644 src/dlw/api/audit.py create mode 100644 src/dlw/schemas/audit.py create mode 100644 src/dlw/services/audit_query.py create mode 100644 tests/api/test_audit_search.py diff --git a/src/dlw/api/audit.py b/src/dlw/api/audit.py new file mode 100644 index 0000000..3b4b63d --- /dev/null +++ b/src/dlw/api/audit.py @@ -0,0 +1,39 @@ +"""GET /api/v1/audit/log — tenant-scoped audit search (UI-SP3).""" +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from dlw.auth.principal import Principal +from dlw.authz.deps import require_perm +from dlw.db.session import get_engine +from dlw.schemas.audit import AuditSearchResponse +from dlw.services.audit_query import search_audit_log + +router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) + + +async def _session(): + factory = async_sessionmaker(get_engine(), expire_on_commit=False) + async with factory() as session: + yield session + + +@router.get("/log") +async def get_audit_log( + actor_user_id: int | None = Query(default=None), + action: str | None = Query(default=None, description="prefix match"), + from_: datetime | None = Query(default=None, alias="from"), + to: datetime | None = Query(default=None), + cursor: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + principal: Principal = Depends(require_perm("/api/v1/audit*", "GET")), + session: AsyncSession = Depends(_session), +) -> AuditSearchResponse: + items, next_cursor = await search_audit_log( + session, principal.tenant_id, + actor_user_id=actor_user_id, action_prefix=action, + from_=from_, to=to, cursor=cursor, limit=limit) + return AuditSearchResponse(items=items, next_cursor=next_cursor) diff --git a/src/dlw/main.py b/src/dlw/main.py index 54460bb..9b78a7d 100644 --- a/src/dlw/main.py +++ b/src/dlw/main.py @@ -305,6 +305,8 @@ def create_app() -> FastAPI: app.include_router(quota_router) from dlw.api.source_proxy import router as source_proxy_router app.include_router(source_proxy_router) + from dlw.api.audit import router as audit_router + app.include_router(audit_router) # DX only: advertise the Bearer-JWT scheme in the generated OpenAPI so # Swagger /docs shows an "Authorize" button and authenticated diff --git a/src/dlw/schemas/audit.py b/src/dlw/schemas/audit.py new file mode 100644 index 0000000..0f80b02 --- /dev/null +++ b/src/dlw/schemas/audit.py @@ -0,0 +1,40 @@ +"""UI-SP3 Audit-search read-only DTOs (additive).""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class AuditEntryRead(BaseModel): + """Mirrors api/openapi.yaml AuditEntry (`searchAuditLog` response item). + + Contract declares several fields as non-nullable that the ORM column + permits NULL; we coerce None -> "" (or {}) to stay contract-faithful. + """ + model_config = ConfigDict(from_attributes=True) + + id: int + occurred_at: datetime + tenant_id: int | None + actor_user_id: int | None + actor_ip: str # contract non-nullable; coerced from None in router + action: str + resource_type: str + resource_id: str | None + outcome: str + payload: dict[str, Any] # contract non-nullable; coerced {} in router + trace_id: str # contract non-nullable; coerced "" in router + prev_hash: str | None + self_hash: str + + +class AuditSearchResponse(BaseModel): + """Response for GET /api/v1/audit/log. + + Matches the on-disk contract (`searchAuditLog`) which UI-SP3 extends in + Task 1 Step 3 to include `next_cursor` (nullable) for client pagination. + """ + items: list[AuditEntryRead] + next_cursor: str | None = None diff --git a/src/dlw/services/audit_query.py b/src/dlw/services/audit_query.py new file mode 100644 index 0000000..3e033db --- /dev/null +++ b/src/dlw/services/audit_query.py @@ -0,0 +1,68 @@ +"""UI-SP3 audit-log search (read-only; tenant-scoped; cursor-paginated).""" +from __future__ import annotations + +import base64 +from datetime import datetime + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from dlw.db.models.audit import AuditLog +from dlw.schemas.audit import AuditEntryRead + + +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 search_audit_log( + session: AsyncSession, tenant_id: int, *, + actor_user_id: int | None, + action_prefix: str | None, + from_: datetime | None, + to: datetime | None, + cursor: str | None, + limit: int, +) -> tuple[list[AuditEntryRead], str | None]: + stmt = select(AuditLog).where(AuditLog.tenant_id == tenant_id) + if actor_user_id is not None: + stmt = stmt.where(AuditLog.actor_user_id == actor_user_id) + if action_prefix: + stmt = stmt.where(AuditLog.action.like(f"{action_prefix}%")) + if from_ is not None: + stmt = stmt.where(AuditLog.occurred_at >= from_) + if to is not None: + stmt = stmt.where(AuditLog.occurred_at <= to) + stmt = stmt.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 = [ + AuditEntryRead( + id=r.id, occurred_at=r.occurred_at, tenant_id=r.tenant_id, + actor_user_id=r.actor_user_id, + actor_ip=str(r.actor_ip) if r.actor_ip is not None else "", + action=r.action, resource_type=r.resource_type, + resource_id=r.resource_id, outcome=r.outcome, + payload=r.payload or {}, + trace_id=r.trace_id or "", + prev_hash=r.prev_hash, self_hash=r.self_hash, + ) + 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_audit_search.py b/tests/api/test_audit_search.py new file mode 100644 index 0000000..2843edd --- /dev/null +++ b/tests/api/test_audit_search.py @@ -0,0 +1,160 @@ +"""Tests for GET /api/v1/audit/log (UI-SP3, audit-derived).""" +from __future__ import annotations + +import datetime as dt + +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")) + session.add(Tenant(id=2, slug="other", display_name="Other")) + 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 +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 _seed(engine, *, tenant_id: int, n: int, action: str = "task.note", + base: dt.datetime | None = None): + from dlw.db.models.audit import AuditLog + base = base or dt.datetime(2026, 5, 20, 12, 0, 0, tzinfo=dt.UTC) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as s: + for i in range(n): + s.add(AuditLog( + occurred_at=base + dt.timedelta(seconds=i), + tenant_id=tenant_id, actor_user_id=1, action=action, + resource_type="task", resource_id=f"r{i}", + outcome="success", payload={"i": i}, self_hash="0" * 64)) + await s.commit() + + +@pytest.mark.slow +async def test_audit_unauthenticated_401(client: AsyncClient) -> None: + r = await client.get("/api/v1/audit/log") + assert r.status_code == 401 + + +@pytest.mark.slow +async def test_audit_tenant_isolation( + client: AsyncClient, auth, engine, +) -> None: + await _seed(engine, tenant_id=2, n=2, + base=dt.datetime(2026, 5, 20, 9, 0, tzinfo=dt.UTC)) + await _seed(engine, tenant_id=1, n=2, + base=dt.datetime(2026, 5, 20, 10, 0, tzinfo=dt.UTC)) + r = await client.get("/api/v1/audit/log", headers=auth) + assert r.status_code == 200, r.text + items = r.json()["items"] + assert len(items) == 2 + assert all(item["tenant_id"] == 1 for item in items) + + +@pytest.mark.slow +async def test_audit_happy_filters_and_pagination( + client: AsyncClient, auth, engine, +) -> None: + await _seed(engine, tenant_id=1, n=3, action="task.created", + base=dt.datetime(2026, 5, 20, 11, 0, tzinfo=dt.UTC)) + await _seed(engine, tenant_id=1, n=2, action="task.cancelled", + base=dt.datetime(2026, 5, 20, 11, 30, tzinfo=dt.UTC)) + r = await client.get("/api/v1/audit/log?limit=3", headers=auth) + assert r.status_code == 200, r.text + body = r.json() + items = body["items"] + assert len(items) == 3 + assert items[0]["action"] == "task.cancelled" + assert "next_cursor" in body + assert body["next_cursor"] + r2 = await client.get("/api/v1/audit/log?action=task.created&limit=10", + headers=auth) + assert r2.status_code == 200 + items2 = r2.json()["items"] + assert len(items2) >= 3 + assert all(it["action"].startswith("task.created") for it in items2) + r3 = await client.get( + f"/api/v1/audit/log?limit=3&cursor={body['next_cursor']}", + headers=auth) + assert r3.status_code == 200 + page2 = r3.json()["items"] + assert len(page2) >= 1 + assert page2[0]["id"] != items[0]["id"] + + +@pytest.mark.slow +async def test_audit_actor_and_time_range_filters( + client: AsyncClient, auth, engine, +) -> None: + from dlw.db.models.audit import AuditLog + factory = async_sessionmaker(engine, expire_on_commit=False) + base = dt.datetime(2026, 5, 20, 13, 0, 0, tzinfo=dt.UTC) + async with factory() as s: + s.add(AuditLog( + occurred_at=base, tenant_id=1, actor_user_id=42, + action="user.login", resource_type="user", resource_id="42", + outcome="success", payload={}, self_hash="0" * 64)) + s.add(AuditLog( + occurred_at=base + dt.timedelta(hours=1), tenant_id=1, + actor_user_id=99, action="user.login", resource_type="user", + resource_id="99", outcome="success", payload={}, + self_hash="0" * 64)) + await s.commit() + r = await client.get( + "/api/v1/audit/log?actor_user_id=42&limit=10", headers=auth) + items = r.json()["items"] + assert all(it["actor_user_id"] == 42 for it in items) + assert any(it["action"] == "user.login" for it in items) + # ISO format with +00:00 gets URL-mangled (+ → space); use Z suffix. + from_iso = (base + dt.timedelta(minutes=30)).strftime( + "%Y-%m-%dT%H:%M:%SZ") + r2 = await client.get( + f"/api/v1/audit/log?from={from_iso}&limit=10", headers=auth) + items2 = r2.json()["items"] + assert all(it["actor_user_id"] != 42 or + dt.datetime.fromisoformat(it["occurred_at"]) >= + base + dt.timedelta(minutes=30) for it in items2) From 09c61f15605bf8b38dd8ace65ad368a376705b0e Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:40:35 +0800 Subject: [PATCH 06/19] UI-SP3 M1: GET /executors browser-facing list (tenant filter; system_admin bypass) --- src/dlw/api/executors_read.py | 39 +++++++++ src/dlw/main.py | 2 + src/dlw/schemas/executor_read.py | 27 +++++++ src/dlw/services/executors_read.py | 33 ++++++++ tests/api/test_executors_list.py | 122 +++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 src/dlw/api/executors_read.py create mode 100644 src/dlw/schemas/executor_read.py create mode 100644 src/dlw/services/executors_read.py create mode 100644 tests/api/test_executors_list.py diff --git a/src/dlw/api/executors_read.py b/src/dlw/api/executors_read.py new file mode 100644 index 0000000..bb90725 --- /dev/null +++ b/src/dlw/api/executors_read.py @@ -0,0 +1,39 @@ +"""GET /api/v1/executors — browser-facing executor list (UI-SP3). + +NOT in src/dlw/api/executors.py because that file is mTLS-only per +tools/lint_invariants.py:check_no_bearer_on_executor_routes (it forbids +require_bearer-style auth there). This module uses require_perm. +""" +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from dlw.auth.principal import Principal +from dlw.authz.deps import require_perm +from dlw.db.session import get_engine +from dlw.schemas.executor_read import ExecutorListResponse, ExecutorRead +from dlw.services.executors_read import list_executors_for_principal + +router = APIRouter(prefix="/api/v1/executors", tags=["executors"]) + +_StatusLit = Literal["joining", "healthy", "degraded", "suspect", "faulty"] + + +async def _session(): + factory = async_sessionmaker(get_engine(), expire_on_commit=False) + async with factory() as session: + yield session + + +@router.get("") +async def list_executors( + status: _StatusLit | None = Query(default=None), + principal: Principal = Depends(require_perm("/api/v1/executors*", "GET")), + session: AsyncSession = Depends(_session), +) -> ExecutorListResponse: + rows = await list_executors_for_principal(session, principal, status) + return ExecutorListResponse( + items=[ExecutorRead.model_validate(r) for r in rows]) diff --git a/src/dlw/main.py b/src/dlw/main.py index 9b78a7d..fb5d36e 100644 --- a/src/dlw/main.py +++ b/src/dlw/main.py @@ -307,6 +307,8 @@ def create_app() -> FastAPI: app.include_router(source_proxy_router) from dlw.api.audit import router as audit_router app.include_router(audit_router) + from dlw.api.executors_read import router as executors_read_router + app.include_router(executors_read_router) # DX only: advertise the Bearer-JWT scheme in the generated OpenAPI so # Swagger /docs shows an "Authorize" button and authenticated diff --git a/src/dlw/schemas/executor_read.py b/src/dlw/schemas/executor_read.py new file mode 100644 index 0000000..5a4dc39 --- /dev/null +++ b/src/dlw/schemas/executor_read.py @@ -0,0 +1,27 @@ +"""UI-SP3 Executor list read-only DTOs (additive).""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class ExecutorRead(BaseModel): + """Browser-facing executor shape (matches openapi.yaml `ExecutorRead`).""" + model_config = ConfigDict(from_attributes=True) + + id: str + status: str + health_score: int + epoch: int + host_id: str | None + tenant_id: int | None + last_heartbeat_at: datetime | None + nic_speed_gbps: int | None + disk_free_gb: int | None + disk_total_gb: int | None + created_at: datetime | None + + +class ExecutorListResponse(BaseModel): + items: list[ExecutorRead] diff --git a/src/dlw/services/executors_read.py b/src/dlw/services/executors_read.py new file mode 100644 index 0000000..60a4621 --- /dev/null +++ b/src/dlw/services/executors_read.py @@ -0,0 +1,33 @@ +"""UI-SP3 executors list (read-only, tenant-scoped or admin-wide).""" +from __future__ import annotations + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from dlw.auth.principal import Principal +from dlw.db.models.executor import Executor + +_VALID_STATUS = {"joining", "healthy", "degraded", "suspect", "faulty"} + + +def _is_admin(principal: Principal) -> bool: + return (getattr(principal, "is_service", False) + or principal.role == "system_admin") + + +async def list_executors_for_principal( + session: AsyncSession, principal: Principal, + status_filter: str | None, +) -> list[Executor]: + stmt = select(Executor) + if not _is_admin(principal): + stmt = stmt.where(or_( + Executor.tenant_id.is_(None), + Executor.tenant_id == principal.tenant_id)) + if status_filter: + if status_filter not in _VALID_STATUS: + return [] + stmt = stmt.where(Executor.status == status_filter) + stmt = stmt.order_by(Executor.host_id.asc().nullslast(), + Executor.id.asc()) + return list((await session.execute(stmt)).scalars().all()) diff --git a/tests/api/test_executors_list.py b/tests/api/test_executors_list.py new file mode 100644 index 0000000..079b66e --- /dev/null +++ b/tests/api/test_executors_list.py @@ -0,0 +1,122 @@ +"""Tests for GET /api/v1/executors (UI-SP3).""" +from __future__ import annotations + +import datetime as dt + +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")) + session.add(Tenant(id=2, slug="other", display_name="Other")) + 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 +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 _seed_executors(engine): + from dlw.db.models.executor import Executor + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as s: + s.add(Executor(id="t1-w1", host_id="host-a", cert_fingerprint="fp1", + status="healthy", epoch=1, health_score=95, + tenant_id=1, nic_speed_gbps=10, + disk_free_gb=500, disk_total_gb=1000, + last_heartbeat_at=dt.datetime( + 2026, 5, 20, 12, 0, tzinfo=dt.UTC))) + s.add(Executor(id="t2-w1", host_id="host-b", cert_fingerprint="fp2", + status="degraded", epoch=2, health_score=60, + tenant_id=2)) + s.add(Executor(id="shared-1", host_id="host-c", cert_fingerprint="fp3", + status="healthy", epoch=1, health_score=100, + tenant_id=None)) + await s.commit() + + +@pytest.mark.slow +async def test_executors_unauthenticated_401(client: AsyncClient) -> None: + r = await client.get("/api/v1/executors") + assert r.status_code == 401 + + +@pytest.mark.slow +async def test_executors_tenant_filter( + client: AsyncClient, auth, engine, +) -> None: + await _seed_executors(engine) + r = await client.get("/api/v1/executors", headers=auth) + assert r.status_code == 200, r.text + items = r.json()["items"] + ids = {it["id"] for it in items} + assert "t1-w1" in ids + assert "shared-1" in ids + assert "t2-w1" not in ids + + +@pytest.mark.slow +async def test_executors_status_filter( + client: AsyncClient, auth, engine, +) -> None: + await _seed_executors(engine) + r = await client.get("/api/v1/executors?status=healthy", headers=auth) + items = r.json()["items"] + assert all(it["status"] == "healthy" for it in items) + assert {it["id"] for it in items} >= {"t1-w1", "shared-1"} + + +@pytest.mark.slow +async def test_executors_system_admin_sees_all( + client: AsyncClient, engine, +) -> None: + await _seed_executors(engine) + admin = principal_headers(secret=SECRET, role="system_admin", + user_id=0, tenant_id=1) + r = await client.get("/api/v1/executors", headers=admin) + items = r.json()["items"] + ids = {it["id"] for it in items} + assert {"t1-w1", "t2-w1", "shared-1"} <= ids From 680c10d7267ac4ebbd412d55b384c892c36cd62b Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:41:20 +0800 Subject: [PATCH 07/19] UI-SP3 M1 fix: test_executors_list reset PKs between tests (avoid dup-id PK error) --- tests/api/test_executors_list.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/api/test_executors_list.py b/tests/api/test_executors_list.py index 079b66e..e919554 100644 --- a/tests/api/test_executors_list.py +++ b/tests/api/test_executors_list.py @@ -60,9 +60,11 @@ async def client(ephemeral_ca): async def _seed_executors(engine): + from sqlalchemy import text from dlw.db.models.executor import Executor factory = async_sessionmaker(engine, expire_on_commit=False) async with factory() as s: + await s.execute(text("DELETE FROM executors")) s.add(Executor(id="t1-w1", host_id="host-a", cert_fingerprint="fp1", status="healthy", epoch=1, health_score=95, tenant_id=1, nic_speed_gbps=10, From d45ab40442c7c63a18c756e5c889edd1e9e0c84c Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:43:18 +0800 Subject: [PATCH 08/19] UI-SP3 M2: SP3 DTO types (Executor / Audit / HealthActive) --- frontend/src/api/types.ts | 44 ++++++++++++++++++++++++++++ frontend/tests/unit/sp3Types.spec.ts | 29 ++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 frontend/tests/unit/sp3Types.spec.ts diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index cd0b0b4..451390f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -142,3 +142,47 @@ export interface TaskEventsResponse { items: TaskEventItem[] next_cursor: string | null } + +export interface ExecutorRead { + id: string + status: string + health_score: number + epoch: number + host_id: string | null + tenant_id: number | null + last_heartbeat_at: string | null + nic_speed_gbps: number | null + disk_free_gb: number | null + disk_total_gb: number | null + created_at: string | null +} + +export interface ExecutorListResponse { + items: ExecutorRead[] +} + +export interface AuditEntry { + id: number + occurred_at: string + tenant_id: number | null + actor_user_id: number | null + actor_ip: string + action: string + resource_type: string + resource_id: string | null + outcome: string + payload: Record + trace_id: string + prev_hash: string | null + self_hash: string +} + +export interface AuditSearchResponse { + items: AuditEntry[] + next_cursor: string | null +} + +export interface HealthActive { + status: string + controller_state: string +} diff --git a/frontend/tests/unit/sp3Types.spec.ts b/frontend/tests/unit/sp3Types.spec.ts new file mode 100644 index 0000000..4ef2c27 --- /dev/null +++ b/frontend/tests/unit/sp3Types.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import type { + ExecutorRead, ExecutorListResponse, + AuditEntry, AuditSearchResponse, + HealthActive, +} from '@/api/types' + +describe('SP3 DTO types', () => { + test('shapes compile', () => { + const ex: ExecutorRead = { + id: 'e', status: 'healthy', health_score: 100, epoch: 1, + host_id: 'h', tenant_id: 1, last_heartbeat_at: null, + nic_speed_gbps: 10, disk_free_gb: 100, disk_total_gb: 200, + created_at: null, + } + const exList: ExecutorListResponse = { items: [ex] } + const ent: AuditEntry = { + id: 1, occurred_at: 'now', tenant_id: 1, actor_user_id: 1, + actor_ip: '', action: 'task.note', resource_type: 'task', + resource_id: 'r', outcome: 'success', payload: {}, trace_id: '', + prev_hash: null, self_hash: 's', + } + const audit: AuditSearchResponse = { items: [ent], next_cursor: null } + const h: HealthActive = { status: 'active', controller_state: 'active' } + expect(exList.items[0]?.id).toBe('e') + expect(audit.items[0]?.action).toBe('task.note') + expect(h.controller_state).toBe('active') + }) +}) From 9a63df1f9cc19d2022758398421d43b73cf3ebf5 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:45:06 +0800 Subject: [PATCH 09/19] UI-SP3 M2: formatDateTime + formatTimeAgo utils --- frontend/src/utils/format.ts | 21 +++++++++++++++ frontend/tests/unit/formatDateTime.spec.ts | 17 ++++++++++++ frontend/tests/unit/formatTimeAgo.spec.ts | 30 ++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 frontend/tests/unit/formatDateTime.spec.ts create mode 100644 frontend/tests/unit/formatTimeAgo.spec.ts diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index bd14a92..f6728f5 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -27,3 +27,24 @@ export function formatDuration(seconds: number | null | undefined): string { if (m > 0) return `${m}m ${sec}s` return `${sec}s` } + +export function formatDateTime(iso: string | null | undefined): string { + if (iso === null || iso === undefined || iso === '') return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString() +} + +export function formatTimeAgo(iso: string | null | undefined): string { + if (iso === null || iso === undefined || iso === '') return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '—' + const secs = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)) + if (secs < 60) return `${secs}s ago` + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} diff --git a/frontend/tests/unit/formatDateTime.spec.ts b/frontend/tests/unit/formatDateTime.spec.ts new file mode 100644 index 0000000..8d6340b --- /dev/null +++ b/frontend/tests/unit/formatDateTime.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'vitest' +import { formatDateTime } from '@/utils/format' + +describe('formatDateTime', () => { + test('null → em-dash', () => { + expect(formatDateTime(null)).toBe('—') + expect(formatDateTime(undefined)).toBe('—') + }) + test('valid ISO → locale string (not the raw ISO)', () => { + const out = formatDateTime('2026-05-20T12:00:00Z') + expect(out).not.toBe('—') + expect(out).not.toBe('2026-05-20T12:00:00Z') + }) + test('invalid → falls back to the input', () => { + expect(formatDateTime('not-a-date')).toBe('not-a-date') + }) +}) diff --git a/frontend/tests/unit/formatTimeAgo.spec.ts b/frontend/tests/unit/formatTimeAgo.spec.ts new file mode 100644 index 0000000..6de2ff7 --- /dev/null +++ b/frontend/tests/unit/formatTimeAgo.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, vi, afterEach } from 'vitest' +import { formatTimeAgo } from '@/utils/format' + +const NOW = new Date('2026-05-20T12:00:00Z').getTime() + +describe('formatTimeAgo', () => { + afterEach(() => { vi.useRealTimers() }) + test('null → —', () => { + expect(formatTimeAgo(null)).toBe('—') + }) + test('30s ago → "30s ago"', () => { + vi.useFakeTimers().setSystemTime(NOW) + expect(formatTimeAgo('2026-05-20T11:59:30Z')).toBe('30s ago') + }) + test('5m ago → "5m ago"', () => { + vi.useFakeTimers().setSystemTime(NOW) + expect(formatTimeAgo('2026-05-20T11:55:00Z')).toBe('5m ago') + }) + test('2h ago → "2h ago"', () => { + vi.useFakeTimers().setSystemTime(NOW) + expect(formatTimeAgo('2026-05-20T10:00:00Z')).toBe('2h ago') + }) + test('3d ago → "3d ago"', () => { + vi.useFakeTimers().setSystemTime(NOW) + expect(formatTimeAgo('2026-05-17T12:00:00Z')).toBe('3d ago') + }) + test('invalid → —', () => { + expect(formatTimeAgo('not-a-date')).toBe('—') + }) +}) From 4c67d5e03fce814e748d8f432cf40268622a0877 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:46:58 +0800 Subject: [PATCH 10/19] UI-SP3 M2: 3 live composables (executors / audit / health) on the useLiveResource seam --- frontend/src/composables/useAuditLog.ts | 47 +++++++++++ frontend/src/composables/useExecutors.ts | 16 ++++ frontend/src/composables/useSystemHealth.ts | 11 +++ frontend/tests/unit/sp3Composables.spec.ts | 91 +++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 frontend/src/composables/useAuditLog.ts create mode 100644 frontend/src/composables/useExecutors.ts create mode 100644 frontend/src/composables/useSystemHealth.ts create mode 100644 frontend/tests/unit/sp3Composables.spec.ts diff --git a/frontend/src/composables/useAuditLog.ts b/frontend/src/composables/useAuditLog.ts new file mode 100644 index 0000000..35f74ee --- /dev/null +++ b/frontend/src/composables/useAuditLog.ts @@ -0,0 +1,47 @@ +import type { Ref } from 'vue' +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { AuditSearchResponse } from '@/api/types' + +export interface AuditFilters { + action: Ref + actor: Ref + from: Ref + to: Ref +} +export interface AuditFiltersPlain { + action: string + actor: number | null + from: string | null + to: string | null +} + +function buildQuery( + f: AuditFiltersPlain, cursor: string | null, +): string { + const p = new URLSearchParams() + p.set('limit', '50') + if (f.action) p.set('action', f.action) + if (f.actor !== null) p.set('actor_user_id', String(f.actor)) + if (f.from) p.set('from', f.from) + if (f.to) p.set('to', f.to) + if (cursor) p.set('cursor', cursor) + return `/api/v1/audit/log?${p.toString()}` +} + +export function useAuditLog(f: AuditFilters) { + return useLiveResource( + ['audit', f.action, f.actor, f.from, f.to], + async () => (await client.get(buildQuery({ + action: f.action.value, actor: f.actor.value, + from: f.from.value, to: f.to.value, + }, null))).data, + { baseIntervalMs: 10_000 }, + ) +} + +export async function fetchOlderAudit( + f: AuditFiltersPlain, cursor: string, +): Promise { + return (await client.get(buildQuery(f, cursor))).data +} diff --git a/frontend/src/composables/useExecutors.ts b/frontend/src/composables/useExecutors.ts new file mode 100644 index 0000000..004af38 --- /dev/null +++ b/frontend/src/composables/useExecutors.ts @@ -0,0 +1,16 @@ +import type { Ref } from 'vue' +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { ExecutorListResponse } from '@/api/types' + +export function useExecutors(status: Ref) { + return useLiveResource( + ['executors', status], + async () => { + const q = status.value ? `?status=${encodeURIComponent(status.value)}` : '' + return (await client.get( + `/api/v1/executors${q}`)).data + }, + { baseIntervalMs: 5_000 }, + ) +} diff --git a/frontend/src/composables/useSystemHealth.ts b/frontend/src/composables/useSystemHealth.ts new file mode 100644 index 0000000..45fb98b --- /dev/null +++ b/frontend/src/composables/useSystemHealth.ts @@ -0,0 +1,11 @@ +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { HealthActive } from '@/api/types' + +export function useSystemHealth() { + return useLiveResource( + ['health-active'], + async () => (await client.get('/health/active')).data, + { baseIntervalMs: 10_000 }, + ) +} diff --git a/frontend/tests/unit/sp3Composables.spec.ts b/frontend/tests/unit/sp3Composables.spec.ts new file mode 100644 index 0000000..20c1218 --- /dev/null +++ b/frontend/tests/unit/sp3Composables.spec.ts @@ -0,0 +1,91 @@ +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 { useExecutors } from '@/composables/useExecutors' +import { useAuditLog, fetchOlderAudit } from '@/composables/useAuditLog' +import { useSystemHealth } from '@/composables/useSystemHealth' + +describe('SP3 live composables', () => { + test('useExecutors wires key + status query + interval', async () => { + captured.length = 0 + get.mockResolvedValueOnce({ data: { items: [] } }) + const status = ref(null) + const q = useExecutors(status) as unknown as { + __fetcher: () => Promise + } + const last = captured[captured.length - 1] + expect(last?.key).toEqual(['executors', status]) + expect((last?.opts as { baseIntervalMs: number }).baseIntervalMs).toBe(5_000) + await q.__fetcher() + expect(get).toHaveBeenCalledWith('/api/v1/executors') + captured.length = 0 + status.value = 'healthy' + get.mockResolvedValueOnce({ data: { items: [] } }) + const q2 = useExecutors(status) as unknown as { + __fetcher: () => Promise + } + await q2.__fetcher() + expect(get).toHaveBeenCalledWith('/api/v1/executors?status=healthy') + }) + + test('useAuditLog builds query from filters', async () => { + captured.length = 0 + get.mockResolvedValueOnce( + { data: { items: [], next_cursor: null } }) + const filters = { + action: ref('task.'), + actor: ref(42), + from: ref(null), + to: ref(null), + } + const q = useAuditLog(filters) as unknown as { + __fetcher: () => Promise + } + await q.__fetcher() + const call = get.mock.calls[get.mock.calls.length - 1] as + [string, ...unknown[]] | undefined + const url = call?.[0] ?? '' + expect(url).toContain('/api/v1/audit/log') + expect(url).toContain('limit=50') + expect(url).toContain('action=task.') + expect(url).toContain('actor_user_id=42') + }) + + test('fetchOlderAudit appends cursor', async () => { + get.mockResolvedValueOnce( + { data: { items: [], next_cursor: null } }) + await fetchOlderAudit({ action: 'x', actor: null, from: null, to: null }, + 'CURSOR') + const call = get.mock.calls[get.mock.calls.length - 1] as + [string, ...unknown[]] | undefined + const url = call?.[0] ?? '' + expect(url).toContain('cursor=CURSOR') + expect(url).toContain('action=x') + }) + + test('useSystemHealth hits /health/active', async () => { + captured.length = 0 + get.mockResolvedValueOnce( + { data: { status: 'active', controller_state: 'active' } }) + const q = useSystemHealth() as unknown as { + __fetcher: () => Promise + } + await q.__fetcher() + expect(get).toHaveBeenCalledWith('/health/active') + const last = captured[captured.length - 1] + expect((last?.opts as { baseIntervalMs: number }).baseIntervalMs).toBe(10_000) + }) +}) From 7dbb7ff15439d76bd12d988f6834ec7e1c105ab4 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:49:34 +0800 Subject: [PATCH 11/19] UI-SP3 M3: HealthPill + ExecutorRow --- frontend/src/components/infra/ExecutorRow.vue | 76 +++++++++++++++++++ frontend/src/components/infra/HealthPill.vue | 22 ++++++ frontend/tests/unit/ExecutorRow.spec.ts | 48 ++++++++++++ frontend/tests/unit/HealthPill.spec.ts | 42 ++++++++++ 4 files changed, 188 insertions(+) create mode 100644 frontend/src/components/infra/ExecutorRow.vue create mode 100644 frontend/src/components/infra/HealthPill.vue create mode 100644 frontend/tests/unit/ExecutorRow.spec.ts create mode 100644 frontend/tests/unit/HealthPill.spec.ts diff --git a/frontend/src/components/infra/ExecutorRow.vue b/frontend/src/components/infra/ExecutorRow.vue new file mode 100644 index 0000000..bd8f87c --- /dev/null +++ b/frontend/src/components/infra/ExecutorRow.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/components/infra/HealthPill.vue b/frontend/src/components/infra/HealthPill.vue new file mode 100644 index 0000000..a288493 --- /dev/null +++ b/frontend/src/components/infra/HealthPill.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/tests/unit/ExecutorRow.spec.ts b/frontend/tests/unit/ExecutorRow.spec.ts new file mode 100644 index 0000000..16e60a4 --- /dev/null +++ b/frontend/tests/unit/ExecutorRow.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, test, vi, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import ExecutorRow from '@/components/infra/ExecutorRow.vue' +import en from '@/locale/en-US.json' +import type { ExecutorRead } from '@/api/types' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +const ex: ExecutorRead = { + id: 'host-1-w1', status: 'healthy', health_score: 95, epoch: 1, + host_id: 'host-1', tenant_id: 1, + last_heartbeat_at: '2026-05-20T11:55:00Z', + nic_speed_gbps: 10, disk_free_gb: 500, disk_total_gb: 1000, + created_at: null, +} + +describe('ExecutorRow', () => { + afterEach(() => { vi.useRealTimers() }) + test('renders id, status badge, health, NIC, disk', () => { + vi.useFakeTimers().setSystemTime(new Date('2026-05-20T12:00:00Z')) + const w = mount(ExecutorRow, { + props: { executor: ex }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('host-1-w1') + expect(w.text()).toContain('healthy') + expect(w.text()).toContain('95') + expect(w.text()).toContain('5m ago') + expect(w.text()).toContain('10') + expect(w.findComponent({ name: 'ElTag' }).exists()).toBe(true) + }) + test('null fields → em-dash, no crash', () => { + const w = mount(ExecutorRow, { + props: { + executor: { + ...ex, last_heartbeat_at: null, nic_speed_gbps: null, + disk_free_gb: null, disk_total_gb: null, + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('host-1-w1') + }) +}) diff --git a/frontend/tests/unit/HealthPill.spec.ts b/frontend/tests/unit/HealthPill.spec.ts new file mode 100644 index 0000000..55c9b21 --- /dev/null +++ b/frontend/tests/unit/HealthPill.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import HealthPill from '@/components/infra/HealthPill.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('HealthPill', () => { + test('active → success ElTag', () => { + const w = mount(HealthPill, { + props: { state: 'active' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('success') + expect(w.text()).toContain('active') + }) + test('recovering → warning', () => { + const w = mount(HealthPill, { + props: { state: 'recovering' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('warning') + }) + test('standby → info', () => { + const w = mount(HealthPill, { + props: { state: 'standby' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('info') + }) + test('unknown → danger', () => { + const w = mount(HealthPill, { + props: { state: 'broken' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('danger') + }) +}) From 28e880aabda628e671f5bd027bf67be8e22ef39b Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:51:12 +0800 Subject: [PATCH 12/19] UI-SP3 M3: AuditRow --- frontend/src/components/infra/AuditRow.vue | 69 ++++++++++++++++++++++ frontend/tests/unit/AuditRow.spec.ts | 46 +++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 frontend/src/components/infra/AuditRow.vue create mode 100644 frontend/tests/unit/AuditRow.spec.ts diff --git a/frontend/src/components/infra/AuditRow.vue b/frontend/src/components/infra/AuditRow.vue new file mode 100644 index 0000000..97f770e --- /dev/null +++ b/frontend/src/components/infra/AuditRow.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/tests/unit/AuditRow.spec.ts b/frontend/tests/unit/AuditRow.spec.ts new file mode 100644 index 0000000..4853539 --- /dev/null +++ b/frontend/tests/unit/AuditRow.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import AuditRow from '@/components/infra/AuditRow.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('AuditRow', () => { + test('success outcome → success tag, action visible', () => { + const w = mount(AuditRow, { + props: { + entry: { + id: 1, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: 7, actor_ip: '10.0.0.1', action: 'task.created', + resource_type: 'task', resource_id: 'abcdef1234567890abcdef', + outcome: 'success', payload: {}, trace_id: 't1', + prev_hash: null, self_hash: 's', + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('task.created') + expect(w.text()).toContain('7') + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('success') + expect(w.text()).toContain('abcdef1234567890') + }) + test('denied → danger', () => { + const w = mount(AuditRow, { + props: { + entry: { + id: 2, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: null, actor_ip: '', action: 'task.denied', + resource_type: 'task', resource_id: null, outcome: 'denied', + payload: {}, trace_id: '', prev_hash: null, self_hash: 's', + }, + }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.findComponent({ name: 'ElTag' }).props('type')).toBe('danger') + expect(w.text().toLowerCase()).toMatch(/system|audit\.systemactor/) + }) +}) From b9c52e8d160f89e42fa5f9a85c6884d6d5c6282c Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:52:55 +0800 Subject: [PATCH 13/19] UI-SP3 M3: QuotaCard --- frontend/src/components/infra/QuotaCard.vue | 92 +++++++++++++++++++++ frontend/tests/unit/QuotaCard.spec.ts | 49 +++++++++++ 2 files changed, 141 insertions(+) create mode 100644 frontend/src/components/infra/QuotaCard.vue create mode 100644 frontend/tests/unit/QuotaCard.spec.ts diff --git a/frontend/src/components/infra/QuotaCard.vue b/frontend/src/components/infra/QuotaCard.vue new file mode 100644 index 0000000..c3191bd --- /dev/null +++ b/frontend/src/components/infra/QuotaCard.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/tests/unit/QuotaCard.spec.ts b/frontend/tests/unit/QuotaCard.spec.ts new file mode 100644 index 0000000..0d1507d --- /dev/null +++ b/frontend/tests/unit/QuotaCard.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import ElementPlus from 'element-plus' +import QuotaCard from '@/components/infra/QuotaCard.vue' +import en from '@/locale/en-US.json' + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('QuotaCard', () => { + test('renders label, formatted used/quota, percent', () => { + const w = mount(QuotaCard, { + props: { label: 'bytes', used: 1024 * 1024, quota: 2 * 1024 * 1024, + format: 'bytes' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('1.0 MB') + expect(w.text()).toContain('2.0 MB') + expect(w.text()).toContain('50%') + }) + test('over-threshold → warning chip', () => { + const w = mount(QuotaCard, { + props: { label: 'concurrent', used: 9, quota: 10, format: 'count' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('90%') + // Pre-review BLOCKER fix: Task 11 (this component) runs BEFORE Task 12 + // adds the i18n keys; assert against the literal key path that vue-i18n + // returns on a missing key. + expect(w.text()).toContain('quotaPage.threshold.warn') + }) + test('over-cap → over chip + 100%', () => { + const w = mount(QuotaCard, { + props: { label: 'bytes', used: 200, quota: 100, format: 'bytes' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('100%') + expect(w.text()).toContain('quotaPage.threshold.over') + }) + test('zero quota → renders 0% (no NaN)', () => { + const w = mount(QuotaCard, { + props: { label: 'x', used: 0, quota: 0, format: 'count' }, + global: { plugins: [ElementPlus, i18n] }, + }) + expect(w.text()).toContain('0%') + }) +}) From c263ba5801fe6754a9c0ecf0324b4d4a0d90fc87 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 07:58:32 +0800 Subject: [PATCH 14/19] =?UTF-8?q?UI-SP3=20M4:=20i18n=20=E2=80=94=20nav=20+?= =?UTF-8?q?=20executors=20+=20audit=20+=20quotaPage=20+=20settings=20(en/z?= =?UTF-8?q?h=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/locale/en-US.json | 32 ++++++++++++++++++++++++++- frontend/src/locale/zh-CN.json | 32 ++++++++++++++++++++++++++- frontend/tests/unit/QuotaCard.spec.ts | 7 ++---- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/frontend/src/locale/en-US.json b/frontend/src/locale/en-US.json index bfac5bf..fac5378 100644 --- a/frontend/src/locale/en-US.json +++ b/frontend/src/locale/en-US.json @@ -5,7 +5,9 @@ "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" }, + "nav": { "dashboard": "Overview", "tasks": "Tasks", "createTask": "New task", + "executors": "Executors", "audit": "Audit log", + "quota": "Quota", "settings": "Settings" }, "shell": { "theme": "Theme", "language": "Language", "commandHint": "Command palette (Ctrl/⌘+K)", "tenant": "Tenant", "role": "Role" @@ -72,5 +74,33 @@ "network": "Network error, check your connection", "quota_exceeded": "Quota exceeded", "conflict": "State conflict (task not terminal or duplicate)", "validation": "Invalid request parameters", "forbidden": "Forbidden" + }, + "executors": { + "heading": "Executors", "empty": "No executors visible", + "filterStatus": "Status", "all": "All", + "joining": "joining", "healthy": "healthy", "degraded": "degraded", + "suspect": "suspect", "faulty": "faulty", + "health": "Health", "lastHeartbeat": "Heartbeat", + "disk": "Disk", "gbps": "Gbps", + "host": "Host", "tenant": "Tenant", "shared": "shared infra" + }, + "audit": { + "heading": "Audit log", "empty": "No audit entries", + "filterAction": "Action prefix", "filterActor": "Actor user id", + "filterFrom": "From", "filterTo": "To", "reset": "Reset filters", + "loadOlder": "Load older", "systemActor": "system" + }, + "quotaPage": { + "heading": "Quota", "byteUsage": "Bytes this month", + "storageUsage": "Storage", "concurrentUsage": "Concurrent tasks", + "threshold": { "warn": "WARN", "over": "OVER" } + }, + "settings": { + "heading": "Settings", "profile": "Profile", "preferences": "Preferences", + "system": "System", + "principal": { "user": "User", "tenant": "Tenant", "role": "Role", + "projects": "Projects", "serviceToken": "Service token" }, + "theme": "Theme", "themeLight": "Light", "themeDark": "Dark", + "localeLabel": "Language", "controllerState": "Controller state" } } diff --git a/frontend/src/locale/zh-CN.json b/frontend/src/locale/zh-CN.json index b9438a6..1424d18 100644 --- a/frontend/src/locale/zh-CN.json +++ b/frontend/src/locale/zh-CN.json @@ -6,7 +6,9 @@ "tokenRequired": "请输入 token", "oidc": "使用 OIDC 登录" }, "nav": { - "dashboard": "概览", "tasks": "任务", "createTask": "新建任务" + "dashboard": "概览", "tasks": "任务", "createTask": "新建任务", + "executors": "执行节点", "audit": "审计日志", + "quota": "配额", "settings": "设置" }, "shell": { "theme": "主题", "language": "语言", "commandHint": "命令面板 (Ctrl/⌘+K)", @@ -76,5 +78,33 @@ "conflict": "状态冲突(任务可能非终态或重复)", "validation": "请求参数有误", "forbidden": "无权限访问" + }, + "executors": { + "heading": "执行节点", "empty": "无可见执行节点", + "filterStatus": "状态", "all": "全部", + "joining": "加入中", "healthy": "健康", "degraded": "降级", + "suspect": "可疑", "faulty": "故障", + "health": "健康分", "lastHeartbeat": "心跳", + "disk": "磁盘", "gbps": "Gbps", + "host": "主机", "tenant": "租户", "shared": "共享基础设施" + }, + "audit": { + "heading": "审计日志", "empty": "暂无审计记录", + "filterAction": "动作前缀", "filterActor": "操作者 user id", + "filterFrom": "起始时间", "filterTo": "结束时间", "reset": "重置筛选", + "loadOlder": "加载更早", "systemActor": "系统" + }, + "quotaPage": { + "heading": "配额", "byteUsage": "本月流量", + "storageUsage": "存储", "concurrentUsage": "并发任务", + "threshold": { "warn": "警告", "over": "超限" } + }, + "settings": { + "heading": "设置", "profile": "个人资料", "preferences": "偏好设置", + "system": "系统", + "principal": { "user": "用户", "tenant": "租户", "role": "角色", + "projects": "项目", "serviceToken": "服务 token" }, + "theme": "主题", "themeLight": "浅色", "themeDark": "深色", + "localeLabel": "语言", "controllerState": "控制器状态" } } diff --git a/frontend/tests/unit/QuotaCard.spec.ts b/frontend/tests/unit/QuotaCard.spec.ts index 0d1507d..675c155 100644 --- a/frontend/tests/unit/QuotaCard.spec.ts +++ b/frontend/tests/unit/QuotaCard.spec.ts @@ -26,10 +26,7 @@ describe('QuotaCard', () => { global: { plugins: [ElementPlus, i18n] }, }) expect(w.text()).toContain('90%') - // Pre-review BLOCKER fix: Task 11 (this component) runs BEFORE Task 12 - // adds the i18n keys; assert against the literal key path that vue-i18n - // returns on a missing key. - expect(w.text()).toContain('quotaPage.threshold.warn') + expect(w.text()).toContain(en.quotaPage.threshold.warn) }) test('over-cap → over chip + 100%', () => { const w = mount(QuotaCard, { @@ -37,7 +34,7 @@ describe('QuotaCard', () => { global: { plugins: [ElementPlus, i18n] }, }) expect(w.text()).toContain('100%') - expect(w.text()).toContain('quotaPage.threshold.over') + expect(w.text()).toContain(en.quotaPage.threshold.over) }) test('zero quota → renders 0% (no NaN)', () => { const w = mount(QuotaCard, { From c5d9141f76826dbdf0a1be67b9d55ab8fc67eeab Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 08:00:21 +0800 Subject: [PATCH 15/19] UI-SP3 M4: Executors page (host-grouped, status filter) --- frontend/src/pages/Executors.vue | 95 +++++++++++++++++++++++ frontend/tests/unit/ExecutorsPage.spec.ts | 60 ++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 frontend/src/pages/Executors.vue create mode 100644 frontend/tests/unit/ExecutorsPage.spec.ts diff --git a/frontend/src/pages/Executors.vue b/frontend/src/pages/Executors.vue new file mode 100644 index 0000000..3952379 --- /dev/null +++ b/frontend/src/pages/Executors.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/tests/unit/ExecutorsPage.spec.ts b/frontend/tests/unit/ExecutorsPage.spec.ts new file mode 100644 index 0000000..f7a9b23 --- /dev/null +++ b/frontend/tests/unit/ExecutorsPage.spec.ts @@ -0,0 +1,60 @@ +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' + +const { execData } = vi.hoisted(() => ({ + execData: { value: null as unknown }, +})) + +vi.mock('@/composables/useExecutors', async () => { + const { ref } = await import('vue') + return { + useExecutors: () => ({ + data: ref(execData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +function mountPage() { + return import('@/pages/Executors.vue').then((m) => + mount(m.default, { global: { plugins: [ElementPlus, i18n] } })) +} + +describe('Executors page', () => { + beforeEach(() => { setActivePinia(createPinia()); execData.value = null }) + test('no data → empty', async () => { + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('data present → host-grouped rows render', async () => { + execData.value = { + items: [ + { id: 'h1-w1', status: 'healthy', health_score: 95, epoch: 1, + host_id: 'h1', tenant_id: 1, last_heartbeat_at: null, + nic_speed_gbps: 10, disk_free_gb: null, disk_total_gb: null, + created_at: null }, + { id: 'h1-w2', status: 'degraded', health_score: 60, epoch: 1, + host_id: 'h1', tenant_id: 1, last_heartbeat_at: null, + nic_speed_gbps: 10, disk_free_gb: null, disk_total_gb: null, + created_at: null }, + { id: 'h2-w1', status: 'healthy', health_score: 100, epoch: 1, + host_id: 'h2', tenant_id: null, last_heartbeat_at: null, + nic_speed_gbps: null, disk_free_gb: null, disk_total_gb: null, + created_at: null }, + ], + } + const w = await mountPage() + await flushPromises() + expect(w.findAllComponents({ name: 'ExecutorRow' }).length).toBe(3) + expect(w.text()).toContain('h1') + expect(w.text()).toContain('h2') + }) +}) From 62451bec26d713f218e3e3253bdf4ae4c2f7a004 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 08:02:13 +0800 Subject: [PATCH 16/19] UI-SP3 M4: Audit page (filters + cursor-paginated) --- frontend/src/pages/Audit.vue | 128 ++++++++++++++++++++++++++ frontend/tests/unit/AuditPage.spec.ts | 53 +++++++++++ 2 files changed, 181 insertions(+) create mode 100644 frontend/src/pages/Audit.vue create mode 100644 frontend/tests/unit/AuditPage.spec.ts diff --git a/frontend/src/pages/Audit.vue b/frontend/src/pages/Audit.vue new file mode 100644 index 0000000..b0dc2f5 --- /dev/null +++ b/frontend/src/pages/Audit.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/tests/unit/AuditPage.spec.ts b/frontend/tests/unit/AuditPage.spec.ts new file mode 100644 index 0000000..45c8859 --- /dev/null +++ b/frontend/tests/unit/AuditPage.spec.ts @@ -0,0 +1,53 @@ +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' + +const { auditData } = vi.hoisted(() => ({ + auditData: { value: null as unknown }, +})) + +vi.mock('@/composables/useAuditLog', async () => { + const { ref } = await import('vue') + return { + useAuditLog: () => ({ + data: ref(auditData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + fetchOlderAudit: vi.fn(), + } +}) + +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) +function mountPage() { + return import('@/pages/Audit.vue').then((m) => + mount(m.default, { global: { plugins: [ElementPlus, i18n] } })) +} + +describe('Audit page', () => { + beforeEach(() => { setActivePinia(createPinia()); auditData.value = null }) + test('no data → empty', async () => { + const w = await mountPage() + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('data present → audit rows render + load older shown', async () => { + auditData.value = { + items: [ + { id: 1, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: 7, actor_ip: '', action: 'task.created', + resource_type: 'task', resource_id: 'r', outcome: 'success', + payload: {}, trace_id: '', prev_hash: null, self_hash: 's' }, + ], + next_cursor: 'NEXT', + } + const w = await mountPage() + await flushPromises() + expect(w.findAllComponents({ name: 'AuditRow' }).length).toBe(1) + expect(w.text()).toContain(en.audit.loadOlder) + }) +}) From 0f3594ab1ee4230684a821bf414f1eb0f05c37b4 Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 08:04:58 +0800 Subject: [PATCH 17/19] UI-SP3 M4: Quota + Settings pages + router/nav additions --- frontend/src/nav/registry.ts | 4 + frontend/src/pages/QuotaPage.vue | 54 +++++++++++ frontend/src/pages/Settings.vue | 90 +++++++++++++++++++ frontend/src/router/index.ts | 16 ++++ frontend/tests/unit/QuotaSettingsPage.spec.ts | 79 ++++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 frontend/src/pages/QuotaPage.vue create mode 100644 frontend/src/pages/Settings.vue create mode 100644 frontend/tests/unit/QuotaSettingsPage.spec.ts diff --git a/frontend/src/nav/registry.ts b/frontend/src/nav/registry.ts index 4d604c3..d2f709b 100644 --- a/frontend/src/nav/registry.ts +++ b/frontend/src/nav/registry.ts @@ -9,6 +9,10 @@ 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' }, + { route: 'executors', labelKey: 'nav.executors', icon: 'Monitor' }, + { route: 'audit', labelKey: 'nav.audit', icon: 'Document' }, + { route: 'quota', labelKey: 'nav.quota', icon: 'DataLine' }, + { route: 'settings', labelKey: 'nav.settings', icon: 'Setting' }, ] export function visibleNav(role: string, items: NavItem[] = NAV_ITEMS): NavItem[] { diff --git a/frontend/src/pages/QuotaPage.vue b/frontend/src/pages/QuotaPage.vue new file mode 100644 index 0000000..1c8796f --- /dev/null +++ b/frontend/src/pages/QuotaPage.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/src/pages/Settings.vue b/frontend/src/pages/Settings.vue new file mode 100644 index 0000000..179d848 --- /dev/null +++ b/frontend/src/pages/Settings.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8fa9774..67980b9 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,6 +24,22 @@ export const routes: RouteRecordRaw[] = [ component: () => import('@/pages/TaskDetail.vue'), props: true, }, + { + path: '/executors', name: 'executors', + component: () => import('@/pages/Executors.vue'), + }, + { + path: '/audit', name: 'audit', + component: () => import('@/pages/Audit.vue'), + }, + { + path: '/quota', name: 'quota', + component: () => import('@/pages/QuotaPage.vue'), + }, + { + path: '/settings', name: 'settings', + component: () => import('@/pages/Settings.vue'), + }, { path: '/:pathMatch(.*)*', redirect: '/' }, ] diff --git a/frontend/tests/unit/QuotaSettingsPage.spec.ts b/frontend/tests/unit/QuotaSettingsPage.spec.ts new file mode 100644 index 0000000..d29f7cc --- /dev/null +++ b/frontend/tests/unit/QuotaSettingsPage.spec.ts @@ -0,0 +1,79 @@ +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' + +const { quotaData, healthData } = vi.hoisted(() => ({ + quotaData: { value: null as unknown }, + healthData: { value: null as unknown }, +})) + +vi.mock('@/composables/useQuota', async () => { + const { ref } = await import('vue') + return { + useQuota: () => ({ + data: ref(quotaData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) +vi.mock('@/composables/useSystemHealth', async () => { + const { ref } = await import('vue') + return { + useSystemHealth: () => ({ + data: ref(healthData.value), isLoading: ref(false), + isError: ref(false), error: ref(null), + }), + } +}) + +const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/=+$/, '') +const i18n = createI18n({ + legacy: false, locale: 'en-US', messages: { 'en-US': en }, +}) + +describe('Quota + Settings pages', () => { + beforeEach(() => { + setActivePinia(createPinia()) + quotaData.value = null + healthData.value = null + }) + test('QuotaPage with data → 3 QuotaCards', async () => { + quotaData.value = { + tenant_id: 1, bytes_used_month: 1024, bytes_quota_month: 2048, + storage_gb_used: 1, storage_gb_quota: 10, + concurrent_tasks: 1, concurrent_quota: 5, + } + const m = await import('@/pages/QuotaPage.vue') + const w = mount(m.default, { + global: { plugins: [ElementPlus, i18n] }, + }) + await flushPromises() + expect(w.findAllComponents({ name: 'QuotaCard' }).length).toBe(3) + }) + test('QuotaPage no data → empty', async () => { + const m = await import('@/pages/QuotaPage.vue') + const w = mount(m.default, { + global: { plugins: [ElementPlus, i18n] }, + }) + await flushPromises() + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('Settings shows principal + system state', async () => { + const { useAuthStore } = await import('@/stores/auth') + useAuthStore().login(`h.${b64({ sub: '7', tid: 3, role: 'tenant_admin', + pids: [9, 11] })}.s`) + healthData.value = { status: 'active', controller_state: 'active' } + const m = await import('@/pages/Settings.vue') + const w = mount(m.default, { + global: { plugins: [ElementPlus, i18n] }, + }) + await flushPromises() + expect(w.text()).toContain('7') + expect(w.text()).toContain('3') + expect(w.text()).toContain('tenant_admin') + expect(w.findComponent({ name: 'HealthPill' }).exists()).toBe(true) + }) +}) From ed79577015fc9b8c7ae6795800374ea734353d0c Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 08:11:51 +0800 Subject: [PATCH 18/19] UI-SP3 M4: operator docs for the infrastructure & governance pages --- docs/operator/web-ui.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/operator/web-ui.md b/docs/operator/web-ui.md index d7dbd7f..eb40eb4 100644 --- a/docs/operator/web-ui.md +++ b/docs/operator/web-ui.md @@ -106,3 +106,26 @@ 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. + +## UI-SP3 — Infrastructure & Governance + +Four new pages backed by **two additive read-only endpoints** (zero migration): + +- `GET /api/v1/audit/log` — tenant-scoped audit search (filters: action prefix, + actor_user_id, from/to time range; cursor-paginated; matches the on-disk + `searchAuditLog` contract). +- `GET /api/v1/executors` — browser-facing executor list (own-tenant + + shared-infra view; `system_admin` sees all). Lives in a new module + (`src/dlw/api/executors_read.py`) because the existing `api/executors.py` is + mTLS-only per `tools/lint_invariants.py:check_no_bearer_on_executor_routes`. + +Pages: **/executors** (host-grouped list + status filter), **/audit** (filterable + +cursor pagination), **/quota** (3 cards over the existing `/quota/current`), +**/settings** (frontend-only: principal info from `stores/session.ts`, +theme/locale from `stores/ui.ts`, controller state from `/health/active`). + +**Known deferrals** (intentional, no backend support today): executor +drain/restart, metrics history, heartbeat history; HF-token rotation; +license-policy CRUD; source-driver registration; maintenance mode; +`/quota/usage` (declared but no backing tables); ML forecast; chargeback PDF; +real-time audit tail (UI-SP5). From 76db12b24b154c71e3da865de308b633dfefb5ce Mon Sep 17 00:00:00 2001 From: l17728 Date: Wed, 20 May 2026 08:18:41 +0800 Subject: [PATCH 19/19] UI-SP3: final-review HIGH fix + MEDIUM hardening (Audit pagination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: Audit "Load older" reused page-1's next_cursor → duplicate rows on the second click. Added an `olderCursor` ref watched off the live query's next_cursor (immediate=true) and advanced after each fetchOlderAudit; button gated by it; reset() clears it. Added a 3-row regression test that clicks twice and asserts cursor advances + button disappears at exhaustion. - MEDIUM: useAuditLog.buildQuery actor guard hardened from `!== null` to `typeof === 'number' && Number.isFinite(...)` so a stray undefined from el-input-number's clear path can't become `actor_user_id=undefined`. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/composables/useAuditLog.ts | 6 +++- frontend/src/pages/Audit.vue | 18 +++++++--- frontend/tests/unit/AuditPage.spec.ts | 46 +++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/frontend/src/composables/useAuditLog.ts b/frontend/src/composables/useAuditLog.ts index 35f74ee..c3fda90 100644 --- a/frontend/src/composables/useAuditLog.ts +++ b/frontend/src/composables/useAuditLog.ts @@ -22,7 +22,11 @@ function buildQuery( const p = new URLSearchParams() p.set('limit', '50') if (f.action) p.set('action', f.action) - if (f.actor !== null) p.set('actor_user_id', String(f.actor)) + // Guard against el-input-number emitting `undefined` on clear (final-review + // MEDIUM): require a finite number, not just `!== null`. + if (typeof f.actor === 'number' && Number.isFinite(f.actor)) { + p.set('actor_user_id', String(f.actor)) + } if (f.from) p.set('from', f.from) if (f.to) p.set('to', f.to) if (cursor) p.set('cursor', cursor) diff --git a/frontend/src/pages/Audit.vue b/frontend/src/pages/Audit.vue index b0dc2f5..f7d19e8 100644 --- a/frontend/src/pages/Audit.vue +++ b/frontend/src/pages/Audit.vue @@ -1,5 +1,5 @@ @@ -99,7 +107,7 @@ function reset() { :entry="e" />
({ auditData: { value: null as unknown }, })) +const { fetchOlder } = vi.hoisted(() => ({ fetchOlder: vi.fn() })) vi.mock('@/composables/useAuditLog', async () => { const { ref } = await import('vue') @@ -16,7 +17,7 @@ vi.mock('@/composables/useAuditLog', async () => { data: ref(auditData.value), isLoading: ref(false), isError: ref(false), error: ref(null), }), - fetchOlderAudit: vi.fn(), + fetchOlderAudit: fetchOlder, } }) @@ -29,7 +30,11 @@ function mountPage() { } describe('Audit page', () => { - beforeEach(() => { setActivePinia(createPinia()); auditData.value = null }) + beforeEach(() => { + setActivePinia(createPinia()) + auditData.value = null + fetchOlder.mockReset() + }) test('no data → empty', async () => { const w = await mountPage() await flushPromises() @@ -50,4 +55,41 @@ describe('Audit page', () => { expect(w.findAllComponents({ name: 'AuditRow' }).length).toBe(1) expect(w.text()).toContain(en.audit.loadOlder) }) + + test('loadOlder twice → cursor advances, button hides when exhausted', async () => { + auditData.value = { + items: [{ id: 1, occurred_at: '2026-05-20T12:00:00Z', tenant_id: 1, + actor_user_id: null, actor_ip: '', action: 'a', resource_type: 't', + resource_id: null, outcome: 'success', payload: {}, trace_id: '', + prev_hash: null, self_hash: 's' }], + next_cursor: 'C1', + } + fetchOlder.mockResolvedValueOnce({ + items: [{ id: 2, occurred_at: '2026-05-20T11:59:00Z', tenant_id: 1, + actor_user_id: null, actor_ip: '', action: 'a', resource_type: 't', + resource_id: null, outcome: 'success', payload: {}, trace_id: '', + prev_hash: null, self_hash: 's' }], + next_cursor: 'C2', + }) + fetchOlder.mockResolvedValueOnce({ + items: [{ id: 3, occurred_at: '2026-05-20T11:58:00Z', tenant_id: 1, + actor_user_id: null, actor_ip: '', action: 'a', resource_type: 't', + resource_id: null, outcome: 'success', payload: {}, trace_id: '', + prev_hash: null, self_hash: 's' }], + next_cursor: null, + }) + const w = await mountPage() + await flushPromises() + const findBtn = () => w.findAll('button').find( + (b) => b.text() === en.audit.loadOlder) + expect(findBtn()).toBeTruthy() + await findBtn()!.trigger('click') + await flushPromises() + expect(fetchOlder.mock.calls[0]?.[1]).toBe('C1') + await findBtn()!.trigger('click') + await flushPromises() + expect(fetchOlder.mock.calls[1]?.[1]).toBe('C2') + expect(w.findAllComponents({ name: 'AuditRow' }).length).toBe(3) + expect(findBtn()).toBeFalsy() + }) })