feat(ui): damascus-ui v1 read-only dashboard (P4)
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:
Hermes
2026-06-24 13:55:17 +00:00
parent f5b53e3f56
commit 08cd25ac9f
24 changed files with 7264 additions and 1 deletions

View File

@@ -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
View File

@@ -0,0 +1,7 @@
node_modules
dist
test-results
playwright-report
.git
*.log
.DS_Store

17
ui/.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
ui/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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)))

View File

@@ -0,0 +1,2 @@
fastapi>=0.110
uvicorn>=0.27

View 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
View 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
View 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,
},
});