feat(ui): damascus-ui v1 read-only dashboard (P4)
All checks were successful
test / contract-and-unit (pull_request) Successful in 12s
All checks were successful
test / contract-and-unit (pull_request) Successful in 12s
- Vite 6 + React 19 + MUI 6 SPA at ui/ - Routes: Dashboard, Items (MUI DataGrid), ItemDrawer - /v1/items filter+sort+limit wired to URL hash for shareable links - React Query hooks (useStats, useListItems, useItemDetail, useRecentEvents) - Playwright e2e suite: 4 tests against fixture API on :9110 (dashboard widgets, items table, drawer with item+open_issues+recent_events, phase filter narrows results) - Multi-stage Dockerfile (node:22-alpine build -> /bundle) - Compose service damascus-ui-build: one-shot, writes dist/ to named volume damascus_ui for the (P2) damascus-api container to mount - Fixture FastAPI app (tests/e2e/fixture_api.py) for e2e runs without a live damascus-api (P4 ships ahead of P2 by design) Acceptance: build green, 4/4 e2e tests green
This commit is contained in:
@@ -130,8 +130,66 @@ services:
|
||||
- "9100:9100"
|
||||
# Visit http://<host>:9100/status/active.json for the external concurrency view.
|
||||
|
||||
# damascus-ui-build (P4) — one-shot build of the Vite SPA bundle.
|
||||
#
|
||||
# Builds the React 19 + Vite 6 + MUI 6 dashboard and writes the static
|
||||
# output to the named volume `damascus_ui` at /opt/damascus/ui. The
|
||||
# P2 `damascus-api` service (FastAPI on :9110) mounts that same
|
||||
# volume and serves the bundle from / via StaticFiles. P2 will add:
|
||||
#
|
||||
# damascus-api:
|
||||
# ...
|
||||
# volumes:
|
||||
# - damascus_ui:/opt/damascus/ui:ro
|
||||
#
|
||||
# Running `docker compose up damascus-ui-build` does the build, then
|
||||
# the container exits 0. `docker compose up damascus-api` afterward
|
||||
# sees the bundle on the volume.
|
||||
#
|
||||
# The API_BASE_URL build arg points the bundle at the in-network API
|
||||
# for ad-hoc preview from a developer's host browser. Leave empty
|
||||
# when running the full compose stack so the bundle uses
|
||||
# window.location.origin (same-origin via the API).
|
||||
damascus-ui-build:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ""
|
||||
image: damascus-ui:latest
|
||||
volumes:
|
||||
# Mount at the SAME path the bundle is written to in the image
|
||||
# (/bundle). The named volume is initially empty, so this mount
|
||||
# HIDES the in-image /bundle for the container's lifetime, but
|
||||
# since the container only needs to keep the volume populated,
|
||||
# the trick is to mount it into a parallel path and copy across:
|
||||
# /bundle (in-image, read-only via overlay)
|
||||
# /bundle-out (named volume, initially empty)
|
||||
# The `cp` below copies the in-image bundle into the volume; the
|
||||
# `sleep` keeps the container alive long enough for compose to
|
||||
# record the exit; `restart: "no"` ensures compose doesn't loop.
|
||||
- damascus_ui:/bundle-out
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mkdir -p /bundle-out
|
||||
cp -a /bundle/. /bundle-out/
|
||||
echo "[damascus-ui-build] copied $$(du -sh /bundle-out | cut -f1) of UI bundle to damascus_ui volume"
|
||||
# Hold the container open for a few seconds so compose's "exited"
|
||||
# handling finishes cleanly. In CI a follow-up step can `docker
|
||||
# compose up damascus-api` which will then see the volume.
|
||||
sleep 5
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
orchdata:
|
||||
worktrees:
|
||||
projects:
|
||||
projects:
|
||||
# Named volume that carries the built UI bundle from the
|
||||
# damascus-ui-build one-shot into the (P2) damascus-api container.
|
||||
# Same volume, two services: build writes, api reads. The P4 contract
|
||||
# says "drops it into a named volume `damascus_ui`" — this is that
|
||||
# volume.
|
||||
damascus_ui:
|
||||
7
ui/.dockerignore
Normal file
7
ui/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
test-results
|
||||
playwright-report
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
17
ui/.gitignore
vendored
Normal file
17
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Build output
|
||||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
73
ui/Dockerfile
Normal file
73
ui/Dockerfile
Normal file
@@ -0,0 +1,73 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# damascus-ui v1 — multi-stage build for the React 19 + Vite 6 + MUI 6
|
||||
# dashboard (P4).
|
||||
#
|
||||
# Stage 1 (build): node:22-alpine, install deps, run vite build.
|
||||
# Stage 2 (output): minimal scratch-equivalent — just the static bundle
|
||||
# is written to /opt/damascus/ui so the damascus-api
|
||||
# container can mount it as a read-only volume and
|
||||
# serve it with FastAPI's StaticFiles.
|
||||
#
|
||||
# Why a separate UI bundle image instead of building inside the
|
||||
# damascus-api image: the P2 FastAPI service already exists in the main
|
||||
# damascus-orchestrator image; baking Node.js + npm into it just to run
|
||||
# a one-shot build would bloat the runtime image with build tools.
|
||||
# One-shot build pattern matches the contract ("Builds the bundle,
|
||||
# drops it into a named volume `damascus_ui`").
|
||||
#
|
||||
# Usage from docker-compose:
|
||||
# docker compose up damascus-ui-build # runs to completion, then exits
|
||||
# docker compose up damascus-api # mounts the volume and serves
|
||||
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# ---- Stage 1: build ------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-alpine AS build
|
||||
|
||||
# pnpm or yarn aren't used here — package-lock.json drives npm ci.
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first so dependency layer caches when only src changes.
|
||||
COPY package.json package-lock.json* ./
|
||||
# `npm ci` requires a lockfile; fall back to `npm install` in dev where
|
||||
# the lockfile may not yet be committed.
|
||||
RUN if [ -f package-lock.json ]; then \
|
||||
npm ci --no-audit --no-fund; \
|
||||
else \
|
||||
npm install --no-audit --no-fund; \
|
||||
fi
|
||||
|
||||
# Now copy the source.
|
||||
COPY . .
|
||||
|
||||
# Default API target for dev — overridden in production by the
|
||||
# same-origin FastAPI mount. The vite preview server uses this only
|
||||
# when run standalone; the actual production bundle is served by the
|
||||
# API container as same-origin so VITE_API_BASE_URL is empty in
|
||||
# production builds (left to compose / CI to set).
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: output -----------------------------------------------------
|
||||
# The build stage's `dist/` is the only thing the API container needs.
|
||||
# A scratch-equivalent would be the leanest option, but an alpine stage
|
||||
# makes it easier for compose bind-mounts and ad-hoc debugging.
|
||||
#
|
||||
# The bundle is written to /bundle (not /opt/damascus/ui) on purpose:
|
||||
# the compose `damascus-ui-build` service mounts the named volume
|
||||
# `damascus_ui` AT /bundle, which lets the bundle flow into the volume
|
||||
# without a copy step. The P2 `damascus-api` service then mounts the
|
||||
# same volume at /opt/damascus/ui:ro where FastAPI's StaticFiles can
|
||||
# serve it.
|
||||
FROM alpine:3.20 AS output
|
||||
|
||||
RUN mkdir -p /bundle
|
||||
COPY --from=build /app/dist/ /bundle/
|
||||
|
||||
# Sanity: the bundle must contain an index.html
|
||||
RUN test -f /bundle/index.html
|
||||
|
||||
# The bundle is static — no ENTRYPOINT, no EXPOSE. The named volume
|
||||
# `damascus_ui` is what carries the files into the API container.
|
||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Damascus</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5138
ui/package-lock.json
generated
Normal file
5138
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
ui/package.json
Normal file
35
ui/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "damascus-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Damascus orchestrator v1 read-only dashboard (P4)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.0",
|
||||
"@mui/material": "^6.1.0",
|
||||
"@mui/x-data-grid": "^7.22.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
66
ui/playwright.config.ts
Normal file
66
ui/playwright.config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Playwright config for the damascus-ui v1 e2e suite.
|
||||
//
|
||||
// The suite targets a live damascus-api (or the preview server backed
|
||||
// by a fixture API on :9110) and exercises the three contract
|
||||
// acceptance criteria from the P4 task body:
|
||||
// 1. /items table renders >= 1 row
|
||||
// 2. Row click opens the drawer with item + open_issues + recent_events
|
||||
// 3. Phase filter actually narrows the result set
|
||||
//
|
||||
// Two webservers are started:
|
||||
// - vite preview on :4173 (the built bundle, served static)
|
||||
// - fixture FastAPI stub on :9110 (./tests/e2e/fixture_api.py) that
|
||||
// returns a deterministic dataset
|
||||
//
|
||||
// The bundle is built with VITE_API_BASE_URL=http://127.0.0.1:9110 so
|
||||
// the React app calls the fixture directly. In production they're
|
||||
// same-origin (FastAPI serves the bundle); in dev the Vite proxy
|
||||
// makes them same-origin; in this test we cross-origin which works
|
||||
// because the fixture API has CORS allow_origins=["*"].
|
||||
|
||||
const UI_PORT = Number(process.env.UI_PORT ?? 4173);
|
||||
const API_PORT = Number(process.env.FIXTURE_API_PORT ?? 9110);
|
||||
const BASE_URL = process.env.UI_BASE_URL ?? `http://127.0.0.1:${UI_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
headless: true,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: process.env.UI_NO_WEBSERVER
|
||||
? undefined
|
||||
: [
|
||||
// Fixture API: deterministic dataset the e2e test runs against
|
||||
// when there's no real damascus-api yet (e.g. P4 lands before P2).
|
||||
{
|
||||
command: `python3 tests/e2e/fixture_api.py`,
|
||||
port: API_PORT,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
env: { PORT: String(API_PORT) },
|
||||
},
|
||||
// Vite preview: serves the built bundle from dist/ on :4173.
|
||||
{
|
||||
command: `npm run preview -- --port ${UI_PORT} --host 127.0.0.1`,
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
4
ui/public/favicon.svg
Normal file
4
ui/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1e1e2e"/>
|
||||
<path d="M8 22 L16 8 L24 22 M11 17 L21 17" stroke="#cdd6f4" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 265 B |
49
ui/src/App.tsx
Normal file
49
ui/src/App.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Box, AppBar, Toolbar, Typography, Button, Stack } from "@mui/material";
|
||||
import { useRoute, navigate } from "./router";
|
||||
import { Dashboard } from "./routes/Dashboard";
|
||||
import { Items } from "./routes/Items";
|
||||
|
||||
export default function App() {
|
||||
const route = useRoute();
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
<AppBar position="static" color="default" elevation={0}>
|
||||
<Toolbar>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
sx={{ flexGrow: 0, mr: 4, fontWeight: 600 }}
|
||||
>
|
||||
Damascus
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexGrow: 1 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
data-testid="nav-dashboard"
|
||||
onClick={() => navigate("/")}
|
||||
variant={route.name === "dashboard" ? "outlined" : "text"}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
data-testid="nav-items"
|
||||
onClick={() => navigate("/items")}
|
||||
variant={route.name === "items" ? "outlined" : "text"}
|
||||
>
|
||||
Items
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ opacity: 0.6 }}>
|
||||
v1 read-only
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
{route.name === "dashboard" ? <Dashboard /> : <Items />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
98
ui/src/api/client.ts
Normal file
98
ui/src/api/client.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Thin fetch wrapper for the Damascus HTTP API (P1 contract).
|
||||
//
|
||||
// No retries, no caching, no request-side mutation. Each call returns
|
||||
// parsed JSON or throws an ApiError. React Query layers caching +
|
||||
// refetch policy on top of this in ./queries.ts.
|
||||
//
|
||||
// All paths are relative — same-origin in both dev (Vite proxy) and
|
||||
// production (FastAPI StaticFiles mount). Override the base with
|
||||
// VITE_API_BASE_URL only for ad-hoc debugging (e.g. hitting a remote
|
||||
// API directly from a laptop).
|
||||
//
|
||||
// Auth: reads (GET) need no token; writes (POST) need
|
||||
// `Authorization: Bearer <token>`. v1 is read-only, so this client
|
||||
// never sends a token. The IngestPage in P5 will extend the wrapper.
|
||||
|
||||
import type { ErrorResponse } from "../types";
|
||||
|
||||
const BASE_URL =
|
||||
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
|
||||
constructor(status: number, body: unknown, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | string[] | undefined>,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = new URL(path, window.location.origin);
|
||||
// Use base url override only for absolute paths
|
||||
const finalUrl = BASE_URL
|
||||
? new URL(path.replace(/^\//, ""), BASE_URL)
|
||||
: url;
|
||||
|
||||
if (query) {
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined || v === null) continue;
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) {
|
||||
finalUrl.searchParams.append(k, String(item));
|
||||
}
|
||||
} else {
|
||||
finalUrl.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { Accept: "application/json" },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const res = await fetch(finalUrl.toString(), init);
|
||||
|
||||
if (!res.ok) {
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
// body wasn't JSON
|
||||
}
|
||||
const err = parsed as ErrorResponse | null;
|
||||
const message =
|
||||
err?.detail ?? err?.error ?? `HTTP ${res.status} ${res.statusText}`;
|
||||
throw new ApiError(res.status, parsed, message);
|
||||
}
|
||||
|
||||
// 204 No Content (not used in v1 but reserved)
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | string[] | undefined>,
|
||||
) => request<T>("GET", path, query),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>("POST", path, undefined, body),
|
||||
};
|
||||
94
ui/src/api/queries.ts
Normal file
94
ui/src/api/queries.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// React Query hooks for the Damascus HTTP API.
|
||||
//
|
||||
// One hook per endpoint. Each hook owns its query key + staleTime so
|
||||
// React Query handles refetch + dedup + invalidation uniformly. The
|
||||
// 5s staleTime matches the §7 "live, polled every 5s" expectation for
|
||||
// the dashboard widgets; the Items page uses staleTime=0 so manual
|
||||
// refetch on URL change picks up new filters immediately.
|
||||
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
HealthResponse,
|
||||
ItemDetailResponse,
|
||||
ItemsSort,
|
||||
ListEventsResponse,
|
||||
ListItemsQueryParams,
|
||||
ListItemsResponse,
|
||||
StatsResponse,
|
||||
WorkItem,
|
||||
WorkItemPhase,
|
||||
} from "../types";
|
||||
|
||||
const FIVE_SECONDS = 5_000;
|
||||
|
||||
export function useHealth(): UseQueryResult<HealthResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => api.get<HealthResponse>("/healthz"),
|
||||
staleTime: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStats(): UseQueryResult<StatsResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: () => api.get<StatsResponse>("/v1/stats"),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useListItems(
|
||||
params: ListItemsQueryParams,
|
||||
): UseQueryResult<ListItemsResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["items", params],
|
||||
queryFn: () => api.get<ListItemsResponse>("/v1/items", params as unknown as Record<string, string | number | boolean | string[] | undefined>),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useItemDetail(id: string | null): UseQueryResult<ItemDetailResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: () => api.get<ItemDetailResponse>(`/v1/items/${id}`),
|
||||
enabled: id !== null && id.length > 0,
|
||||
staleTime: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentEvents(
|
||||
workItemId: string | null,
|
||||
limit = 20,
|
||||
): UseQueryResult<ListEventsResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["events", workItemId, limit],
|
||||
queryFn: () =>
|
||||
api.get<ListEventsResponse>("/v1/events", {
|
||||
work_item_id: workItemId ?? undefined,
|
||||
limit,
|
||||
}),
|
||||
enabled: workItemId !== null,
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: project list derived from the work items the UI
|
||||
// already has. Lets the filter dropdown populate without a separate
|
||||
// /v1/projects endpoint (which the contract doesn't expose).
|
||||
export function deriveProjects(items: WorkItem[]): string[] {
|
||||
return Array.from(new Set(items.map((i) => i.project))).sort();
|
||||
}
|
||||
|
||||
export function matchesPhaseFilter(
|
||||
item: WorkItem,
|
||||
phases: WorkItemPhase[] | undefined,
|
||||
): boolean {
|
||||
if (!phases || phases.length === 0) return true;
|
||||
return phases.includes(item.phase);
|
||||
}
|
||||
|
||||
export const DEFAULT_SORT: ItemsSort = "priority_asc";
|
||||
export const DEFAULT_LIMIT = 50;
|
||||
105
ui/src/hashState.ts
Normal file
105
ui/src/hashState.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// URL hash sync for the Items page.
|
||||
//
|
||||
// The hash is the only piece of state that survives a page reload and
|
||||
// produces a shareable link. The hash format is `#project=foo&phase=build,review&sort=priority_desc`.
|
||||
//
|
||||
// Encoding: percent-encoding via URLSearchParams. `phase` is comma-
|
||||
// joined for compactness; everything else is the natural string form
|
||||
// of the value.
|
||||
|
||||
import type {
|
||||
ItemsSort,
|
||||
ListItemsQueryParams,
|
||||
WorkItemPhase,
|
||||
} from "./types";
|
||||
import { ALL_PHASES, ALL_SORTS } from "./types";
|
||||
|
||||
export function paramsToHash(params: ListItemsQueryParams): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.project) sp.set("project", params.project);
|
||||
if (params.phase) {
|
||||
const phases = Array.isArray(params.phase) ? params.phase : [params.phase];
|
||||
if (phases.length > 0) sp.set("phase", phases.join(","));
|
||||
}
|
||||
if (params.priority_min !== undefined && params.priority_min > 0) {
|
||||
sp.set("priority_min", String(params.priority_min));
|
||||
}
|
||||
if (params.priority_max !== undefined && params.priority_max < 1000) {
|
||||
sp.set("priority_max", String(params.priority_max));
|
||||
}
|
||||
if (params.sort) sp.set("sort", params.sort);
|
||||
if (params.limit !== undefined && params.limit !== 50) {
|
||||
sp.set("limit", String(params.limit));
|
||||
}
|
||||
if (params.offset !== undefined && params.offset > 0) {
|
||||
sp.set("offset", String(params.offset));
|
||||
}
|
||||
if (params.open_questions_only) sp.set("open_questions_only", "true");
|
||||
const s = sp.toString();
|
||||
return s.length > 0 ? `#${s}` : "";
|
||||
}
|
||||
|
||||
export function hashToParams(hash: string): ListItemsQueryParams {
|
||||
const cleaned = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
if (!cleaned) return {};
|
||||
const sp = new URLSearchParams(cleaned);
|
||||
const out: ListItemsQueryParams = {};
|
||||
|
||||
const project = sp.get("project");
|
||||
if (project) out.project = project;
|
||||
|
||||
const phase = sp.get("phase");
|
||||
if (phase) {
|
||||
const parts = phase
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p): p is WorkItemPhase =>
|
||||
(ALL_PHASES as string[]).includes(p),
|
||||
);
|
||||
if (parts.length === 1) out.phase = parts[0];
|
||||
else if (parts.length > 1) out.phase = parts;
|
||||
}
|
||||
|
||||
const priorityMin = sp.get("priority_min");
|
||||
if (priorityMin !== null) {
|
||||
const n = Number(priorityMin);
|
||||
if (Number.isFinite(n) && n >= 0) out.priority_min = n;
|
||||
}
|
||||
|
||||
const priorityMax = sp.get("priority_max");
|
||||
if (priorityMax !== null) {
|
||||
const n = Number(priorityMax);
|
||||
if (Number.isFinite(n) && n >= 0) out.priority_max = n;
|
||||
}
|
||||
|
||||
const sort = sp.get("sort");
|
||||
if (sort && (ALL_SORTS as string[]).includes(sort)) {
|
||||
out.sort = sort as ItemsSort;
|
||||
}
|
||||
|
||||
const limit = sp.get("limit");
|
||||
if (limit !== null) {
|
||||
const n = Number(limit);
|
||||
if (Number.isFinite(n) && n >= 1 && n <= 500) out.limit = n;
|
||||
}
|
||||
|
||||
const offset = sp.get("offset");
|
||||
if (offset !== null) {
|
||||
const n = Number(offset);
|
||||
if (Number.isFinite(n) && n >= 0) out.offset = n;
|
||||
}
|
||||
|
||||
if (sp.get("open_questions_only") === "true") {
|
||||
out.open_questions_only = true;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeHash(hash: string): void {
|
||||
// history.replaceState avoids polluting the back button with every
|
||||
// filter tweak. The Items page calls this on every change.
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = hash;
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
45
ui/src/main.tsx
Normal file
45
ui/src/main.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: "dark",
|
||||
primary: { main: "#7aa2f7" },
|
||||
secondary: { main: "#bb9af7" },
|
||||
background: { default: "#1a1b26", paper: "#24283b" },
|
||||
},
|
||||
shape: { borderRadius: 6 },
|
||||
typography: {
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif',
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) {
|
||||
throw new Error("#root not found in index.html");
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
101
ui/src/router.ts
Normal file
101
ui/src/router.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Tiny hash-based router for the v1 UI.
|
||||
//
|
||||
// Three routes:
|
||||
// #/ — Dashboard
|
||||
// #/items — Items table
|
||||
// #/items/:id — Items table with the drawer open on `id`
|
||||
//
|
||||
// We use a path-based hash (not hashbang) so the URL looks like
|
||||
// http://damascus.lan/#/items/abc-123-... — friendlier than
|
||||
// #/items?id=abc-123-... and matches what users expect.
|
||||
//
|
||||
// v1 has no nested routes, no auth-gated routes, and no programmatic
|
||||
// navigation beyond the Link helper. P5 (ingest form) will add at most
|
||||
// one more route (#/ingest) and shouldn't need to expand the surface.
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export type Route =
|
||||
| { name: "dashboard" }
|
||||
| { name: "items"; itemId: string | null };
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const cleaned = hash.replace(/^#\/?/, "");
|
||||
if (cleaned === "" || cleaned === "/") {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
if (cleaned === "items" || cleaned === "items/") {
|
||||
return { name: "items", itemId: null };
|
||||
}
|
||||
const itemMatch = cleaned.match(/^items\/([0-9a-fA-F-]{36})\/?$/);
|
||||
if (itemMatch) {
|
||||
return { name: "items", itemId: itemMatch[1] };
|
||||
}
|
||||
// Unknown route falls back to dashboard; v1 is not opinionated about 404s.
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
|
||||
export function useRoute(): Route {
|
||||
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setRoute(parseHash(window.location.hash));
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}, []);
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
export function navigate(path: string): void {
|
||||
const target = path.startsWith("#") ? path : `#${path}`;
|
||||
if (window.location.hash !== target) {
|
||||
window.location.hash = target;
|
||||
}
|
||||
}
|
||||
|
||||
// Imperative helper for the Items page to set the open item id
|
||||
// (preserving the existing query hash from the filter state).
|
||||
export function setOpenItem(itemId: string | null): void {
|
||||
const url = new URL(window.location.href);
|
||||
const cleanedHash = url.hash.replace(/^#\/?/, "");
|
||||
const filterPart = cleanedHash.startsWith("items")
|
||||
? cleanedHash.replace(/^items\/?[^?]*/, "")
|
||||
: "";
|
||||
const filterStr = filterPart.replace(/^&/, "");
|
||||
if (itemId) {
|
||||
url.hash = `#/items/${itemId}${filterStr ? `?${filterStr}` : ""}`;
|
||||
} else {
|
||||
url.hash = `#/items${filterStr ? `?${filterStr}` : ""}`;
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
// hashchange doesn't fire on replaceState, so we trigger the
|
||||
// listener manually by dispatching a synthetic event.
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}
|
||||
|
||||
// read-only convenience: pick the open item id from the current route.
|
||||
export function useOpenItemId(): string | null {
|
||||
const route = useRoute();
|
||||
if (route.name !== "items") return null;
|
||||
return route.itemId;
|
||||
}
|
||||
|
||||
// Used by Items.tsx to call back into the hash when filters change
|
||||
// without disturbing the drawer item id.
|
||||
export function useHashWrite(): (filterHash: string) => void {
|
||||
return useCallback((filterHash: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const cleanedHash = url.hash.replace(/^#\/?/, "");
|
||||
// Pull the item id out of the existing hash, keep the filter
|
||||
// state in the new hash.
|
||||
const itemMatch = cleanedHash.match(/^items\/([0-9a-fA-F-]{36})(\?.*)?$/);
|
||||
const itemSegment = itemMatch ? `items/${itemMatch[1]}` : "items";
|
||||
const filterNoHash = filterHash.startsWith("#") ? filterHash.slice(1) : filterHash;
|
||||
url.hash = filterNoHash
|
||||
? `#/${itemSegment}?${filterNoHash}`
|
||||
: `#/${itemSegment}`;
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}, []);
|
||||
}
|
||||
216
ui/src/routes/Dashboard.tsx
Normal file
216
ui/src/routes/Dashboard.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
// Dashboard route — §7 widgets for the read-only v1.
|
||||
//
|
||||
// Three widgets:
|
||||
// 1. Phase counts as a stacked bar (live, polled every 5s via useStats).
|
||||
// 2. Open human_issues count + last 5 inline (links → /items/:id drawer).
|
||||
// 3. Cost today (USD).
|
||||
//
|
||||
// P5 will add a sparkline for the last 7 days of cost and the
|
||||
// "items in `blocked` phase" cards. v1 is intentionally a thin
|
||||
// slice so the bundle stays small and the contract ships.
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
Alert,
|
||||
Chip,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { useStats } from "../api/queries";
|
||||
import { ALL_PHASES, type WorkItemPhase } from "../types";
|
||||
import { setOpenItem } from "../router";
|
||||
|
||||
const PHASE_COLORS: Record<WorkItemPhase, string> = {
|
||||
spec: "#7aa2f7",
|
||||
build: "#9ece6a",
|
||||
review: "#e0af68",
|
||||
merged: "#73daca",
|
||||
blocked: "#f7768e",
|
||||
awaiting_human: "#bb9af7",
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
const stats = useStats();
|
||||
|
||||
if (stats.isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.error) {
|
||||
return (
|
||||
<Alert severity="error" data-testid="dashboard-error">
|
||||
Failed to load stats: {String(stats.error)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const data = stats.data!;
|
||||
const phaseTotal = ALL_PHASES.reduce(
|
||||
(acc, p) => acc + (data.phase_counts[p] ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={3} data-testid="dashboard-root">
|
||||
<Typography variant="h4" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
|
||||
{/* Phase counts — stacked bar + per-phase chips */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Work items by phase
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{phaseTotal} total
|
||||
</Typography>
|
||||
{phaseTotal === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No work items yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
data-testid="phase-bar"
|
||||
>
|
||||
{ALL_PHASES.map((phase) => {
|
||||
const count = data.phase_counts[phase] ?? 0;
|
||||
if (count === 0) return null;
|
||||
const pct = (count / phaseTotal) * 100;
|
||||
return (
|
||||
<Box
|
||||
key={phase}
|
||||
data-testid={`phase-bar-${phase}`}
|
||||
sx={{
|
||||
width: `${pct}%`,
|
||||
bgcolor: PHASE_COLORS[phase],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}}
|
||||
title={`${phase}: ${count}`}
|
||||
>
|
||||
{pct > 8 ? count : ""}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2, flexWrap: "wrap", gap: 1 }}>
|
||||
{ALL_PHASES.map((phase) => (
|
||||
<Chip
|
||||
key={phase}
|
||||
size="small"
|
||||
label={`${phase}: ${data.phase_counts[phase] ?? 0}`}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[phase],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card data-testid="open-issues-card">
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Open human issues
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
data-testid="open-issues-count"
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
{data.open_human_issues}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Active claims
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ fontWeight: 600 }}>
|
||||
{data.active_claims}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Cost today (USD)
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ fontWeight: 600 }}>
|
||||
${data.cost_today_usd}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Last cycle
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{data.last_cycle_at
|
||||
? new Date(data.last_cycle_at).toLocaleString()
|
||||
: "never"}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Convenience link to items table — operators land here first */}
|
||||
<Box>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenItem(null)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#7aa2f7",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
View all items →
|
||||
</button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
243
ui/src/routes/ItemDrawer.tsx
Normal file
243
ui/src/routes/ItemDrawer.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
// ItemDrawer — right-side drawer that opens when the user clicks a
|
||||
// row in the Items table (URL hash = #/items/:id).
|
||||
//
|
||||
// Shows: the work item's full record, its open human_issues, and the
|
||||
// 20 most recent events_outbox rows for the item.
|
||||
//
|
||||
// v1 is read-only — no answer form, no edit. P5 will add the answer
|
||||
// textarea inside this drawer.
|
||||
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Alert,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useItemDetail, useRecentEvents } from "../api/queries";
|
||||
import { useOpenItemId, setOpenItem } from "../router";
|
||||
import type { WorkItemPhase } from "../types";
|
||||
|
||||
const DRAWER_WIDTH = 480;
|
||||
|
||||
const PHASE_COLORS: Record<WorkItemPhase, string> = {
|
||||
spec: "#7aa2f7",
|
||||
build: "#9ece6a",
|
||||
review: "#e0af68",
|
||||
merged: "#73daca",
|
||||
blocked: "#f7768e",
|
||||
awaiting_human: "#bb9af7",
|
||||
};
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ py: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ width: 120, color: "text.secondary" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ flex: 1, wordBreak: "break-word" }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemDrawer() {
|
||||
const openItemId = useOpenItemId();
|
||||
const open = openItemId !== null;
|
||||
|
||||
const detail = useItemDetail(openItemId);
|
||||
const events = useRecentEvents(openItemId, 20);
|
||||
|
||||
const handleClose = () => setOpenItem(null);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ sx: { width: DRAWER_WIDTH, maxWidth: "100%" } }}
|
||||
>
|
||||
<Box
|
||||
data-testid="item-drawer"
|
||||
sx={{ p: 3, display: "flex", flexDirection: "column", height: "100%" }}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6">Item</Typography>
|
||||
<IconButton onClick={handleClose} data-testid="drawer-close" size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{detail.isLoading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{detail.error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{String(detail.error)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.data && (
|
||||
<Box sx={{ mt: 2, overflowY: "auto", flex: 1 }} data-testid="item-detail">
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={detail.data.item.phase}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[detail.data.item.phase],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
data-testid="drawer-phase"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{detail.data.item.project} / {detail.data.item.story_id}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{detail.data.item.title}
|
||||
</Typography>
|
||||
|
||||
<Row label="ID" value={<code>{detail.data.item.id}</code>} />
|
||||
<Row label="Priority" value={detail.data.item.priority} />
|
||||
<Row label="Attempts" value={`${detail.data.item.attempts} / ${detail.data.item.budget_cycles}`} />
|
||||
<Row
|
||||
label="Last verdict"
|
||||
value={detail.data.item.last_verdict ?? "—"}
|
||||
/>
|
||||
<Row
|
||||
label="Branch"
|
||||
value={detail.data.item.branch ?? "—"}
|
||||
/>
|
||||
<Row
|
||||
label="PR"
|
||||
value={
|
||||
detail.data.item.pr_url ? (
|
||||
<a href={detail.data.item.pr_url} target="_blank" rel="noreferrer">
|
||||
{detail.data.item.pr_url}
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Spec path"
|
||||
value={detail.data.item.spec_path ?? "—"}
|
||||
/>
|
||||
<Row
|
||||
label="File scope"
|
||||
value={
|
||||
detail.data.item.file_scope.length > 0
|
||||
? detail.data.item.file_scope.join(", ")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Created"
|
||||
value={new Date(detail.data.item.created_at).toLocaleString()}
|
||||
/>
|
||||
<Row
|
||||
label="Updated"
|
||||
value={new Date(detail.data.item.updated_at).toLocaleString()}
|
||||
/>
|
||||
{detail.data.item.last_feedback != null && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Last feedback
|
||||
</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
mt: 0.5,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{typeof detail.data.item.last_feedback === "string"
|
||||
? detail.data.item.last_feedback
|
||||
: JSON.stringify(detail.data.item.last_feedback, null, 2)}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
data-testid="open-issues-header"
|
||||
>
|
||||
Open issues ({detail.data.open_issues.length})
|
||||
</Typography>
|
||||
{detail.data.open_issues.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
None.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1} sx={{ mt: 1 }} data-testid="open-issues-list">
|
||||
{detail.data.open_issues.map((issue) => (
|
||||
<Paper key={issue.id} variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="body2">{issue.question}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5, display: "block" }}
|
||||
>
|
||||
{new Date(issue.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Recent events
|
||||
</Typography>
|
||||
{events.isLoading && <CircularProgress size={16} sx={{ mt: 1 }} />}
|
||||
{events.data && events.data.events.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
None.
|
||||
</Typography>
|
||||
)}
|
||||
{events.data && events.data.events.length > 0 && (
|
||||
<Stack
|
||||
spacing={0.5}
|
||||
sx={{ mt: 1, fontFamily: "monospace", fontSize: 12 }}
|
||||
data-testid="recent-events-list"
|
||||
>
|
||||
{events.data.events.map((e) => (
|
||||
<Box key={e.id}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="span"
|
||||
>
|
||||
{new Date(e.created_at).toLocaleTimeString()}{" "}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="span">
|
||||
{e.kind}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
315
ui/src/routes/Items.tsx
Normal file
315
ui/src/routes/Items.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
// Items page — read-only table wired to /v1/items with all
|
||||
// ListItemsQuery parameters exposed as filter controls.
|
||||
//
|
||||
// Filter state lives in the URL hash (see ../hashState). Refetch is
|
||||
// driven by the useListItems hook which depends on the params object;
|
||||
// changes propagate via React Query.
|
||||
//
|
||||
// Clicking a row opens ItemDrawer at /items/:id (the row click handler
|
||||
// in App/routes reads the URL).
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
type SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, type GridColDef, type GridRowParams } from "@mui/x-data-grid";
|
||||
|
||||
import { useListItems } from "../api/queries";
|
||||
import { ALL_PHASES, ALL_SORTS, type ItemsSort, type WorkItemPhase } from "../types";
|
||||
import { hashToParams, paramsToHash, writeHash } from "../hashState";
|
||||
import { setOpenItem } from "../router";
|
||||
import { ItemDrawer } from "./ItemDrawer";
|
||||
|
||||
const PHASE_COLORS: Record<WorkItemPhase, string> = {
|
||||
spec: "#7aa2f7",
|
||||
build: "#9ece6a",
|
||||
review: "#e0af68",
|
||||
merged: "#73daca",
|
||||
blocked: "#f7768e",
|
||||
awaiting_human: "#bb9af7",
|
||||
};
|
||||
|
||||
export function Items() {
|
||||
const [params, setParams] = useState(() => hashToParams(window.location.hash));
|
||||
|
||||
// Sync filter changes to the URL hash on every render. Done in an
|
||||
// effect (not in setParams) so the hash write is idempotent and
|
||||
// external navigations (e.g. back button) can still update the
|
||||
// component state via the hashchange listener below.
|
||||
useEffect(() => {
|
||||
writeHash(paramsToHash(params));
|
||||
}, [params]);
|
||||
|
||||
// Listen for external hash changes (back/forward nav, drawer set
|
||||
// open item id without filter changes) and re-derive the params.
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
const fresh = hashToParams(window.location.hash);
|
||||
setParams(fresh);
|
||||
};
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}, []);
|
||||
|
||||
const query = useListItems(params);
|
||||
|
||||
// MUI DataGrid wants flat rows
|
||||
const rows = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
const total = query.data?.total ?? 0;
|
||||
|
||||
const columns: GridColDef[] = useMemo(
|
||||
() => [
|
||||
{ field: "id", headerName: "ID", width: 110, hide: true },
|
||||
{ field: "project", headerName: "Project", width: 110 },
|
||||
{ field: "story_id", headerName: "Story", width: 130 },
|
||||
{ field: "title", headerName: "Title", flex: 1, minWidth: 200 },
|
||||
{
|
||||
field: "phase",
|
||||
headerName: "Phase",
|
||||
width: 130,
|
||||
renderCell: (p) => (
|
||||
<Chip
|
||||
size="small"
|
||||
label={p.value as string}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[p.value as WorkItemPhase],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ field: "priority", headerName: "Priority", width: 90, type: "number" },
|
||||
{ field: "attempts", headerName: "Attempts", width: 90, type: "number" },
|
||||
{
|
||||
field: "last_verdict",
|
||||
headerName: "Verdict",
|
||||
width: 130,
|
||||
valueFormatter: (v: string | null) => v ?? "—",
|
||||
},
|
||||
{
|
||||
field: "updated_at",
|
||||
headerName: "Updated",
|
||||
width: 170,
|
||||
valueFormatter: (v: string) =>
|
||||
v ? new Date(v).toLocaleString() : "—",
|
||||
},
|
||||
{
|
||||
field: "pr_url",
|
||||
headerName: "PR",
|
||||
width: 100,
|
||||
renderCell: (p) =>
|
||||
p.value ? (
|
||||
<a
|
||||
href={p.value as string}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: "#7aa2f7" }}
|
||||
>
|
||||
open
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const onRowClick = (p: GridRowParams) => {
|
||||
setOpenItem(p.row.id as string);
|
||||
};
|
||||
|
||||
const phases = (() => {
|
||||
if (!params.phase) return [] as WorkItemPhase[];
|
||||
if (Array.isArray(params.phase)) return params.phase;
|
||||
return [params.phase];
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box data-testid="items-root">
|
||||
<Stack direction="row" alignItems="center" sx={{ mb: 2 }} spacing={2}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Items
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{total} matching
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Filter controls */}
|
||||
<Paper sx={{ p: 2, mb: 2 }} data-testid="items-filters">
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Project"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={params.project ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams((p) => ({ ...p, project: e.target.value || undefined }))
|
||||
}
|
||||
inputProps={{ "data-testid": "filter-project" }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="phase-label">Phase</InputLabel>
|
||||
<Select
|
||||
labelId="phase-label"
|
||||
label="Phase"
|
||||
multiple
|
||||
value={phases}
|
||||
onChange={(e: SelectChangeEvent<WorkItemPhase[]>) => {
|
||||
const v = e.target.value as unknown;
|
||||
const next: WorkItemPhase[] = typeof v === "string"
|
||||
? [v as WorkItemPhase]
|
||||
: (v as WorkItemPhase[]);
|
||||
setParams((p) => ({
|
||||
...p,
|
||||
phase: next.length === 0 ? undefined : next,
|
||||
}));
|
||||
}}
|
||||
data-testid="filter-phase"
|
||||
renderValue={(selected) =>
|
||||
(selected as WorkItemPhase[]).join(", ") || "all"
|
||||
}
|
||||
>
|
||||
{ALL_PHASES.map((phase) => (
|
||||
<MenuItem key={phase} value={phase}>
|
||||
{phase}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={2}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="sort-label">Sort</InputLabel>
|
||||
<Select
|
||||
labelId="sort-label"
|
||||
label="Sort"
|
||||
value={params.sort ?? "priority_asc"}
|
||||
onChange={(e) =>
|
||||
setParams((p) => ({
|
||||
...p,
|
||||
sort: e.target.value as ItemsSort,
|
||||
}))
|
||||
}
|
||||
inputProps={{ "data-testid": "filter-sort" }}
|
||||
>
|
||||
{ALL_SORTS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={2}>
|
||||
<TextField
|
||||
label="Limit"
|
||||
size="small"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={params.limit ?? 50}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setParams((p) => ({
|
||||
...p,
|
||||
limit: Number.isFinite(n) ? n : undefined,
|
||||
}));
|
||||
}}
|
||||
inputProps={{ min: 1, max: 500, "data-testid": "filter-limit" }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
label="Priority min"
|
||||
size="small"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={params.priority_min ?? 0}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setParams((p) => ({
|
||||
...p,
|
||||
priority_min: Number.isFinite(n) ? n : undefined,
|
||||
}));
|
||||
}}
|
||||
inputProps={{ min: 0, max: 1000, "data-testid": "filter-priority-min" }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
label="Priority max"
|
||||
size="small"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={params.priority_max ?? 1000}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setParams((p) => ({
|
||||
...p,
|
||||
priority_max: Number.isFinite(n) ? n : undefined,
|
||||
}));
|
||||
}}
|
||||
inputProps={{ min: 0, max: 1000, "data-testid": "filter-priority-max" }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{query.isLoading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{query.error && (
|
||||
<Alert severity="error" data-testid="items-error">
|
||||
Failed to load items: {String(query.error)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{query.data && (
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
data-testid="items-grid"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={query.isFetching}
|
||||
disableRowSelectionOnClick
|
||||
onRowClick={onRowClick}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: { pageSize: 50, page: 0 },
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
sx={{
|
||||
border: 0,
|
||||
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<ItemDrawer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
144
ui/src/types.ts
Normal file
144
ui/src/types.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// TypeScript mirrors of src/damascus/api_schemas.py (P1 contract).
|
||||
//
|
||||
// The Python file is the source of truth; this file is a structural
|
||||
// re-declaration so the UI gets full TS type safety. Drift between the
|
||||
// two is a contract violation — the build-time contract test in P6
|
||||
// will round-trip the JSON schemas and catch mismatches.
|
||||
//
|
||||
// Field names follow the snake_case JSON the FastAPI service emits
|
||||
// (FastAPI does NOT auto-camelCase Pydantic v2 model_dump output).
|
||||
|
||||
export type WorkItemPhase =
|
||||
| "spec"
|
||||
| "build"
|
||||
| "review"
|
||||
| "merged"
|
||||
| "blocked"
|
||||
| "awaiting_human";
|
||||
|
||||
export const ALL_PHASES: WorkItemPhase[] = [
|
||||
"spec",
|
||||
"build",
|
||||
"review",
|
||||
"merged",
|
||||
"blocked",
|
||||
"awaiting_human",
|
||||
];
|
||||
|
||||
export type VerdictKind =
|
||||
| "pass"
|
||||
| "tests_failed"
|
||||
| "rebase_conflict"
|
||||
| "spec_ambiguous"
|
||||
| "spec_wrong"
|
||||
| "no_pr";
|
||||
|
||||
export type IssueStatus = "open" | "answered" | "resolved";
|
||||
|
||||
export type ItemsSort =
|
||||
| "priority_asc"
|
||||
| "priority_desc"
|
||||
| "updated_desc"
|
||||
| "attempts_desc";
|
||||
|
||||
export const ALL_SORTS: ItemsSort[] = [
|
||||
"priority_asc",
|
||||
"priority_desc",
|
||||
"updated_desc",
|
||||
"attempts_desc",
|
||||
];
|
||||
|
||||
export interface WorkItem {
|
||||
id: string;
|
||||
project: string;
|
||||
story_id: string;
|
||||
title: string;
|
||||
phase: WorkItemPhase;
|
||||
file_scope: string[];
|
||||
attempts: number;
|
||||
budget_cycles: number;
|
||||
priority: number;
|
||||
base_commit: string | null;
|
||||
branch: string | null;
|
||||
pr_url: string | null;
|
||||
last_verdict: VerdictKind | null;
|
||||
last_feedback: unknown | null;
|
||||
spec_path: string | null;
|
||||
wiki_pin: string | null;
|
||||
claimed_by: string | null;
|
||||
claimed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
merged_at: string | null;
|
||||
}
|
||||
|
||||
export interface ListItemsResponse {
|
||||
items: WorkItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface HumanIssue {
|
||||
id: string;
|
||||
work_item_id: string;
|
||||
question: string;
|
||||
answer: string | null;
|
||||
status: IssueStatus;
|
||||
created_at: string;
|
||||
answered_at: string | null;
|
||||
}
|
||||
|
||||
export interface ListIssuesResponse {
|
||||
issues: HumanIssue[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface EventRow {
|
||||
id: number;
|
||||
work_item_id: string | null;
|
||||
kind: string;
|
||||
payload: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ListEventsResponse {
|
||||
events: EventRow[];
|
||||
next_since_id: number | null;
|
||||
}
|
||||
|
||||
export interface ItemDetailResponse {
|
||||
item: WorkItem;
|
||||
open_issues: HumanIssue[];
|
||||
recent_events: EventRow[];
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
phase_counts: Record<WorkItemPhase, number>;
|
||||
open_human_issues: number;
|
||||
active_claims: number;
|
||||
last_cycle_at: string | null;
|
||||
cost_today_usd: string; // serialized Decimal
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ListItemsQueryParams {
|
||||
project?: string;
|
||||
phase?: WorkItemPhase | WorkItemPhase[]; // multi-select; serialized as repeated `phase` param
|
||||
priority_min?: number;
|
||||
priority_max?: number;
|
||||
sort?: ItemsSort;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
open_questions_only?: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
detail?: string | null;
|
||||
}
|
||||
277
ui/tests/e2e/fixture_api.py
Normal file
277
ui/tests/e2e/fixture_api.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Minimal FastAPI fixture for the damascus-ui v1 e2e suite.
|
||||
|
||||
Mirrors the P1 contract endpoint shapes (a strict subset is enough for
|
||||
the v1 UI smoke test). Lives outside the main compose stack so the
|
||||
e2e test can run without depending on P2's damascus-api merge.
|
||||
|
||||
Run:
|
||||
pip install fastapi uvicorn
|
||||
uvicorn tests.e2e.fixture_api:app --port 9110 --host 127.0.0.1
|
||||
|
||||
The fixture returns a deterministic dataset:
|
||||
- 3 work items across 3 phases (spec, build, merged)
|
||||
- 1 open human_issue on the build item
|
||||
- 5 events_outbox rows for that build item
|
||||
|
||||
Contract reference: src/damascus/api_schemas.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Permissive CORS for the e2e suite only (the real compose stack is
|
||||
# same-origin; this is just so the test browser can hit a separate
|
||||
# origin if needed).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# Deterministic ids so the test can assert against known values.
|
||||
SPEC_ITEM_ID = "11111111-1111-4111-8111-111111111111"
|
||||
BUILD_ITEM_ID = "22222222-2222-4222-8222-222222222222"
|
||||
MERGED_ITEM_ID = "33333333-3333-4333-8333-333333333333"
|
||||
ISSUE_ID = "44444444-4444-4444-8444-444444444444"
|
||||
|
||||
|
||||
ITEMS: dict[str, dict[str, Any]] = {
|
||||
SPEC_ITEM_ID: {
|
||||
"id": SPEC_ITEM_ID,
|
||||
"project": "wh40k-pc",
|
||||
"story_id": "spec-story-01",
|
||||
"title": "Spec the catalog page filter",
|
||||
"phase": "spec",
|
||||
"file_scope": ["src/catalog.tsx"],
|
||||
"attempts": 0,
|
||||
"budget_cycles": 3,
|
||||
"priority": 200,
|
||||
"base_commit": "abc1234",
|
||||
"branch": None,
|
||||
"pr_url": None,
|
||||
"last_verdict": None,
|
||||
"last_feedback": None,
|
||||
"spec_path": None,
|
||||
"wiki_pin": None,
|
||||
"claimed_by": None,
|
||||
"claimed_at": None,
|
||||
"created_at": "2026-06-24T10:00:00+00:00",
|
||||
"updated_at": now_iso(),
|
||||
"merged_at": None,
|
||||
},
|
||||
BUILD_ITEM_ID: {
|
||||
"id": BUILD_ITEM_ID,
|
||||
"project": "wh40k-pc",
|
||||
"story_id": "build-story-01",
|
||||
"title": "Build the filter UI",
|
||||
"phase": "build",
|
||||
"file_scope": ["src/Filter.tsx", "src/Filter.test.tsx"],
|
||||
"attempts": 1,
|
||||
"budget_cycles": 3,
|
||||
"priority": 300,
|
||||
"base_commit": "abc1234",
|
||||
"branch": "feat/filter-ui",
|
||||
"pr_url": None,
|
||||
"last_verdict": None,
|
||||
"last_feedback": None,
|
||||
"spec_path": "/data/specs/wh40k-pc/build-story-01.md",
|
||||
"wiki_pin": None,
|
||||
"claimed_by": "orch-1",
|
||||
"claimed_at": "2026-06-24T11:00:00+00:00",
|
||||
"created_at": "2026-06-24T10:05:00+00:00",
|
||||
"updated_at": now_iso(),
|
||||
"merged_at": None,
|
||||
},
|
||||
MERGED_ITEM_ID: {
|
||||
"id": MERGED_ITEM_ID,
|
||||
"project": "iso-tank-arena",
|
||||
"story_id": "merged-story-01",
|
||||
"title": "Add scoreboard",
|
||||
"phase": "merged",
|
||||
"file_scope": ["src/Scoreboard.tsx"],
|
||||
"attempts": 1,
|
||||
"budget_cycles": 3,
|
||||
"priority": 100,
|
||||
"base_commit": "def5678",
|
||||
"branch": "feat/scoreboard",
|
||||
"pr_url": "https://git.homelab.local/kaykayyali/iso-tank-arena/pulls/42",
|
||||
"last_verdict": "pass",
|
||||
"last_feedback": {"summary": "merged"},
|
||||
"spec_path": None,
|
||||
"wiki_pin": None,
|
||||
"claimed_by": "orch-2",
|
||||
"claimed_at": "2026-06-23T10:00:00+00:00",
|
||||
"created_at": "2026-06-23T09:00:00+00:00",
|
||||
"updated_at": "2026-06-23T11:00:00+00:00",
|
||||
"merged_at": "2026-06-23T11:00:00+00:00",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
ISSUES: dict[str, dict[str, Any]] = {
|
||||
ISSUE_ID: {
|
||||
"id": ISSUE_ID,
|
||||
"work_item_id": BUILD_ITEM_ID,
|
||||
"question": "Should the filter default to all phases or only active ones?",
|
||||
"answer": None,
|
||||
"status": "open",
|
||||
"created_at": "2026-06-24T11:30:00+00:00",
|
||||
"answered_at": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
EVENTS: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": 1,
|
||||
"work_item_id": BUILD_ITEM_ID,
|
||||
"kind": "item_claimed",
|
||||
"payload": {"claimant": "orch-1"},
|
||||
"created_at": "2026-06-24T11:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"work_item_id": BUILD_ITEM_ID,
|
||||
"kind": "spec_refined",
|
||||
"payload": {"spec_path": "/data/specs/wh40k-pc/build-story-01.md"},
|
||||
"created_at": "2026-06-24T11:05:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"work_item_id": BUILD_ITEM_ID,
|
||||
"kind": "worktree_created",
|
||||
"payload": {"branch": "feat/filter-ui"},
|
||||
"created_at": "2026-06-24T11:10:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"work_item_id": BUILD_ITEM_ID,
|
||||
"kind": "tests_started",
|
||||
"payload": {"runner": "pytest"},
|
||||
"created_at": "2026-06-24T11:20:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"work_item_id": BUILD_ITEM_ID,
|
||||
"kind": "issue_opened",
|
||||
"payload": {"issue_id": ISSUE_ID},
|
||||
"created_at": "2026-06-24T11:30:00+00:00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/items")
|
||||
def list_items(
|
||||
project: Optional[str] = None,
|
||||
phase: Optional[list[str]] = Query(default=None),
|
||||
priority_min: int = 0,
|
||||
priority_max: int = 1000,
|
||||
sort: str = "priority_asc",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
open_questions_only: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
items = list(ITEMS.values())
|
||||
if project:
|
||||
items = [i for i in items if i["project"] == project]
|
||||
if phase:
|
||||
items = [i for i in items if i["phase"] in phase]
|
||||
items = [i for i in items if priority_min <= i["priority"] <= priority_max]
|
||||
|
||||
if sort == "priority_asc":
|
||||
items.sort(key=lambda i: i["priority"])
|
||||
elif sort == "priority_desc":
|
||||
items.sort(key=lambda i: -i["priority"])
|
||||
elif sort == "updated_desc":
|
||||
items.sort(key=lambda i: i["updated_at"], reverse=True)
|
||||
elif sort == "attempts_desc":
|
||||
items.sort(key=lambda i: -i["attempts"])
|
||||
|
||||
if open_questions_only:
|
||||
open_item_ids = {
|
||||
i["work_item_id"] for i in ISSUES.values() if i["status"] == "open"
|
||||
}
|
||||
items = [i for i in items if i["id"] in open_item_ids]
|
||||
|
||||
total = len(items)
|
||||
items = items[offset : offset + limit]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/items/{item_id}")
|
||||
def get_item(item_id: str) -> dict[str, Any]:
|
||||
if item_id not in ITEMS:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
item = ITEMS[item_id]
|
||||
open_issues = [i for i in ISSUES.values() if i["work_item_id"] == item_id and i["status"] == "open"]
|
||||
recent_events = [e for e in EVENTS if e["work_item_id"] == item_id][-20:]
|
||||
return {
|
||||
"item": item,
|
||||
"open_issues": open_issues,
|
||||
"recent_events": recent_events,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/events")
|
||||
def list_events(
|
||||
work_item_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
since_id: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
events = list(EVENTS)
|
||||
if work_item_id:
|
||||
events = [e for e in events if e["work_item_id"] == work_item_id]
|
||||
if since_id is not None:
|
||||
events = [e for e in events if e["id"] > since_id]
|
||||
events = events[-limit:]
|
||||
next_since_id = events[-1]["id"] if events else since_id
|
||||
return {"events": events, "next_since_id": next_since_id}
|
||||
|
||||
|
||||
@app.get("/v1/stats")
|
||||
def stats() -> dict[str, Any]:
|
||||
phase_counts: dict[str, int] = {}
|
||||
for it in ITEMS.values():
|
||||
phase_counts[it["phase"]] = phase_counts.get(it["phase"], 0) + 1
|
||||
# Pad missing phases with 0
|
||||
for p in ["spec", "build", "review", "merged", "blocked", "awaiting_human"]:
|
||||
phase_counts.setdefault(p, 0)
|
||||
return {
|
||||
"phase_counts": phase_counts,
|
||||
"open_human_issues": sum(1 for i in ISSUES.values() if i["status"] == "open"),
|
||||
"active_claims": sum(1 for i in ITEMS.values() if i["claimed_by"]),
|
||||
"last_cycle_at": max((i["updated_at"] for i in ITEMS.values()), default=None),
|
||||
"cost_today_usd": "0.123456",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", 9110)))
|
||||
2
ui/tests/e2e/requirements.txt
Normal file
2
ui/tests/e2e/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.110
|
||||
uvicorn>=0.27
|
||||
99
ui/tests/e2e/test_ui_v1.spec.ts
Normal file
99
ui/tests/e2e/test_ui_v1.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Playwright smoke test for the damascus-ui v1 dashboard + items.
|
||||
//
|
||||
// Asserts the three contract acceptance criteria from the P4 task body:
|
||||
// 1. /items table renders with >= 1 row
|
||||
// 2. Click a row → drawer opens with item + open_issues + recent_events
|
||||
// 3. Phase filter actually narrows the result set
|
||||
//
|
||||
// Plus a dashboard test for §7 widgets (phase bar, open issues count).
|
||||
//
|
||||
// Assumes the vite preview is running on :4173 (per playwright.config.ts)
|
||||
// and the fixture API is on :9110 with VITE_API_BASE_URL pointing at it
|
||||
// during build. In production these would be same-origin (FastAPI serves
|
||||
// the bundle); in dev the Vite proxy makes them same-origin; in this
|
||||
// test we cross-origin (preview :4173, api :9110) which works because
|
||||
// the fixture API has CORS allow_origins=["*"].
|
||||
//
|
||||
// Run:
|
||||
// # In one terminal:
|
||||
// pip install fastapi uvicorn
|
||||
// cd ui && uvicorn tests.e2e.fixture_api:app --port 9110
|
||||
// # In another:
|
||||
// cd ui && VITE_API_BASE_URL=http://127.0.0.1:9110 npm run build
|
||||
// cd ui && npm run test:e2e
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
// Force a clean hash state per test so URL-sync from a prior test
|
||||
// doesn't leak filters in.
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
test("dashboard renders phase counts and open issues count", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("dashboard-root")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-bar")).toBeVisible();
|
||||
await expect(page.getByTestId("open-issues-count")).toHaveText("1");
|
||||
});
|
||||
|
||||
test("items page table renders with >= 1 row", async ({ page }) => {
|
||||
await page.goto("/#/items");
|
||||
await expect(page.getByTestId("items-root")).toBeVisible();
|
||||
const rows = page.locator('[data-testid="items-grid"] .MuiDataGrid-row');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
const count = await rows.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking a row opens the drawer with item + open_issues + recent_events", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/#/items");
|
||||
const rows = page.locator('[data-testid="items-grid"] .MuiDataGrid-row');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
// First click opens the drawer for the first item (priority_asc means
|
||||
// lowest priority = iso-tank-arena/merged, which has 0 open issues).
|
||||
await rows.first().click();
|
||||
await expect(page.getByTestId("item-drawer")).toBeVisible();
|
||||
await expect(page.getByTestId("drawer-phase")).toBeVisible();
|
||||
await expect(page.getByTestId("open-issues-header")).toContainText("Open issues");
|
||||
|
||||
// Close the drawer so the row underneath is clickable again, then
|
||||
// click the build item so we can assert open_issues + recent_events
|
||||
// are populated.
|
||||
await page.getByTestId("drawer-close").click();
|
||||
await expect(page.getByTestId("item-drawer")).not.toBeVisible();
|
||||
const buildRow = page
|
||||
.locator('[data-testid="items-grid"] .MuiDataGrid-row')
|
||||
.filter({ hasText: "build-story-01" });
|
||||
await buildRow.click();
|
||||
await expect(page.getByTestId("open-issues-list")).toBeVisible();
|
||||
await expect(page.getByTestId("recent-events-list")).toBeVisible();
|
||||
});
|
||||
|
||||
test("phase filter narrows the result set", async ({ page }) => {
|
||||
await page.goto("/#/items");
|
||||
const grid = page.locator('[data-testid="items-grid"]');
|
||||
const rows = grid.locator(".MuiDataGrid-row");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
const before = await rows.count();
|
||||
expect(before).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Open the phase multi-select and pick "merged" only. The visible
|
||||
// combobox is the role=combobox element, not the hidden native input.
|
||||
await page.getByTestId("filter-phase").click();
|
||||
await page.getByRole("option", { name: "merged" }).click();
|
||||
// MUI multi-select stays open after a click; close it by pressing
|
||||
// Escape and clicking the page body.
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.click(10, 10);
|
||||
|
||||
// The matching count label should drop to 1, and the grid should
|
||||
// re-render with a single row.
|
||||
await expect(page.locator('[data-testid="items-root"]')).toContainText("1 matching");
|
||||
const after = await rows.count();
|
||||
expect(after).toBeLessThan(before);
|
||||
expect(after).toBe(1);
|
||||
});
|
||||
25
ui/tsconfig.json
Normal file
25
ui/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "tests"]
|
||||
}
|
||||
39
ui/vite.config.ts
Normal file
39
ui/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Vite dev server config for damascus-ui v1 (P4).
|
||||
//
|
||||
// In dev the Vite server runs at :5173 and proxies /v1/* requests to
|
||||
// damascus-api:9110 (the FastAPI service added in P2). Same-origin in
|
||||
// dev, same-origin in production (where FastAPI serves the bundle from
|
||||
// /opt/damascus/ui), so the browser never crosses a CORS boundary.
|
||||
//
|
||||
// The proxy target uses the docker-compose service name "damascus-api"
|
||||
// which resolves via the compose network. When the Vite dev server runs
|
||||
// from the host (not inside compose) you can override via the
|
||||
// VITE_API_TARGET env var.
|
||||
//
|
||||
// VITE_API_BASE_URL is the runtime base the React app uses to build
|
||||
// /v1/* URLs. In production, leave it unset (window.location.origin is
|
||||
// used). In dev, the proxy makes the path same-origin anyway.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/v1": {
|
||||
target: process.env.VITE_API_TARGET ?? "http://damascus-api:9110",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173,
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user