From b93a44d4acf1efde61097b984d116c0377d02d34 Mon Sep 17 00:00:00 2001
From: 5x
Date: Sat, 15 Aug 2026 21:47:10 +0800
Subject: [PATCH 01/34] docs: add frontend monorepo integration design spec
Co-Authored-By: Claude Sonnet 5
---
...15-frontend-monorepo-integration-design.md | 204 ++++++++++++++++++
1 file changed, 204 insertions(+)
create mode 100644 docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md
diff --git a/docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md b/docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md
new file mode 100644
index 0000000..c5aa566
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md
@@ -0,0 +1,204 @@
+# Frontend Monorepo Integration — Design
+
+## Overview
+
+Argus's dashboard is currently a Google-OAuth-protected, server-rendered (Jinja2) web UI. A separate Next.js project, `argus-dashboard`, was started as an eventual replacement — originally planned as an independently-deployed, cross-origin single-page app.
+
+That direction is reversed by this spec. `argus-dashboard` moves **into** the `argus` repository as `frontend/`, and its build output is served by the **same** FastAPI process, at the **same** origin. Same-origin serving means the dashboard's existing session-cookie authentication continues to work unchanged — no Bearer tokens, no CORS, no separate SPA OAuth flow are needed. (An earlier plan and PR built exactly that cross-origin machinery; it is abandoned — see [Superseded work](#superseded-work).)
+
+This spec covers three things that must land together, in one branch, before merging to `main`:
+
+1. Folding `argus-dashboard`'s source into `argus/frontend/`, with a Docker multi-stage build and CI wired up.
+2. FastAPI serving the built static frontend, replacing two of the three legacy Jinja2 routes.
+3. The actual dashboard pages (event list, event detail, webhook logs, login-state handling) built against the existing `/dashboard/api/*` JSON API.
+
+The three are inseparable: shipping (1) alone — a Docker build that now requires a Node/pnpm toolchain but whose output nothing serves — adds a new deployment failure point for zero functional benefit. Nothing merges to `main` until the whole pipeline works end-to-end.
+
+## Superseded Work
+
+An earlier plan (`docs/superpowers/plans/2026-08-15-frontend-api-extraction.md`) and its PR (#16) added:
+- Bearer-token issue/verify (`auth.issue_api_token`/`verify_api_token`)
+- Bearer support in `require_login`
+- `GET /dashboard/login/spa` / `GET /dashboard/oauth/callback/spa`
+- `CORSMiddleware`
+- Config: `FRONTEND_ORIGINS`, `FRONTEND_REDIRECT_URL`, `AUTH_TOKEN_TTL_SECONDS`
+
+None of it is needed once the frontend is same-origin. PR #16 was closed without merging; none of its code exists on `main`, so there is nothing to revert. `GET /dashboard/api/me` is the one thing from that effort worth keeping conceptually — but it already works with the plain session cookie via the *existing* (pre-PR-#16) `require_login`, so it needs no Bearer-specific code either. It is not currently on `main`; whether to (re-)add it is a call for whoever implements the plan, based on whether the new frontend's client-side auth check (see [Client-side auth check](#client-side-auth-check)) needs it.
+
+## Repository Layout
+
+`argus-dashboard` (a local-only repo at `/Users/zhangwuxian/Code/sciwork/argus-dashboard`, no remote, 14 commits including shadcn/ui setup, Storybook+Vitest scaffolding, and `output: "export"` already configured) is copied — **files only, not git history** — into `argus/frontend/`:
+
+```
+argus/
+├── src/argus/... # unchanged
+├── frontend/ # new: Next.js source (this repo's copy of argus-dashboard)
+│ ├── app/
+│ ├── components/
+│ ├── package.json # packageManager: pnpm@10.33.0
+│ ├── pnpm-lock.yaml
+│ ├── pnpm-workspace.yaml
+│ ├── next.config.ts
+│ └── .gitignore # kept — frontend-specific ignores stay scoped here, not merged into root
+├── Dockerfile # modified — see below
+├── .dockerignore # modified — see below
+├── pyproject.toml # modified — see below
+└── .github/workflows/ci.yml # modified — see below
+```
+
+The original `argus-dashboard` directory is left in place (not deleted) — it's out of scope for this spec to decide its fate.
+
+## Routing
+
+| Path | Before | After |
+|------|--------|-------|
+| `GET /dashboard` | Jinja2 `index.html` | **Swapped** — serves the built static frontend's home page |
+| `GET /dashboard/webhook-logs` | Jinja2 `webhook_logs.html` | **Swapped** — serves the built static frontend |
+| `GET /dashboard/events?slug=` | *(doesn't exist)* | **New** — serves the built static frontend's event-detail page |
+| `GET /dashboard/events/{slug}` | Jinja2 `event.html`, session-gated | **Unchanged** — kept because Next.js static export cannot pre-render a path segment for a slug that doesn't exist yet at build time (new events arrive via webhook after deploy) |
+| `GET /dashboard/login`, `GET /dashboard/oauth/callback`, `GET /dashboard/logout` | session-cookie OAuth flow | **Unchanged** |
+| `GET /dashboard/api/*` (events, timeseries, webhook-logs, report/trigger, event delete) | session-cookie protected via `Depends(auth.require_login)` | **Unchanged** |
+
+Consequences:
+- `dashboard/templates/index.html` and `dashboard/templates/webhook_logs.html`, and the `dashboard_home`/`dashboard_webhook_logs` route functions that render them, are deleted. `event.html` and `dashboard_event` stay exactly as they are.
+- The new frontend's event-detail page reads its identifier from a query string (`?slug=`), not a path segment — Next.js can pre-render this as a single static file (`app/events/page.tsx`, no dynamic route segment), sidestepping the pre-rendering problem entirely. The frontend's own links to this page (e.g. from the event list) point at `/dashboard/events?slug=`, not the legacy path.
+
+### Client-side auth check
+
+The three swapped/new pages (`/dashboard`, `/dashboard/webhook-logs`, `/dashboard/events`) are **public shells** — FastAPI serves the static HTML with no server-side session check, because a static file has no per-request logic to check anything with. Protection stays where it already lives: every `/dashboard/api/*` call still requires the session cookie. The frontend calls the dashboard's user-lookup endpoint on load; on 401 it redirects the browser to `/dashboard/login`. (Whether that's the existing-but-unused `/dashboard/api/me` or a route the implementer adds is their call — see [Superseded Work](#superseded-work).)
+
+This is a deliberate, accepted UX change from today's behavior: an unauthenticated visit to `/dashboard` currently gets an immediate server-side 302; after this change it will briefly render the shell before client-side JS redirects. For an internal admin tool this trade-off is acceptable.
+
+`/dashboard/events/{slug}` (the retained legacy route) is unaffected — it keeps its existing server-side `_session_email_or_redirect` gate, because it's still a real Jinja2 route with per-request logic.
+
+## Static File Serving
+
+A real `next build` with `output: "export"` (already configured in `argus-dashboard`) was run as a spike to see the actual output shape. It produces more than per-page `.html` files:
+
+```
+out/
+├── index.html, index.txt # page + a lightweight client-nav prefetch payload
+├── 404.html, _not-found.html/.txt
+├── favicon.ico, *.svg # public/ assets, copied verbatim
+└── _next/static/
+ ├── chunks/*.js, *.css # content-hashed, cacheable forever
+ ├── media/*.woff2
+ └── /*.js # build-id directory name changes every build
+```
+
+Because of the extra `.txt` companion files and the per-build hashed directory, **the whole output tree must be served as-is** — bespoke per-page route handlers (as originally sketched in the superseded plan) would miss files the client-side router needs for navigation. The recommended mechanism is a single `StaticFiles(directory=..., html=True)` mount covering the whole build directory, registered **after** `app.include_router(dashboard_router)` so the more specific routes (`/dashboard/login`, `/dashboard/api/*`, `/dashboard/events/{slug}`, `/dashboard/oauth/callback`) are matched first and only unmatched paths fall through to the static mount.
+
+Two Next.js config requirements this implies, both belonging in `frontend/next.config.ts`:
+
+- **`basePath: "/dashboard"`** — so every one of Next's own asset/script/link references resolves under the same prefix FastAPI mounts the files at. Without this, the built HTML's script tags would reference `/_next/static/...` (root-relative) instead of `/dashboard/_next/static/...`, and the assets would 404.
+- **`trailingSlash: true`** — so each page exports as `/index.html` rather than a flat `.html`. Static file servers (including Starlette's `StaticFiles(html=True)`) resolve directory-style paths (`/dashboard/webhook-logs/` → `webhook-logs/index.html`) far more predictably than they resolve an extensionless path to a same-named `.html` file. This is also the standard recommendation for serving a Next static export from a non-Next server.
+
+The build directory itself is `src/argus/dashboard/frontend/` — inside the `dashboard` feature package, alongside the existing `templates/` directory, following the same convention (see [Build & Packaging](#build--packaging)). This is a *build artifact location*, not source — it exists only inside the Docker image, populated by the multi-stage build below. It is unrelated to (and not a duplicate of) `argus/frontend/`, which is the Next.js *source*.
+
+**Left to implementation, not fully specified here:** the exact FastAPI mount call, and empirical verification that a request to `/dashboard/webhook-logs` actually resolves against the real Next export output once `trailingSlash`/`basePath` are set — this needs a real build-and-serve check, not just code review.
+
+## Build & Packaging
+
+### Dockerfile (multi-stage)
+
+```dockerfile
+FROM node:22-slim AS frontend-build
+
+WORKDIR /frontend
+RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
+
+COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY frontend ./
+RUN pnpm build
+
+
+FROM python:3.12-slim-bookworm
+
+LABEL org.opencontainers.image.source="https://github.com/sciwork/argus"
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+COPY pyproject.toml README.md ./
+COPY src ./src
+COPY --from=frontend-build /frontend/out ./src/argus/dashboard/frontend
+
+RUN pip install --no-cache-dir .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "argus.main:app", "--host", "0.0.0.0", "--port", "8000"]
+```
+
+Notes on deviations from the reference Dockerfile the design started from:
+- **pnpm, not npm** — matches `argus-dashboard`'s actual tooling (`pnpm-lock.yaml`, `packageManager` field); installed via corepack rather than a global `npm install -g pnpm`, so the exact pinned version is used.
+- **Copies from `/frontend/out`, not `/frontend/dist`** — matches Next.js's actual static-export output directory (confirmed by the spike), not the reference's assumption.
+- **No `sqlite3` CLI install** — the reference Dockerfile this design started from included `apt-get install sqlite3`; that was deliberately removed from `argus`'s Dockerfile in a past change (#13) and must **not** be reintroduced.
+
+### `pyproject.toml`
+
+Add the frontend build output to package data, alongside the existing `templates/*.html` entry:
+
+```toml
+[tool.setuptools.package-data]
+"argus.dashboard" = ["templates/*.html", "frontend/**/*"]
+"argus.kktix" = ["templates/*.j2"]
+```
+
+**Verify, don't assume:** confirm setuptools' glob actually picks up nested files recursively (build a wheel and inspect its contents) rather than trusting the `**` pattern works as written — this is an easy thing to get subtly wrong.
+
+### `.dockerignore`
+
+Add:
+```
+frontend/node_modules
+frontend/.next
+frontend/out
+```
+
+### CI (`.github/workflows/ci.yml`)
+
+Add a `frontend` job alongside the existing Python `test` job: corepack-installed pnpm (pinned via `packageManager`), `pnpm install --frozen-lockfile`, `pnpm lint`, `pnpm build`. No test step yet — `package.json` has no `test` script defined despite the Storybook/Vitest scaffolding commit; add one only once real component tests exist.
+
+## Local Development
+
+In production, FastAPI serves the built frontend, so both are same-origin by construction. In local development, `next dev` (typically `localhost:3000`) and `uvicorn` (`localhost:8000`) run as separate processes on different ports — still cross-origin.
+
+Rather than reintroducing CORS for dev only, `frontend/next.config.ts` gets a dev-only `rewrites()` entry forwarding `/dashboard/api/:path*` to `http://localhost:8000/dashboard/api/:path*`. The browser only ever talks to `localhost:3000`; Next's dev server proxies the API calls server-side. This keeps dev and prod auth behavior identical (session cookie, no CORS, ever) and preserves hot-reload.
+
+## Frontend Pages
+
+Built against the **existing, unchanged** `/dashboard/api/*` JSON shapes (documented in `SPEC.md`) — no backend API changes are needed to support them:
+
+- **Home / event list** (`/dashboard`) — replaces the Jinja2 event list. Consumes `GET /dashboard/api/events`. Links to each event point at `/dashboard/events?slug=`.
+- **Event detail** (`/dashboard/events`, reading `slug` from the query string) — replaces the Jinja2 per-event chart page. Consumes `GET /dashboard/api/events/{slug}/timeseries`. Feature parity with the current Chart.js rendering: one line per ticket type plus "Total", horizontal dashed capacity line, vertical dashed event-start line, daily granularity.
+- **Webhook logs** (`/dashboard/webhook-logs`) — replaces the Jinja2 log viewer. Consumes `GET /dashboard/api/webhook-logs` (paginated), `DELETE /dashboard/api/webhook-logs/{id}`, `DELETE /dashboard/api/webhook-logs`.
+- **Login-state handling** — see [Client-side auth check](#client-side-auth-check) above.
+
+Charting library: `argus-dashboard` already has shadcn/ui set up, which ships a chart component built on **Recharts**. Recommended over pulling in Chart.js again, for consistency with the rest of the design system already in place. This is a low-risk, easily-revisited implementation choice, not a hard requirement of this spec.
+
+Visual/component-level design (exact layout, spacing, styling) is intentionally not specified here — build to functional parity with the current dashboard, using the design system already scaffolded in `argus-dashboard` (shadcn/ui, breakpoint tokens), and use judgment for the rest. This is an internal admin tool, not a customer-facing product.
+
+## Sequencing & Merge Gate
+
+All of the above lands in one branch (fresh off `main` — PR #16 was closed unmerged first). Suggested order, each step kept independently testable:
+
+1. Copy `argus-dashboard` → `frontend/`, add `.gitignore`, confirm `pnpm install`/`pnpm build`/`pnpm lint` work standalone.
+2. Dockerfile + `.dockerignore` + `pyproject.toml` package-data — build the image, confirm the static files land in the installed package (inspect the built wheel/image, don't assume).
+3. CI `frontend` job.
+4. FastAPI static-serving wiring (`basePath`/`trailingSlash` config, the `StaticFiles` mount, removal of the two Jinja2 routes/templates) — verified against a real build, not just code review.
+5. The three pages + client-side auth check.
+6. Local-dev proxy (`next.config.ts` rewrites).
+7. End-to-end verification: fresh clone, `docker build`, run the container, confirm `/dashboard`, `/dashboard/webhook-logs`, `/dashboard/events?slug=...`, and `/dashboard/events/{slug}` (legacy) all work, login/logout still work, and nothing else (webhook ingestion, Discord reports, `/health`) regressed.
+
+Only after step 7 passes does this merge to `main`.
+
+## Open Questions / Explicitly Deferred
+
+- Preserving `argus-dashboard`'s original git history in the merge — explicitly decided against; a plain copy is used instead.
+- A frontend test suite / CI test step — deferred until real tests exist.
+- Deleting the original `argus-dashboard` directory — deferred, not this spec's call.
From 41f9a6b008e50295719939d1c1898b4ecb8fe832 Mon Sep 17 00:00:00 2001
From: 5x
Date: Sat, 15 Aug 2026 22:07:21 +0800
Subject: [PATCH 02/34] docs: add frontend monorepo integration implementation
plan
Co-Authored-By: Claude Sonnet 5
---
...026-08-15-frontend-monorepo-integration.md | 1466 +++++++++++++++++
1 file changed, 1466 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md
diff --git a/docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md b/docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md
new file mode 100644
index 0000000..d6c5a39
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md
@@ -0,0 +1,1466 @@
+# Frontend Monorepo Integration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fold the separate `argus-dashboard` Next.js project into `argus` as `frontend/`, have FastAPI serve its static export at the same origin (replacing two of three legacy Jinja2 dashboard routes), and build the three real dashboard pages against the existing `/dashboard/api/*` JSON API — so the whole pipeline works end-to-end before it ever reaches `main`.
+
+**Architecture:** Same-origin static-file serving. `frontend/` (Next.js, `output: "export"`) builds to a directory Docker copies into `src/argus/dashboard/frontend/`, which FastAPI mounts via `StaticFiles(html=True)` under `/dashboard`, registered after the existing dashboard router so `/dashboard/login`, `/dashboard/api/*`, `/dashboard/oauth/callback`, and the retained legacy `/dashboard/events/{slug}` all take priority. No Bearer tokens, no CORS — the existing session-cookie `require_login` dependency is untouched and is what actually protects data; the static pages themselves are public shells.
+
+**Tech Stack:** Backend unchanged (FastAPI, SQLAlchemy, Starlette). Frontend: Next.js 16 (App Router, static export), TypeScript, Tailwind v4, shadcn/ui (Base UI primitives, `style: "base-sera"`), axios, Recharts (via shadcn's chart wrapper), pnpm, Vitest + Storybook (`@storybook/nextjs-vite`, Playwright browser provider).
+
+**Spec:** [docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md](../specs/2026-08-15-frontend-monorepo-integration-design.md)
+
+## Global Constraints
+
+- Nothing in this plan merges to `main` until Task 11 (end-to-end verification) passes — see the spec's "Sequencing & Merge Gate".
+- Backend: Python 3.11+, ruff (`I, N, E, W, F, UP`), double-quote strings, isort `from-first`, 2 blank lines after imports. Every commit ends with `Co-Authored-By: Claude Sonnet 5 `.
+- Frontend: **pnpm only, never npm** (`packageManager: "pnpm@10.33.0"` in `frontend/package.json`). **Axios, not native `fetch`**, for all API calls (AGENTS.md: "Data Fetching: Axios"). Follow the folder convention from `frontend/AGENTS.md`: `apis/` (API calls), `types/responses/` (response shapes), `hooks/` (custom hooks) — create these directories as needed, they don't exist yet. TypeScript: prefer explicit types over inferred (`as const` where relevant); avoid `any`, use `unknown` + narrowing. Run `pnpm exec prettier . --write` before every frontend commit (double quotes, semicolons, trailing commas, the project's specific import order, Tailwind class sorting — all enforced by the configured Prettier plugins, not by hand).
+- **`basePath: "/dashboard"` gotcha:** once Task 5 sets this, Next's own `` / `useRouter()` **automatically prepend** `/dashboard` to any app-internal path. Write internal hrefs *without* the `/dashboard` prefix (e.g. `href="/events?slug=..."`, which Next renders as `/dashboard/events?slug=...`). Paths Next does *not* own — `/dashboard/login`, `/dashboard/oauth/callback` — are FastAPI routes; navigate to them with a real browser navigation (`window.location.href = "/dashboard/login"`), never through Next's router, and write them with the full `/dashboard/...` path since nothing auto-prefixes a raw `window.location` assignment.
+- `cn()` convention (from `frontend/AGENTS.md`): static classes as a string argument, conditional classes in an object argument — `cn("static-class", { "conditional-class": isCondition })`.
+- Storybook stories + Vitest component tests are required by `frontend/AGENTS.md` for reusable `components/*`. This plan scopes that requirement to the one genuinely reusable extraction (`EventChart`, Task 8) — page-level route files (`app/*/page.tsx`) are composition/wiring, not reusable components, and are not given stories.
+- The original `argus-dashboard` directory is left untouched throughout this plan.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|------|----------------|
+| `frontend/` | Copied Next.js source (from `argus-dashboard`) |
+| `AGENTS.md`, `CLAUDE.md` (repo root) | New — point Claude/agents at `frontend/AGENTS.md` for frontend conventions (see Task 1) |
+| `Dockerfile` | Modified — multi-stage: Node/pnpm build stage, then Python |
+| `.dockerignore`, `pyproject.toml` | Modified — frontend build artifacts ignored; static output packaged |
+| `.github/workflows/ci.yml` | Modified — new `frontend` job |
+| `src/argus/auth.py` | Modified — `require_login` gains no new logic; only a new route consumes it |
+| `src/argus/dashboard/router.py` | Modified — remove `dashboard_home`/`dashboard_webhook_logs` + their templates; add `api_me` |
+| `src/argus/dashboard/templates/index.html`, `webhook_logs.html` | Deleted |
+| `src/argus/main.py` | Modified — `StaticFiles` mount |
+| `frontend/next.config.ts` | Modified — `basePath`, `trailingSlash`, dev-only `rewrites()` |
+| `frontend/apis/*.ts`, `frontend/types/responses/*.ts`, `frontend/hooks/*.ts` | New — API client + auth-check hook |
+| `frontend/app/page.tsx` | Modified — event list |
+| `frontend/app/events/page.tsx`, `frontend/components/event-chart.tsx` | New — event detail + chart |
+| `frontend/app/webhook-logs/page.tsx` | New — webhook log viewer |
+
+---
+
+### Task 1: Fold `argus-dashboard` into `frontend/`
+
+**Files:**
+- Create: `frontend/` (copied from `argus-dashboard`, files only — no git history)
+- Create: `AGENTS.md`, `CLAUDE.md` (repo root)
+
+**Interfaces:**
+- Produces: a working, standalone `frontend/` Next.js project (`pnpm install`/`pnpm build`/`pnpm lint` all succeed from within it) — every later task builds on this.
+
+- [ ] **Step 1: Copy tracked files only, no history**
+
+`argus-dashboard`'s working tree must be clean before this (`git -C /Users/zhangwuxian/Code/sciwork/argus-dashboard status --porcelain` should print nothing).
+
+```bash
+mkdir -p frontend
+git -C /Users/zhangwuxian/Code/sciwork/argus-dashboard archive HEAD | tar -x -C frontend
+```
+
+`git archive` exports exactly the tracked tree at `HEAD` — no `.git/`, no history, and (because it's a tracked file) `frontend/.gitignore` comes along automatically, so `node_modules/`, `.next/`, `out/`, etc. stay correctly ignored once you `pnpm install`/`pnpm build` below.
+
+- [ ] **Step 2: Verify the copy is self-contained**
+
+```bash
+cd frontend && pnpm install && pnpm build && pnpm lint
+cd ..
+```
+
+Expected: all three succeed. `pnpm build` produces `frontend/out/` (gitignored, don't commit it).
+
+- [ ] **Step 3: Root `AGENTS.md`/`CLAUDE.md` — point at the frontend's own conventions**
+
+`frontend/AGENTS.md` and `frontend/CLAUDE.md` came along in Step 1, but the repo root's existing `.gitignore` has:
+```
+# AI assistant configs (personal, not shared)
+.claude/
+CLAUDE.md
+AGENTS.md
+```
+No leading `/`, so this matches at *any* depth — `frontend/AGENTS.md` and `frontend/CLAUDE.md` will be silently excluded from git by this repo's existing, deliberate policy (agent-config files aren't shared via git here). Leave that policy alone. But a Claude session working from the repo root (not already `cd`'d into `frontend/`) needs *some* on-disk pointer to discover that `frontend/` has its own detailed conventions doc — so create root-level files whose job is only to point there:
+
+```markdown
+# AGENTS.md
+# Argus
+
+FastAPI backend (`src/argus/`) — see `SPEC.md` for the full API/architecture
+reference — plus a Next.js dashboard frontend (`frontend/`), served
+same-origin by the same FastAPI process. See
+`docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md`
+for how the two fit together.
+
+## Frontend (`frontend/`)
+
+A separate Next.js project with its own tech stack, folder conventions, and
+testing rules — see `frontend/AGENTS.md` (present on disk once `frontend/`
+exists in your working copy; not tracked in git, matching this repo's
+existing policy of not committing agent-config files). Key points if you
+don't have it handy:
+
+- Static export (`output: "export"`), served by FastAPI at the same origin
+ as the backend — no separate deployment, no CORS, no Bearer tokens.
+- Package manager: pnpm (not npm).
+- Data fetching: axios.
+- Component library: shadcn/ui on Base UI (not Radix) — see
+ `frontend/.agents/skills/shadcn/` and
+ `frontend/.agents/skills/migrate-radix-to-base/`.
+```
+
+```markdown
+# CLAUDE.md
+@AGENTS.md
+```
+
+(Mirrors `frontend/CLAUDE.md`'s own one-line-pointer pattern.) These two root files will *also* be excluded by the same gitignore rule — that's fine, they exist to help whoever's local working copy has `frontend/` present; they aren't meant to be the shared record of these conventions (the spec and this plan are).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A frontend
+git status --porcelain # confirm AGENTS.md/CLAUDE.md (root and frontend/) do NOT appear — gitignored, as intended
+git commit -m "feat: fold argus-dashboard into frontend/
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+(The root/`frontend/` `AGENTS.md`/`CLAUDE.md` files from Step 3 stay on disk but aren't part of this commit, by design — see Step 3.)
+
+---
+
+### Task 2: Docker multi-stage build + package data
+
+**Files:**
+- Modify: `Dockerfile`
+- Modify: `.dockerignore`
+- Modify: `pyproject.toml`
+
+**Interfaces:**
+- Consumes: `frontend/` (Task 1).
+- Produces: a Docker image whose Python package includes the built static frontend at `src/argus/dashboard/frontend/` at runtime — consumed by Task 5's `StaticFiles` mount.
+
+- [ ] **Step 1: Rewrite `Dockerfile` as multi-stage**
+
+```dockerfile
+FROM node:22-slim AS frontend-build
+
+WORKDIR /frontend
+RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
+
+COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY frontend ./
+RUN pnpm build
+
+
+FROM python:3.12-slim-bookworm
+
+LABEL org.opencontainers.image.source="https://github.com/sciwork/argus"
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+COPY pyproject.toml README.md ./
+COPY src ./src
+COPY --from=frontend-build /frontend/out ./src/argus/dashboard/frontend
+
+RUN pip install --no-cache-dir .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "argus.main:app", "--host", "0.0.0.0", "--port", "8000"]
+```
+
+Do not reintroduce `apt-get install sqlite3` — the current single-stage `Dockerfile` already has it removed (past change #13); this rewrite must preserve that.
+
+- [ ] **Step 2: Update `.dockerignore`**
+
+Add:
+```
+frontend/node_modules
+frontend/.next
+frontend/out
+```
+
+- [ ] **Step 3: Update `pyproject.toml` package data**
+
+```toml
+[tool.setuptools.package-data]
+"argus.dashboard" = ["templates/*.html", "frontend/**/*"]
+"argus.kktix" = ["templates/*.j2"]
+```
+
+- [ ] **Step 4: Build the image and verify the static files actually land in the installed package**
+
+Don't just trust the `**` glob — check the built wheel directly:
+
+```bash
+docker build --tag argus-frontend-check .
+docker run --rm argus-frontend-check python -c "
+import pathlib
+p = pathlib.Path('/usr/local/lib/python3.12/site-packages/argus/dashboard/frontend')
+assert p.is_dir(), f'{p} missing'
+assert (p / 'index.html').exists(), 'index.html missing from installed package'
+print('OK:', sorted(str(f.relative_to(p)) for f in p.rglob('*'))[:10], '...')
+"
+docker image rm argus-frontend-check
+```
+
+Expected: `OK: [...]` printing at least `index.html` and something under `_next/`. If the assertion fails, the `package-data` glob isn't matching recursively as written — the `[tool.setuptools.package-data]` glob or a `MANIFEST.in`/`include_package_data` setting needs adjusting; don't guess which without seeing the actual failure.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Dockerfile .dockerignore pyproject.toml
+git commit -m "feat: multi-stage Docker build for the frontend static export
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 3: CI frontend job
+
+**Files:**
+- Modify: `.github/workflows/ci.yml`
+
+**Interfaces:**
+- Consumes: `frontend/` (Task 1).
+- Produces: CI coverage that `pnpm install`/`pnpm lint`/`pnpm build` keep working on every push/PR.
+
+- [ ] **Step 1: Add a `frontend` job**
+
+```yaml
+ frontend:
+ name: Frontend
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Enable corepack
+ run: corepack enable
+
+ - name: Set up Node
+ uses: actions/setup-node@v5
+ with:
+ node-version: "22"
+
+ - name: Install dependencies
+ working-directory: frontend
+ run: pnpm install --frozen-lockfile
+
+ - name: Lint
+ working-directory: frontend
+ run: pnpm lint
+
+ - name: Build
+ working-directory: frontend
+ run: pnpm build
+```
+
+Add this as a sibling to the existing `test:` job (same `jobs:` level), not nested inside it. No test step — `frontend/package.json` has no `test` script yet (the Storybook/Vitest scaffolding isn't wired to one); add it once real component tests exist (Task 8 adds the first one, at which point revisit).
+
+- [ ] **Step 2: Verify locally as far as possible**
+
+```bash
+cd frontend && pnpm install --frozen-lockfile && pnpm lint && pnpm build
+cd ..
+```
+
+(Full CI verification happens when this branch's commits actually run in GitHub Actions — note that in your PR description/final check rather than trying to fully simulate it locally.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .github/workflows/ci.yml
+git commit -m "ci: add frontend lint/build job
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 4: `GET /dashboard/api/me`
+
+**Files:**
+- Modify: `src/argus/dashboard/router.py`
+- Test: `tests/test_auth.py`
+
+**Interfaces:**
+- Consumes: `auth.require_login` (existing, unchanged — session-cookie only, no Bearer).
+- Produces: `GET /dashboard/api/me` → `{"email": str}` — consumed by Task 6's frontend auth-check hook.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_auth.py — reuse the existing dashboard_app fixture and its
+# session-cookie login pattern (this file already has a real OIDC-mock
+# OAuth flow test to model from; a simpler direct-session-set test suffices
+# here since api_me has no logic beyond require_login)
+@pytest.mark.asyncio
+async def test_api_me_returns_authenticated_email(dashboard_app):
+ """The frontend can look up who is currently logged in via the session cookie."""
+ transport = httpx.ASGITransport(app=dashboard_app)
+ async with httpx.AsyncClient(
+ transport=transport, base_url="http://test"
+ ) as client:
+ # Log in by hitting the real OAuth flow, or set the session directly
+ # via the test client's cookie jar if this file already has a helper
+ # for that — check tests/test_auth.py's existing fixtures/imports
+ # before adding a new one.
+ login_response = await client.get(
+ "/dashboard/api/me"
+ )
+ assert login_response.status_code == 401 # no session yet
+
+ # (Use whichever session-establishing approach the existing test file
+ # already relies on — e.g. driving the real OIDC-mock flow like
+ # test_google_oauth_accepts_only_allowlisted_user does — to then
+ # assert a 200 with {"email": "chester@example.com"}.)
+```
+
+Read `tests/test_auth.py` in full before writing this — the file already has a working pattern for establishing an authenticated session via the real (mocked) Google OAuth flow (`run_server_in_thread`, `client.get("/dashboard/login", ...)`, following the redirect chain). Reuse that pattern rather than inventing a new one; the test above is a starting sketch, not the literal final code — fill in the actual login step using the existing pattern in the same file.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest tests/test_auth.py -v -k api_me`
+Expected: FAIL — route doesn't exist yet (404, or the test itself won't even reach a meaningful assertion).
+
+- [ ] **Step 3: Implement**
+
+```python
+# src/argus/dashboard/router.py — add to the "── JSON API ──" section,
+# as the first route, right before api_events
+@router.get("/dashboard/api/me")
+async def api_me(email: str = Depends(auth.require_login)):
+ return {"email": email}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest tests/test_auth.py -v`
+Expected: PASS (whole file — confirms this addition didn't disturb the existing OAuth cookie-flow test)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/argus/dashboard/router.py tests/test_auth.py
+git commit -m "feat: add GET /dashboard/api/me for frontend session bootstrap
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 5: Next.js config + FastAPI static serving + remove two legacy routes
+
+**Files:**
+- Modify: `frontend/next.config.ts`
+- Modify: `src/argus/main.py`
+- Modify: `src/argus/dashboard/router.py`
+- Delete: `src/argus/dashboard/templates/index.html`, `src/argus/dashboard/templates/webhook_logs.html`
+- Modify: `tests/test_docker_integration.py`
+
+**Interfaces:**
+- Consumes: `src/argus/dashboard/frontend/` existing at runtime (Task 2's Docker copy).
+- Produces: `GET /dashboard` resolves to the built static frontend (still just the default scaffold page at this point — Task 7 replaces its content); `/dashboard/webhook-logs` and `/dashboard/events` will 404 until Tasks 8–9 add those pages, which is expected and fine at this stage. `/dashboard/events/{slug}` (legacy Jinja2) and all `/dashboard/api/*`, `/dashboard/login`, `/dashboard/oauth/callback` routes are unaffected.
+
+- [ ] **Step 1: Next.js config**
+
+```typescript
+// frontend/next.config.ts
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "export",
+ basePath: "/dashboard",
+ trailingSlash: true,
+ // next/image's default loader needs a server; serve images as-is instead.
+ images: {
+ unoptimized: true,
+ },
+};
+
+export default nextConfig;
+```
+
+- [ ] **Step 2: Remove the two legacy Jinja2 routes and templates**
+
+```python
+# src/argus/dashboard/router.py — DELETE these two route functions entirely:
+# @router.get("/dashboard")
+# async def dashboard_home(request: Request): ...
+#
+# @router.get("/dashboard/webhook-logs")
+# async def dashboard_webhook_logs(request: Request): ...
+#
+# Keep `dashboard_event` (the /dashboard/events/{slug} handler), the
+# `templates` Jinja2Templates instance (event.html still needs it),
+# `_session_email_or_redirect`, and `_format_start_at_local` exactly as they are.
+```
+
+```bash
+rm src/argus/dashboard/templates/index.html
+rm src/argus/dashboard/templates/webhook_logs.html
+```
+
+- [ ] **Step 3: Mount the static frontend in `main.py`**
+
+```python
+# src/argus/main.py — imports: add
+from pathlib import Path
+
+from starlette.staticfiles import StaticFiles
+```
+
+```python
+# src/argus/main.py — add right after app.include_router(health_router)
+_FRONTEND_DIR = Path(__file__).parent / "dashboard" / "frontend"
+
+if _FRONTEND_DIR.is_dir():
+ app.mount(
+ "/dashboard", StaticFiles(directory=_FRONTEND_DIR, html=True), name="dashboard-frontend"
+ )
+```
+
+The `is_dir()` guard matters: in local development (no Docker build), `src/argus/dashboard/frontend/` won't exist, and `StaticFiles(directory=...)` raises at construction time if its directory is missing — without the guard, the app would fail to start at all for anyone running `uvicorn` directly against a source checkout without having built the frontend first.
+
+Mounting *after* `app.include_router(dashboard_router)` (already the case — this is appended after the last `include_router` call) means the more specific routes (`/dashboard/login`, `/dashboard/api/*`, `/dashboard/events/{slug}`, `/dashboard/oauth/callback`) are matched first; only paths under `/dashboard/*` that don't match any of those fall through to the static mount.
+
+- [ ] **Step 4: Extend the Docker integration test**
+
+```python
+# tests/test_docker_integration.py — new test function, using the existing
+# api_url fixture (already builds the real image and runs it)
+def test_docker_image_serves_frontend_shell(api_url: str) -> None:
+ """The built static frontend is served at /dashboard, same-origin."""
+ with httpx.Client(base_url=api_url, timeout=5) as client:
+ response = client.get("/dashboard")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+```
+
+Do **not** add assertions for `/dashboard/webhook-logs` or `/dashboard/events` here — those pages don't exist in the Next app until Tasks 8–9, so a request for them would 404 at this point in the sequence; those tasks add their own equivalent assertions once their pages exist. Do **not** remove or alter `test_docker_image_api_flow` or `test_docker_image_cors_and_bearer_token_auth` — wait, the latter is from the abandoned PR #16 and shouldn't exist on `main` at all; if you find it while reading this file, that means you're working from the wrong base — confirm you branched from current `main`, not the old `worktree-frontend-api-extraction` branch.
+
+- [ ] **Step 5: Run the full suite, including Docker**
+
+Run: `uv run pytest tests/ -v` (this rebuilds the image — expect ~30-60s)
+Expected: PASS, including the new frontend-shell test and the retained legacy-`/dashboard/events/{slug}` coverage in the existing suite.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add frontend/next.config.ts src/argus/main.py src/argus/dashboard/router.py \
+ tests/test_docker_integration.py
+git rm src/argus/dashboard/templates/index.html src/argus/dashboard/templates/webhook_logs.html
+git commit -m "feat: serve the built frontend at /dashboard, retire two Jinja2 routes
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 6: Frontend API client + auth-check hook
+
+**Files:**
+- Create: `frontend/apis/client.ts`, `frontend/apis/auth.ts`, `frontend/apis/events.ts`, `frontend/apis/webhook-logs.ts`
+- Create: `frontend/types/responses/auth.ts`, `frontend/types/responses/events.ts`, `frontend/types/responses/webhook-logs.ts`
+- Create: `frontend/hooks/use-require-auth.ts`
+- Test: `frontend/tests/hooks/use-require-auth.test.tsx`
+
+**Interfaces:**
+- Consumes: `GET /dashboard/api/me`, `/events`, `/events/{slug}/timeseries`, `/events/{slug}` (DELETE), `/webhook-logs`, `/webhook-logs/{id}` (DELETE) — all documented in `SPEC.md`, unchanged by this plan.
+- Produces: `getCurrentUser(): Promise`, `listEvents()`, `getEventTimeseries(slug)`, `deleteEvent(slug)`, `listWebhookLogs(limit, offset)`, `deleteWebhookLog(id)`, `clearWebhookLogs()`, and the `useRequireAuth()` hook — consumed by Tasks 7–9's pages.
+
+- [ ] **Step 1: Response types**
+
+```typescript
+// frontend/types/responses/auth.ts
+export interface CurrentUser {
+ email: string;
+}
+```
+
+```typescript
+// frontend/types/responses/events.ts
+export interface EventSummary {
+ event_slug: string;
+ event_name: string;
+ channel: string | null;
+ start_at: string | null;
+ capacity: number | null;
+}
+
+export interface TimeseriesDataset {
+ name: string;
+ data: number[];
+}
+
+export interface EventTimeseries {
+ event: EventSummary;
+ labels: string[];
+ datasets: TimeseriesDataset[];
+ start_marker_label: string | null;
+}
+```
+
+```typescript
+// frontend/types/responses/webhook-logs.ts
+export interface WebhookLogEntry {
+ id: number;
+ method: string;
+ channel: string | null;
+ headers: string;
+ body: string | null;
+ created_at: string;
+}
+
+export interface WebhookLogsPage {
+ items: WebhookLogEntry[];
+ total: number;
+ limit: number;
+ offset: number;
+}
+```
+
+- [ ] **Step 2: Axios client + API functions**
+
+```typescript
+// frontend/apis/client.ts
+import axios from "axios";
+
+export const apiClient = axios.create({
+ baseURL: "/dashboard/api",
+});
+```
+
+Same-origin by design (see the spec) — no `withCredentials` needed; the browser sends the session cookie automatically for same-origin requests.
+
+```typescript
+// frontend/apis/auth.ts
+import { isAxiosError } from "axios";
+import { apiClient } from "@/apis/client";
+import type { CurrentUser } from "@/types/responses/auth";
+
+export async function getCurrentUser(): Promise {
+ try {
+ const response = await apiClient.get("/me");
+ return response.data;
+ } catch (error) {
+ if (isAxiosError(error) && error.response?.status === 401) {
+ return null;
+ }
+ throw error;
+ }
+}
+```
+
+```typescript
+// frontend/apis/events.ts
+import { apiClient } from "@/apis/client";
+import type { EventSummary, EventTimeseries } from "@/types/responses/events";
+
+export async function listEvents(): Promise {
+ const response = await apiClient.get("/events");
+ return response.data;
+}
+
+export async function getEventTimeseries(
+ slug: string,
+): Promise {
+ const response = await apiClient.get(
+ `/events/${encodeURIComponent(slug)}/timeseries`,
+ );
+ return response.data;
+}
+
+export async function deleteEvent(slug: string): Promise {
+ await apiClient.delete(`/events/${encodeURIComponent(slug)}`);
+}
+```
+
+```typescript
+// frontend/apis/webhook-logs.ts
+import { apiClient } from "@/apis/client";
+import type { WebhookLogsPage } from "@/types/responses/webhook-logs";
+
+export async function listWebhookLogs(
+ limit: number,
+ offset: number,
+): Promise {
+ const response = await apiClient.get("/webhook-logs", {
+ params: { limit, offset },
+ });
+ return response.data;
+}
+
+export async function deleteWebhookLog(id: number): Promise {
+ await apiClient.delete(`/webhook-logs/${id}`);
+}
+
+export async function clearWebhookLogs(): Promise {
+ await apiClient.delete("/webhook-logs");
+}
+```
+
+- [ ] **Step 3: Write the failing test for the auth-check hook**
+
+```tsx
+// frontend/tests/hooks/use-require-auth.test.tsx
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import * as authApi from "@/apis/auth";
+import { useRequireAuth } from "@/hooks/use-require-auth";
+
+describe("useRequireAuth", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ // @ts-expect-error -- jsdom's location isn't reassignable by default; tests override it directly
+ delete window.location;
+ window.location = { href: "" } as Location;
+ });
+
+ it("returns the authenticated user when the session is valid", async () => {
+ vi.spyOn(authApi, "getCurrentUser").mockResolvedValue({
+ email: "chester@example.com",
+ });
+
+ const { result } = renderHook(() => useRequireAuth());
+
+ await waitFor(() =>
+ expect(result.current).toEqual({
+ status: "authenticated",
+ user: { email: "chester@example.com" },
+ }),
+ );
+ });
+
+ it("navigates to /dashboard/login when there is no session", async () => {
+ vi.spyOn(authApi, "getCurrentUser").mockResolvedValue(null);
+ window.location = { href: "" } as Location;
+
+ renderHook(() => useRequireAuth());
+
+ await waitFor(() => expect(window.location.href).toBe("/dashboard/login"));
+ });
+});
+```
+
+This needs `@testing-library/react` — check whether it's already a devDependency (the Storybook/Vitest setup may have pulled it in transitively); if not, add it: `pnpm add -D @testing-library/react`.
+
+- [ ] **Step 4: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/hooks/use-require-auth.test.tsx`
+Expected: FAIL — `@/hooks/use-require-auth` doesn't exist yet.
+
+- [ ] **Step 5: Implement the hook**
+
+```typescript
+// frontend/hooks/use-require-auth.ts
+"use client";
+
+import { useEffect, useState } from "react";
+import { getCurrentUser } from "@/apis/auth";
+import type { CurrentUser } from "@/types/responses/auth";
+
+type AuthState =
+ | { status: "loading" }
+ | { status: "authenticated"; user: CurrentUser }
+ | { status: "unauthenticated" };
+
+export function useRequireAuth(): AuthState {
+ const [state, setState] = useState({ status: "loading" });
+
+ useEffect(() => {
+ let cancelled = false;
+
+ getCurrentUser().then((user) => {
+ if (cancelled) return;
+ if (user) {
+ setState({ status: "authenticated", user });
+ } else {
+ setState({ status: "unauthenticated" });
+ window.location.href = "/dashboard/login";
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return state;
+}
+```
+
+Note this deliberately does **not** use `useTransition` — that's for marking user-triggered updates (button clicks, pagination, deletes — see Tasks 8–9) as non-urgent; this is a passive on-mount fetch, a different pattern.
+
+Also note: the redirect target is `window.location.href`, not Next's router — `/dashboard/login` is a FastAPI route the Next app doesn't own, so this must be a real browser navigation, not a client-side route transition (see Global Constraints).
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/hooks/use-require-auth.test.tsx`
+Expected: PASS (2/2)
+
+- [ ] **Step 7: Format, lint, typecheck**
+
+```bash
+cd frontend
+pnpm exec prettier . --write
+pnpm lint
+pnpm exec tsc --noEmit
+cd ..
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add frontend/apis frontend/types frontend/hooks frontend/tests frontend/package.json frontend/pnpm-lock.yaml
+git commit -m "feat(frontend): add API client and session auth-check hook
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 7: Home / event list page
+
+**Files:**
+- Modify: `frontend/app/page.tsx`
+
+**Interfaces:**
+- Consumes: `useRequireAuth()`, `listEvents()` (Task 6).
+- Produces: the real `/dashboard` home page, replacing the `create-next-app` scaffold.
+
+- [ ] **Step 1: Replace the scaffold page**
+
+```tsx
+// frontend/app/page.tsx
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { listEvents } from "@/apis/events";
+import { useRequireAuth } from "@/hooks/use-require-auth";
+import type { EventSummary } from "@/types/responses/events";
+
+export default function DashboardHomePage() {
+ const auth = useRequireAuth();
+ const [events, setEvents] = useState(null);
+
+ useEffect(() => {
+ if (auth.status !== "authenticated") return;
+ let cancelled = false;
+ listEvents().then((result) => {
+ if (!cancelled) setEvents(result);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [auth.status]);
+
+ if (auth.status !== "authenticated") {
+ return null;
+ }
+
+ return (
+
+
+
Argus Dashboard
+ {auth.user.email}
+
+
+ {events === null &&
Loading…
}
+ {events?.length === 0 &&
No events yet.
}
+ {events?.map((event) => (
+
+
+ {event.event_name}
+
+
+ ))}
+
+
+ );
+}
+```
+
+Note the `Link href` is `/events?slug=...`, **not** `/dashboard/events?slug=...` — `basePath` (set in Task 5) auto-prepends `/dashboard` to Next-owned internal links; writing the prefix explicitly here would double it (see Global Constraints).
+
+- [ ] **Step 2: Update the metadata title** (still says "Create Next App" from the scaffold)
+
+```tsx
+// frontend/app/layout.tsx — change only the metadata export
+export const metadata: Metadata = {
+ title: "Argus Dashboard",
+ description: "Registration analytics dashboard for Argus",
+};
+```
+
+- [ ] **Step 3: Format, lint, typecheck, build**
+
+```bash
+cd frontend
+pnpm exec prettier . --write
+pnpm lint
+pnpm exec tsc --noEmit
+pnpm build
+cd ..
+```
+
+- [ ] **Step 4: End-to-end check against the real backend**
+
+```bash
+uv run pytest tests/test_docker_integration.py -v -k serves_frontend_shell
+```
+
+Expected: still passes (the shell test from Task 5 now serves this real page instead of the scaffold — confirm the response still comes back 200 `text/html`; it doesn't assert on content, so no change needed there, but this is a good moment to also manually check the built `frontend/out/index.html` contains `Argus Dashboard` somewhere, confirming the real page — not a stale cached scaffold — is what actually got built).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/page.tsx frontend/app/layout.tsx
+git commit -m "feat(frontend): build the real event-list home page
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 8: shadcn chart + event detail page
+
+**Files:**
+- Modify: `frontend/package.json`, `frontend/components.json`-managed additions (via CLI)
+- Create: `frontend/components/ui/chart.tsx` (generated), `frontend/components/event-chart.tsx`
+- Create: `frontend/app/events/page.tsx`
+- Create: `frontend/stories/components/event-chart.stories.tsx`
+- Test: `frontend/tests/components/event-chart.test.tsx`
+
+**Interfaces:**
+- Consumes: `getEventTimeseries(slug)` (Task 6), `EventTimeseries` type.
+- Produces: `/dashboard/events?slug=` — the event-detail page with a line chart.
+
+- [ ] **Step 1: Add the chart component via the shadcn CLI**
+
+```bash
+cd frontend && pnpm dlx shadcn@latest add chart
+cd ..
+```
+
+This respects the project's existing `components.json` (`style: "base-sera"`, Base UI primitives, `@/` aliases) and adds `recharts` to `package.json` plus `components/ui/chart.tsx`. Don't hand-author this file — let the CLI generate it, then read what it produced before writing `EventChart` below, since the exact `ChartContainer`/`ChartConfig`/`ChartTooltip` API surface should be read from the real generated file, not assumed. If the CLI's output differs meaningfully from the usage shown in Step 3 below, adapt Step 3 to match what was actually generated rather than forcing the assumed API.
+
+- [ ] **Step 2: Write the failing component test**
+
+```tsx
+// frontend/tests/components/event-chart.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { EventChart } from "@/components/event-chart";
+import type { EventTimeseries } from "@/types/responses/events";
+
+const sample: EventTimeseries = {
+ event: {
+ event_slug: "test-event",
+ event_name: "Test Event",
+ channel: "SPRINT",
+ start_at: "2026-04-25T01:00:00",
+ capacity: 30,
+ },
+ labels: ["2026-04-15", "2026-04-16"],
+ datasets: [
+ { name: "Total", data: [1, 3] },
+ { name: "一般票", data: [1, 2] },
+ ],
+ start_marker_label: "2026-04-25",
+};
+
+describe("EventChart", () => {
+ it("renders a line for every dataset", () => {
+ render();
+ // Recharts renders each Line as an SVG ; assert one exists per dataset
+ // by checking the chart container rendered at all — refine this assertion
+ // once you can see the actual DOM shape ChartContainer produces.
+ expect(screen.getByRole("img", { hidden: true })).toBeTruthy();
+ });
+});
+```
+
+This is a starting sketch — Recharts' exact rendered DOM (SVG structure) should be inspected once `EventChart` exists to write a real, specific assertion (e.g. counting rendered `.recharts-line` elements equals `sample.datasets.length`) rather than the placeholder role-based check above. Do not leave a test that merely asserts the component didn't crash — assert on the *dataset count* actually rendering as lines, since that's the behavior this component exists to provide.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/components/event-chart.test.tsx`
+Expected: FAIL — `@/components/event-chart` doesn't exist yet.
+
+- [ ] **Step 3: Implement `EventChart`**
+
+```tsx
+// frontend/components/event-chart.tsx
+"use client";
+
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ReferenceLine,
+ XAxis,
+ YAxis,
+} from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
+import type { EventTimeseries } from "@/types/responses/events";
+
+interface EventChartProps {
+ timeseries: EventTimeseries;
+}
+
+export function EventChart({ timeseries }: EventChartProps) {
+ const data = timeseries.labels.map((label, index) => {
+ const point: Record = { label };
+ for (const dataset of timeseries.datasets) {
+ point[dataset.name] = dataset.data[index];
+ }
+ return point;
+ });
+
+ const config: ChartConfig = Object.fromEntries(
+ timeseries.datasets.map((dataset, index) => [
+ dataset.name,
+ { label: dataset.name, color: `var(--chart-${(index % 5) + 1})` },
+ ]),
+ );
+
+ return (
+
+
+
+
+
+ } />
+ {timeseries.event.capacity !== null && (
+
+ )}
+ {timeseries.start_marker_label !== null && (
+
+ )}
+ {timeseries.datasets.map((dataset, index) => (
+
+ ))}
+
+
+ );
+}
+```
+
+Verify this against the actual generated `components/ui/chart.tsx` from Step 1 — adjust prop names/`ChartConfig` shape if the real generated file differs from what's assumed here.
+
+- [ ] **Step 4: Storybook story** (required by `frontend/AGENTS.md` for reusable components)
+
+```tsx
+// frontend/stories/components/event-chart.stories.tsx
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { expect, within } from "storybook/test";
+import { EventChart } from "@/components/event-chart";
+
+const meta: Meta = {
+ title: "Components/EventChart",
+ component: EventChart,
+ tags: ["ai-generated"],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ timeseries: {
+ event: {
+ event_slug: "test-event",
+ event_name: "Test Event",
+ channel: "SPRINT",
+ start_at: "2026-04-25T01:00:00",
+ capacity: 30,
+ },
+ labels: ["2026-04-15", "2026-04-16", "2026-04-17"],
+ datasets: [
+ { name: "Total", data: [1, 3, 5] },
+ { name: "一般票", data: [1, 2, 3] },
+ { name: "早鳥票", data: [0, 1, 2] },
+ ],
+ start_marker_label: "2026-04-25",
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByRole("img", { hidden: true })).toBeTruthy();
+ },
+};
+```
+
+Match `stories/components/ui/button.stories.tsx`'s established pattern (`tags: ["ai-generated"]`, a smoke-check `play` function) rather than inventing a new story convention.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/components/event-chart.test.tsx`
+Expected: PASS
+
+- [ ] **Step 6: Event detail page**
+
+```tsx
+// frontend/app/events/page.tsx
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { useEffect, useState } from "react";
+import { getEventTimeseries } from "@/apis/events";
+import { EventChart } from "@/components/event-chart";
+import { useRequireAuth } from "@/hooks/use-require-auth";
+import type { EventTimeseries } from "@/types/responses/events";
+
+export default function EventDetailPage() {
+ const auth = useRequireAuth();
+ const searchParams = useSearchParams();
+ const slug = searchParams.get("slug");
+ const [timeseries, setTimeseries] = useState(null);
+
+ useEffect(() => {
+ if (auth.status !== "authenticated" || !slug) return;
+ let cancelled = false;
+ getEventTimeseries(slug).then((result) => {
+ if (!cancelled) setTimeseries(result);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [auth.status, slug]);
+
+ if (auth.status !== "authenticated") {
+ return null;
+ }
+
+ if (!slug) {
+ return
No event selected.
;
+ }
+
+ if (!timeseries) {
+ return
Loading…
;
+ }
+
+ return (
+
+
{timeseries.event.event_name}
+
+
+ );
+}
+```
+
+`useSearchParams()` in a static-export app is fine at runtime (client-side reads `window.location.search`) — this is exactly why the query-string approach was chosen over a dynamic path segment (see the spec's "Routing" section).
+
+- [ ] **Step 7: Format, lint, typecheck, build**
+
+```bash
+cd frontend
+pnpm exec prettier . --write
+pnpm lint
+pnpm exec tsc --noEmit
+pnpm build
+cd ..
+```
+
+- [ ] **Step 8: Extend the Docker integration test**
+
+```python
+# tests/test_docker_integration.py — extend test_docker_image_serves_frontend_shell
+# or add a sibling assertion
+def test_docker_image_serves_event_detail_page(api_url: str) -> None:
+ """The event-detail page (query-string based) is served at /dashboard/events."""
+ with httpx.Client(base_url=api_url, timeout=5) as client:
+ response = client.get("/dashboard/events", params={"slug": "anything"})
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+```
+
+- [ ] **Step 9: Run the full suite**
+
+Run: `uv run pytest tests/ -v` and `cd frontend && pnpm exec vitest run && cd ..`
+Expected: all PASS.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add frontend/package.json frontend/pnpm-lock.yaml frontend/components.json \
+ frontend/components/ui/chart.tsx frontend/components/event-chart.tsx \
+ frontend/app/events frontend/stories/components/event-chart.stories.tsx \
+ frontend/tests/components/event-chart.test.tsx \
+ tests/test_docker_integration.py
+git commit -m "feat(frontend): add event detail page with Recharts-based chart
+
+Co-Authored-By: Claude Sonnet 5 "
+```
+
+---
+
+### Task 9: Webhook logs page
+
+**Files:**
+- Create: `frontend/app/webhook-logs/page.tsx`
+- Modify: `tests/test_docker_integration.py`
+
+**Interfaces:**
+- Consumes: `listWebhookLogs`, `deleteWebhookLog`, `clearWebhookLogs` (Task 6).
+- Produces: `/dashboard/webhook-logs` — paginated log viewer with per-row and bulk delete.
+
+- [ ] **Step 1: Implement the page**
+
+```tsx
+// frontend/app/webhook-logs/page.tsx
+"use client";
+
+import { useEffect, useState, useTransition } from "react";
+import {
+ clearWebhookLogs,
+ deleteWebhookLog,
+ listWebhookLogs,
+} from "@/apis/webhook-logs";
+import { useRequireAuth } from "@/hooks/use-require-auth";
+import type { WebhookLogsPage } from "@/types/responses/webhook-logs";
+
+const PAGE_SIZE = 50;
+
+export default function WebhookLogsPage() {
+ const auth = useRequireAuth();
+ const [offset, setOffset] = useState(0);
+ const [page, setPage] = useState(null);
+ const [isPending, startTransition] = useTransition();
+
+ const reload = () => {
+ listWebhookLogs(PAGE_SIZE, offset).then(setPage);
+ };
+
+ useEffect(() => {
+ if (auth.status !== "authenticated") return;
+ reload();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [auth.status, offset]);
+
+ if (auth.status !== "authenticated") {
+ return null;
+ }
+
+ const handleDelete = (id: number) => {
+ startTransition(async () => {
+ await deleteWebhookLog(id);
+ reload();
+ });
+ };
+
+ const handleClearAll = () => {
+ startTransition(async () => {
+ await clearWebhookLogs();
+ reload();
+ });
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+export default function EventDetailPage() {
+ // useSearchParams() opts the page out of static prerendering unless it's
+ // wrapped in Suspense — required even for a fully client-rendered,
+ // statically-exported route like this one (see next.config.ts's
+ // `output: "export"`).
+ return (
+ Loading…
}>
+
+
+ );
+}
diff --git a/frontend/components/event-chart.tsx b/frontend/components/event-chart.tsx
new file mode 100644
index 0000000..3ba4b56
--- /dev/null
+++ b/frontend/components/event-chart.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ReferenceLine,
+ XAxis,
+ YAxis,
+} from "recharts";
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import type { EventTimeseries } from "@/types/responses/events";
+
+interface EventChartProps {
+ timeseries: EventTimeseries;
+}
+
+export function EventChart({ timeseries }: EventChartProps) {
+ // Recharts' category x-axis derives its domain purely from the plotted
+ // data, ignoring an explicit `domain` prop — so a ReferenceLine for a date
+ // outside `labels` (e.g. an upcoming event's start date) is silently
+ // discarded unless that date is itself a row in `data`. Add it as an
+ // otherwise-empty row so the axis includes it, connecting the surrounding
+ // line across the gap.
+ const labels =
+ timeseries.start_marker_label !== null &&
+ !timeseries.labels.includes(timeseries.start_marker_label)
+ ? [...timeseries.labels, timeseries.start_marker_label].sort()
+ : timeseries.labels;
+
+ const data = labels.map((label) => {
+ const point: Record = { label };
+ const index = timeseries.labels.indexOf(label);
+ if (index !== -1) {
+ for (const dataset of timeseries.datasets) {
+ point[dataset.name] = dataset.data[index];
+ }
+ }
+ return point;
+ });
+
+ const config: ChartConfig = Object.fromEntries(
+ timeseries.datasets.map((dataset, index) => [
+ dataset.name,
+ { label: dataset.name, color: `var(--chart-${(index % 5) + 1})` },
+ ]),
+ );
+
+ return (
+
+
+
+
+
+ } />
+ {timeseries.event.capacity !== null && (
+
+ )}
+ {timeseries.start_marker_label !== null && (
+
+ )}
+ {timeseries.datasets.map((dataset) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx
new file mode 100644
index 0000000..a4df405
--- /dev/null
+++ b/frontend/components/ui/card.tsx
@@ -0,0 +1,102 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+