From b93a44d4acf1efde61097b984d116c0377d02d34 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 15 Aug 2026 21:47:10 +0800 Subject: [PATCH 01/33] 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/33] 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 ( +
+
+

Webhook Logs

+ +
+ {page === null &&

Loading…

} + {page && ( + <> + + + + + + + + + + {page.items.map((item) => ( + + + + + + + ))} + +
MethodChannelCreated +
{item.method}{item.channel ?? "—"}{item.created_at} + +
+
+ + + {offset + 1}–{Math.min(offset + PAGE_SIZE, page.total)} of{" "} + {page.total} + + +
+ + )} +
+ ); +} +``` + +Per-row delete and clear-all each get their own `useTransition` call site sharing one `isPending`/`startTransition` pair here since they're mutually exclusive user actions on the same page (not two *independent* concurrent operations) — this matches the spirit of `frontend/AGENTS.md`'s "each independent async operation gets its own `useTransition`" rule without over-splitting a single page's sequential actions into unnecessary separate transitions. If reviewing this, judge whether that reading holds; split into separate `useTransition` pairs if delete and clear-all ever need to be triggerable concurrently. + +- [ ] **Step 2: Format, lint, typecheck, build** + +```bash +cd frontend +pnpm exec prettier . --write +pnpm lint +pnpm exec tsc --noEmit +pnpm build +cd .. +``` + +- [ ] **Step 3: Extend the Docker integration test** + +```python +# tests/test_docker_integration.py +def test_docker_image_serves_webhook_logs_page(api_url: str) -> None: + """The webhook-logs page is served at /dashboard/webhook-logs.""" + with httpx.Client(base_url=api_url, timeout=5) as client: + response = client.get("/dashboard/webhook-logs") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] +``` + +- [ ] **Step 4: Run the full suite** + +Run: `uv run pytest tests/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/webhook-logs tests/test_docker_integration.py +git commit -m "feat(frontend): add webhook logs page + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +### Task 10: Local-dev proxy + +**Files:** +- Modify: `frontend/next.config.ts` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `next dev` (typically `localhost:3000`) transparently proxies `/dashboard/api/*` to a locally-running backend (`localhost:8000`), so the browser only ever talks to one origin, in dev exactly as in prod. + +- [ ] **Step 1: Add dev-only rewrites** + +```typescript +// frontend/next.config.ts +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "export", + basePath: "/dashboard", + trailingSlash: true, + images: { + unoptimized: true, + }, + // `rewrites()` has no effect on `output: "export"` production builds + // (static export can't proxy at request time) — it only applies to + // `next dev`, which is exactly where it's needed: `next dev` and + // `uvicorn` run as separate processes on different ports locally, so + // without this the browser would see a cross-origin request. + async rewrites() { + return [ + { + source: "/dashboard/api/:path*", + destination: "http://localhost:8000/dashboard/api/:path*", + }, + ]; + }, +}; + +export default nextConfig; +``` + +- [ ] **Step 2: Verify manually** + +```bash +# Terminal 1 +uv run uvicorn argus.main:app --host 0.0.0.0 --port 8000 +# Terminal 2 +cd frontend && pnpm dev +``` + +Visit `http://localhost:3000/dashboard` — confirm the page loads and, once logged in, its `/dashboard/api/*` calls succeed (check the browser network tab shows requests to `localhost:3000/dashboard/api/*`, proxied server-side to `localhost:8000`, not a direct cross-origin browser request). + +- [ ] **Step 3: Format, lint, build** (confirm the dev-only `rewrites()` doesn't affect the static export build) + +```bash +cd frontend +pnpm exec prettier . --write +pnpm lint +pnpm build +cd .. +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/next.config.ts +git commit -m "feat(frontend): proxy API calls to the backend during next dev + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +### Task 11: End-to-end verification (merge gate) + +**Files:** none — verification only. + +**Interfaces:** none. + +- [ ] **Step 1: Full test suite** + +```bash +uv run ruff check src tests +uv run ruff format --check src tests +uv run pytest tests/ -v +cd frontend +pnpm exec prettier --check . +pnpm lint +pnpm exec tsc --noEmit +pnpm exec vitest run +pnpm build +cd .. +``` + +Expected: everything clean/passing. + +- [ ] **Step 2: Fresh-clone, real-container smoke test** + +```bash +docker build --tag argus-e2e-check . +docker run --rm -d --name argus-e2e \ + -p 18000:8000 \ + -e SESSION_SECRET=e2e-check-secret \ + -e WEBHOOK_SECRET=e2e-check-webhook \ + -e ALLOWED_EMAILS=e2e@example.com \ + -e DATABASE_URL=sqlite:////tmp/e2e.db \ + argus-e2e-check + +sleep 2 +curl -sf http://localhost:18000/health +curl -sf http://localhost:18000/dashboard | grep -qi "argus dashboard" && echo "home OK" +curl -sf "http://localhost:18000/dashboard/events?slug=anything" | grep -qi "html" && echo "event detail OK" +curl -sf http://localhost:18000/dashboard/webhook-logs | grep -qi "webhook logs" && echo "webhook logs OK" + +docker stop argus-e2e +docker image rm argus-e2e-check +``` + +Expected: `/health` returns 200, and each grep prints its "OK" line — confirming the real built image serves all three new/swapped pages correctly, not just that the test suite's mocked assertions pass. + +- [ ] **Step 3: Confirm nothing else regressed** + +Re-read `tests/test_kktix_handler.py`, `tests/test_report.py`, `tests/test_health.py` results from Step 1's full suite run — webhook ingestion and the Discord report feature must be untouched by anything in this plan (nothing in Tasks 1–10 touches `kktix/` or `report.py`). If the full suite passed, this is already confirmed; this step is a sanity re-read, not new test-writing. + +- [ ] **Step 4: This is the merge gate** + +Only once Steps 1–3 all pass does this branch merge to `main` (per the spec's "Sequencing & Merge Gate"). No commit is made in this task — it's a verification gate, not a code change. + +--- + +## Self-Review + +**1. Spec coverage:** +- Repository layout (Task 1) ✓, including the `AGENTS.md`/`CLAUDE.md` discoverability fix agreed on after the spec was written (not in the spec file itself — a refinement made during plan-writing; worth back-porting a note into the spec, but not blocking). +- Docker/CI/package-data (Tasks 2–3) ✓ +- `/dashboard/api/me` (Task 4) — the spec left this as "a call for whoever implements the plan"; this plan makes the call to add it, since Task 6's auth-check hook needs *some* endpoint and this is the smallest, most direct one. ✓ +- FastAPI static serving + route retirement (Task 5) ✓ +- Frontend pages + auth check (Tasks 6–9) ✓ +- Local-dev proxy (Task 10) ✓ +- Sequencing & merge gate (Task 11) ✓ +- Explicitly deferred in the spec (git history preservation, frontend test-suite CI step beyond build/lint, deleting the old `argus-dashboard` dir) — correctly not present anywhere in this plan. + +**2. Placeholder scan:** The one intentionally-loose spot is Task 8 Step 1 (shadcn CLI generates `chart.tsx` — its exact contents aren't hand-specified, by design, since it's a real generated file to be read rather than guessed) and the sketch-quality test assertions flagged explicitly as such in Tasks 4 and 8 (both call out, in their own text, exactly what needs filling in once the real API/DOM shape is visible, rather than silently leaving something vague). Every other step has literal, complete code. + +**3. Type consistency check:** +- `EventSummary`, `EventTimeseries`, `TimeseriesDataset` (Task 6) match their usage in Tasks 7–8 exactly (same field names, same nullability). +- `CurrentUser` (Task 6) matches `useRequireAuth`'s `AuthState` union and both pages' `auth.user.email` access. +- `WebhookLogsPage`/`WebhookLogEntry` (Task 6) match Task 9's usage (`page.items`, `page.total`, `item.id`/`.method`/`.channel`/`.created_at`). +- `getCurrentUser`, `listEvents`, `getEventTimeseries`, `deleteEvent`, `listWebhookLogs`, `deleteWebhookLog`, `clearWebhookLogs` — every call site in Tasks 7–9 matches the signature defined in Task 6. +- `useRequireAuth()`'s returned `AuthState` shape (`{status: "loading"|"authenticated"|"unauthenticated"}`) is destructured identically in Tasks 7, 8, 9. +- The `basePath` gotcha (Global Constraints) is applied consistently: Task 7's `Link href` and Task 8's route path both omit the `/dashboard` prefix; `useRequireAuth`'s redirect (Task 6) includes it, correctly, since it's a `window.location` navigation, not a Next `Link`. + +--- + +**Plan complete and saved to `docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** From 7bc1b714f060358ead0f089e85beed9998e32443 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 15 Aug 2026 22:11:28 +0800 Subject: [PATCH 03/33] feat: fold argus-dashboard into frontend/ Co-Authored-By: Claude Sonnet 5 --- .../skills/migrate-radix-to-base/SKILL.md | 173 + .../migrate-radix-to-base/class-mapping.md | 62 + .../migrate-radix-to-base/consumer-props.md | 58 + .../migrate-radix-to-base/disclosure.md | 353 + .../migrate-radix-to-base/display-misc.md | 410 + .../migrate-radix-to-base/form-controls.md | 390 + .../skills/migrate-radix-to-base/menus.md | 409 + .../skills/migrate-radix-to-base/overlays.md | 459 + .../universal-patterns.md | 286 + .../migrate-radix-to-base/wrapper-shapes.md | 110 + frontend/.agents/skills/shadcn/SKILL.md | 277 + .../.agents/skills/shadcn/agents/openai.yml | 5 + .../skills/shadcn/assets/shadcn-small.png | Bin 0 -> 1049 bytes .../.agents/skills/shadcn/assets/shadcn.png | Bin 0 -> 3852 bytes frontend/.agents/skills/shadcn/cli.md | 290 + .../.agents/skills/shadcn/customization.md | 209 + .../.agents/skills/shadcn/evals/evals.json | 77 + frontend/.agents/skills/shadcn/mcp.md | 105 + frontend/.agents/skills/shadcn/registry.md | 277 + .../skills/shadcn/rules/base-vs-radix.md | 306 + frontend/.agents/skills/shadcn/rules/chat.md | 224 + .../skills/shadcn/rules/composition.md | 213 + frontend/.agents/skills/shadcn/rules/forms.md | 192 + frontend/.agents/skills/shadcn/rules/icons.md | 101 + .../.agents/skills/shadcn/rules/styling.md | 185 + frontend/.gitignore | 44 + frontend/.npmrc | 1 + frontend/.prettierignore | 20 + frontend/.prettierrc.json | 22 + frontend/.storybook/main.ts | 18 + frontend/.storybook/preview.tsx | 22 + frontend/.vscode/mcp.json | 8 + frontend/.vscode/settings.json | 7 + frontend/README.md | 36 + frontend/app/favicon.ico | Bin 0 -> 25931 bytes frontend/app/globals.css | 135 + frontend/app/layout.tsx | 45 + frontend/app/page.tsx | 69 + frontend/components.json | 25 + frontend/components/ui/button.tsx | 55 + frontend/eslint.config.mjs | 22 + frontend/lib/utils.ts | 6 + frontend/next.config.ts | 11 + frontend/package.json | 53 + frontend/pnpm-lock.yaml | 8910 +++++++++++++++++ frontend/pnpm-workspace.yaml | 3 + frontend/postcss.config.mjs | 7 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/skills-lock.json | 17 + .../stories/components/ui/button.stories.tsx | 48 + frontend/tsconfig.json | 35 + frontend/vitest.config.ts | 35 + frontend/vitest.shims.d.ts | 1 + 57 files changed, 14831 insertions(+) create mode 100644 frontend/.agents/skills/migrate-radix-to-base/SKILL.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/class-mapping.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/consumer-props.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/disclosure.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/display-misc.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/form-controls.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/menus.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/overlays.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/universal-patterns.md create mode 100644 frontend/.agents/skills/migrate-radix-to-base/wrapper-shapes.md create mode 100644 frontend/.agents/skills/shadcn/SKILL.md create mode 100644 frontend/.agents/skills/shadcn/agents/openai.yml create mode 100644 frontend/.agents/skills/shadcn/assets/shadcn-small.png create mode 100644 frontend/.agents/skills/shadcn/assets/shadcn.png create mode 100644 frontend/.agents/skills/shadcn/cli.md create mode 100644 frontend/.agents/skills/shadcn/customization.md create mode 100644 frontend/.agents/skills/shadcn/evals/evals.json create mode 100644 frontend/.agents/skills/shadcn/mcp.md create mode 100644 frontend/.agents/skills/shadcn/registry.md create mode 100644 frontend/.agents/skills/shadcn/rules/base-vs-radix.md create mode 100644 frontend/.agents/skills/shadcn/rules/chat.md create mode 100644 frontend/.agents/skills/shadcn/rules/composition.md create mode 100644 frontend/.agents/skills/shadcn/rules/forms.md create mode 100644 frontend/.agents/skills/shadcn/rules/icons.md create mode 100644 frontend/.agents/skills/shadcn/rules/styling.md create mode 100644 frontend/.gitignore create mode 100644 frontend/.npmrc create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/.storybook/main.ts create mode 100644 frontend/.storybook/preview.tsx create mode 100644 frontend/.vscode/mcp.json create mode 100644 frontend/.vscode/settings.json create mode 100644 frontend/README.md create mode 100644 frontend/app/favicon.ico create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components.json create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/lib/utils.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/skills-lock.json create mode 100644 frontend/stories/components/ui/button.stories.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vitest.config.ts create mode 100644 frontend/vitest.shims.d.ts diff --git a/frontend/.agents/skills/migrate-radix-to-base/SKILL.md b/frontend/.agents/skills/migrate-radix-to-base/SKILL.md new file mode 100644 index 0000000..5eb5dc5 --- /dev/null +++ b/frontend/.agents/skills/migrate-radix-to-base/SKILL.md @@ -0,0 +1,173 @@ +--- +name: migrate-radix-to-base +description: Migrates React projects and components from Radix UI to Base UI. Use when asked to migrate from radix, move to base-ui, convert radix primitives, or switch a shadcn project's base library. Handles single components ("migrate accordion") and whole projects. +--- + +# Radix UI -> Base UI migration + +You migrate shadcn wrappers, hand-rolled radix compositions, and their +consumers to `@base-ui/react`, keeping the project buildable at every step. +Be precise; never guess a mapping. When a prop or part is not in these +reference files, check `node_modules/@base-ui/react/**/*.d.ts` before +transforming, and record gaps in the report. + +## Preflight (always) + +1. `npx shadcn@latest info --json` (or the project's runner): gives the + current base, STYLE (e.g. `radix-lyra`), tailwind version, aliases, + installed components, and package manager. Trust it over inference. +2. Detect the package manager (packageManager field / lockfile: + pnpm-lock.yaml, bun.lock, yarn.lock, package-lock.json) and use IT for + every install. Never leave a stale lockfile. +3. Require a clean git tree; work on a branch; one commit per component. +4. Baseline check BEFORE touching dependencies: run the project's + typecheck/build so pre-existing failures are never attributed to you. +5. Install `@base-ui/react` alongside radix. Radix packages are removed only + after the LAST component is migrated (both coexist fine). + +## Strategy: golden pair first, transformation engine second + +- **Golden pair via the CLI (preferred).** If the project is shadcn with a + known style (`radix- - -{% if events %} - - - - - - - - - - - - {% for ev in events %} - - - - - - - - {% endfor %} - -
NameChannelStartCapacity
{{ ev.event_name }}{{ ev.channel }}{{ ev.start_at or "—" }}{{ ev.capacity if ev.capacity is not none else "—" }} - -
-{% else %} -
No events yet.
-{% endif %} - - -{% endblock %} diff --git a/src/argus/dashboard/templates/webhook_logs.html b/src/argus/dashboard/templates/webhook_logs.html deleted file mode 100644 index 3c22103..0000000 --- a/src/argus/dashboard/templates/webhook_logs.html +++ /dev/null @@ -1,166 +0,0 @@ -{% extends "_base.html" %} -{% block title %}Webhook Logs — Argus Dashboard{% endblock %} -{% block content %} -
-

Webhook Logs ({{ total }} total)

- -
-
- - - -
-
Loading…
-
- - -{% endblock %} diff --git a/src/argus/main.py b/src/argus/main.py index a1e11d0..4b28591 100644 --- a/src/argus/main.py +++ b/src/argus/main.py @@ -1,10 +1,12 @@ from contextlib import asynccontextmanager +from pathlib import Path import logging import os from fastapi import FastAPI from fastapi.responses import RedirectResponse from starlette.middleware.sessions import SessionMiddleware +from starlette.staticfiles import StaticFiles from argus import config from argus.dashboard.router import router as dashboard_router @@ -62,6 +64,13 @@ async def lifespan(_app: FastAPI): app.include_router(dashboard_router) 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" + ) + @app.get("/", include_in_schema=False) async def root() -> RedirectResponse: diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 0708ecd..a2eeca1 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -111,6 +111,20 @@ def test_docker_image_api_flow(api_url: str) -> None: assert client.get("/dashboard/api/events").json() == [] +def test_docker_image_serves_frontend_shell(api_url: str) -> None: + """The built static frontend is served at /dashboard, same-origin. + + `/dashboard` (no trailing slash) 307-redirects to `/dashboard/` — that's + Starlette's standard `redirect_slashes` behavior for a mount whose static + export uses Next's `trailingSlash: true`, and any browser follows it + transparently, so the client here does too. + """ + with httpx.Client(base_url=api_url, timeout=5, follow_redirects=True) as client: + response = client.get("/dashboard") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def _start_postgresql(container: str, network: str) -> None: _run( [ From 19420a2a0858f5c39624e3402793ca7f4aba7098 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 15 Aug 2026 22:43:07 +0800 Subject: [PATCH 08/33] feat(frontend): add API client and session auth-check hook Co-Authored-By: Claude Sonnet 5 --- frontend/apis/auth.ts | 15 + frontend/apis/client.ts | 5 + frontend/apis/events.ts | 20 + frontend/apis/webhook-logs.ts | 20 + frontend/hooks/use-require-auth.ts | 34 ++ frontend/package.json | 26 +- frontend/pnpm-lock.yaml | 349 +++++++++++++++++- .../tests/hooks/use-require-auth.test.tsx | 39 ++ frontend/types/responses/auth.ts | 3 + frontend/types/responses/events.ts | 19 + frontend/types/responses/webhook-logs.ts | 15 + frontend/vitest.config.ts | 13 + frontend/vitest.shims.d.ts | 2 +- 13 files changed, 541 insertions(+), 19 deletions(-) create mode 100644 frontend/apis/auth.ts create mode 100644 frontend/apis/client.ts create mode 100644 frontend/apis/events.ts create mode 100644 frontend/apis/webhook-logs.ts create mode 100644 frontend/hooks/use-require-auth.ts create mode 100644 frontend/tests/hooks/use-require-auth.test.tsx create mode 100644 frontend/types/responses/auth.ts create mode 100644 frontend/types/responses/events.ts create mode 100644 frontend/types/responses/webhook-logs.ts diff --git a/frontend/apis/auth.ts b/frontend/apis/auth.ts new file mode 100644 index 0000000..3ae2ff2 --- /dev/null +++ b/frontend/apis/auth.ts @@ -0,0 +1,15 @@ +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; + } +} diff --git a/frontend/apis/client.ts b/frontend/apis/client.ts new file mode 100644 index 0000000..f69d290 --- /dev/null +++ b/frontend/apis/client.ts @@ -0,0 +1,5 @@ +import axios from "axios"; + +export const apiClient = axios.create({ + baseURL: "/dashboard/api", +}); diff --git a/frontend/apis/events.ts b/frontend/apis/events.ts new file mode 100644 index 0000000..b6565b5 --- /dev/null +++ b/frontend/apis/events.ts @@ -0,0 +1,20 @@ +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)}`); +} diff --git a/frontend/apis/webhook-logs.ts b/frontend/apis/webhook-logs.ts new file mode 100644 index 0000000..03a565b --- /dev/null +++ b/frontend/apis/webhook-logs.ts @@ -0,0 +1,20 @@ +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"); +} diff --git a/frontend/hooks/use-require-auth.ts b/frontend/hooks/use-require-auth.ts new file mode 100644 index 0000000..388e739 --- /dev/null +++ b/frontend/hooks/use-require-auth.ts @@ -0,0 +1,34 @@ +"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; +} diff --git a/frontend/package.json b/frontend/package.json index adc969a..24b1ad3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,30 +24,32 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@chromatic-com/storybook": "latest", + "@storybook/addon-a11y": "^10.5.8", + "@storybook/addon-docs": "^10.5.8", + "@storybook/addon-mcp": "latest", + "@storybook/addon-vitest": "^10.5.8", + "@storybook/nextjs-vite": "^10.5.8", "@tailwindcss/postcss": "^4", + "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/browser-playwright": "latest", + "@vitest/coverage-v8": "latest", "eslint": "^9", "eslint-config-next": "16.3.1", + "eslint-plugin-storybook": "^10.5.8", + "jsdom": "^30.0.1", + "playwright": "latest", "prettier": "3.9.6", "prettier-plugin-tailwindcss": "^0.8.1", + "storybook": "^10.5.8", "tailwindcss": "^4", "typescript": "^5", - "storybook": "^10.5.8", - "@storybook/nextjs-vite": "^10.5.8", - "@chromatic-com/storybook": "latest", - "@storybook/addon-vitest": "^10.5.8", - "@storybook/addon-a11y": "^10.5.8", - "@storybook/addon-docs": "^10.5.8", - "@storybook/addon-mcp": "latest", "vite": "latest", - "eslint-plugin-storybook": "^10.5.8", - "vitest": "latest", - "playwright": "latest", - "@vitest/browser-playwright": "latest", - "@vitest/coverage-v8": "latest" + "vitest": "latest" }, "packageManager": "pnpm@10.33.0" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d8c7a1e..2805156 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.3 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.9.6) @@ -90,6 +93,9 @@ importers: eslint-plugin-storybook: specifier: ^10.5.8 version: 10.5.8(eslint@9.39.5(jiti@2.7.0))(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 playwright: specifier: latest version: 1.62.1 @@ -113,7 +119,7 @@ importers: version: 8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0) vitest: specifier: latest - version: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) packages: @@ -124,6 +130,14 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -291,12 +305,52 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chromatic-com/storybook@5.3.0': resolution: {integrity: sha512-gMAfVYJnF/tjdY8C3Q54KYz/low2NFjPIZMpCqSFuMUiTJ2DNWCTb7mFhuuzZ1iduWFDhewlJyilFmvKcEDYRA==} engines: {node: '>=20.0.0', yarn: '>=1.22.18'} peerDependencies: storybook: ^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0 + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -531,6 +585,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -1457,6 +1520,21 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.6.4': resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} engines: {node: '>=12', npm: '>=6'} @@ -1963,6 +2041,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -2136,6 +2217,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2150,6 +2235,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2183,6 +2272,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2300,6 +2392,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2761,6 +2857,10 @@ packages: resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2933,6 +3033,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3046,6 +3149,15 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3322,6 +3434,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} @@ -3579,6 +3694,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3888,6 +4006,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4117,6 +4239,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.33.1: resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==} engines: {node: '>=10.0.0'} @@ -4159,6 +4284,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + tmcp@1.20.0: resolution: {integrity: sha512-dcDximKQBGqLP/aEAVA26HcWRHhKipstg1w0fbDDJ0HcFZ6lolLU1YYmStYEn/73f534i7WrDNcjRfRYdV9CoA==} @@ -4174,6 +4306,14 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -4258,6 +4398,10 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -4412,9 +4556,29 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4473,6 +4637,13 @@ packages: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4511,6 +4682,21 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4726,6 +4912,10 @@ snapshots: '@blazediff/core@1.9.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chromatic-com/storybook@5.3.0(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: '@neoconfetti/react': 1.0.0 @@ -4738,6 +4928,30 @@ snapshots: - '@chromatic-com/playwright' - '@chromatic-com/vitest' + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -4936,6 +5150,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -5445,7 +5661,7 @@ snapshots: '@vitest/browser': 4.1.10(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0))(vitest@4.1.10) '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0))(vitest@4.1.10) '@vitest/runner': 4.1.10 - vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) + vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) transitivePeerDependencies: - react @@ -5651,6 +5867,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -5924,7 +6150,7 @@ snapshots: '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) playwright: 1.62.1 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) + vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) transitivePeerDependencies: - bufferutil - msw @@ -5940,7 +6166,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) + vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -5960,7 +6186,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) + vitest: 4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) optionalDependencies: '@vitest/browser': 4.1.10(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0))(vitest@4.1.10) @@ -6199,6 +6425,10 @@ snapshots: baseline-browser-mapping@2.11.14: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -6365,6 +6595,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -6373,6 +6608,13 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6403,6 +6645,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.7.2: {} deep-eql@5.0.2: {} @@ -6490,6 +6734,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -7168,6 +7414,12 @@ snapshots: hono@4.13.2: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -7317,6 +7569,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -7418,6 +7672,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -7628,6 +7908,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -7930,6 +8212,10 @@ snapshots: parse-statements@1.0.11: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -8209,6 +8495,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -8540,6 +8830,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + systeminformation@5.33.1: {} tailwind-merge@3.6.0: {} @@ -8565,6 +8857,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + tmcp@1.20.0(typescript@5.9.3): dependencies: '@standard-schema/spec': 1.1.0 @@ -8583,6 +8881,14 @@ snapshots: totalist@3.0.1: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8682,6 +8988,8 @@ snapshots: undici@7.29.0: {} + undici@8.10.0: {} + unicorn-magic@0.3.0: {} universalify@2.0.1: {} @@ -8787,7 +9095,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)): + vitest@4.1.10(@types/node@20.19.43)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)) @@ -8813,11 +9121,36 @@ snapshots: '@types/node': 20.19.43 '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0))(vitest@4.1.10) '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + jsdom: 30.0.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -8887,6 +9220,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/frontend/tests/hooks/use-require-auth.test.tsx b/frontend/tests/hooks/use-require-auth.test.tsx new file mode 100644 index 0000000..eab7c93 --- /dev/null +++ b/frontend/tests/hooks/use-require-auth.test.tsx @@ -0,0 +1,39 @@ +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; + // @ts-expect-error -- window.location's setter type is narrower than Location in this TS/lib config + 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); + // @ts-expect-error -- window.location's setter type is narrower than Location in this TS/lib config + window.location = { href: "" } as Location; + + renderHook(() => useRequireAuth()); + + await waitFor(() => expect(window.location.href).toBe("/dashboard/login")); + }); +}); diff --git a/frontend/types/responses/auth.ts b/frontend/types/responses/auth.ts new file mode 100644 index 0000000..da2be8c --- /dev/null +++ b/frontend/types/responses/auth.ts @@ -0,0 +1,3 @@ +export interface CurrentUser { + email: string; +} diff --git a/frontend/types/responses/events.ts b/frontend/types/responses/events.ts new file mode 100644 index 0000000..79c6a85 --- /dev/null +++ b/frontend/types/responses/events.ts @@ -0,0 +1,19 @@ +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; +} diff --git a/frontend/types/responses/webhook-logs.ts b/frontend/types/responses/webhook-logs.ts new file mode 100644 index 0000000..c5d8a40 --- /dev/null +++ b/frontend/types/responses/webhook-logs.ts @@ -0,0 +1,15 @@ +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; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 2c8c88d..14de21c 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -11,6 +11,11 @@ const dirname = // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(dirname, "./"), + }, + }, test: { projects: [ { @@ -30,6 +35,14 @@ export default defineConfig({ }, }, }, + { + extends: true, + test: { + name: "unit", + environment: "jsdom", + include: ["tests/**/*.test.{ts,tsx}"], + }, + }, ], }, }); diff --git a/frontend/vitest.shims.d.ts b/frontend/vitest.shims.d.ts index 7782f28..03b1801 100644 --- a/frontend/vitest.shims.d.ts +++ b/frontend/vitest.shims.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +/// From 3bb160dd18d4522eea9409e135caf5c7ff94edb6 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 15 Aug 2026 22:50:57 +0800 Subject: [PATCH 09/33] feat(frontend): build the real event-list home page Co-Authored-By: Claude Sonnet 5 --- frontend/app/layout.tsx | 4 +- frontend/app/page.tsx | 109 +++++++++++++++++----------------------- 2 files changed, 47 insertions(+), 66 deletions(-) diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 60c8751..0caeb5c 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -21,8 +21,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Argus Dashboard", + description: "Registration analytics dashboard for Argus", }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d40116c..b8a3ab5 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,69 +1,50 @@ -import Image from "next/image"; +"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; + } -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - +

+
- -
-
+ {event.event_name} + + + ))} + + ); } From c28f882d3d33219b8b28cc4eeb83213e9a6158a6 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 15 Aug 2026 23:06:08 +0800 Subject: [PATCH 10/33] feat(frontend): add event detail page with Recharts-based chart Co-Authored-By: Claude Sonnet 5 --- frontend/app/events/page.tsx | 57 +++ frontend/components/event-chart.tsx | 89 +++++ frontend/components/ui/card.tsx | 102 +++++ frontend/components/ui/chart.tsx | 372 ++++++++++++++++++ frontend/eslint.config.mjs | 5 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 288 ++++++++++++++ .../components/event-chart.stories.tsx | 40 ++ .../tests/components/event-chart.test.tsx | 52 +++ tests/test_docker_integration.py | 13 + 10 files changed, 1016 insertions(+), 3 deletions(-) create mode 100644 frontend/app/events/page.tsx create mode 100644 frontend/components/event-chart.tsx create mode 100644 frontend/components/ui/card.tsx create mode 100644 frontend/components/ui/chart.tsx create mode 100644 frontend/stories/components/event-chart.stories.tsx create mode 100644 frontend/tests/components/event-chart.test.tsx diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx new file mode 100644 index 0000000..e6b75ff --- /dev/null +++ b/frontend/app/events/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense, 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"; + +function EventDetailContent() { + 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}

+ +
+ ); +} + +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 ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(5)] *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/frontend/components/ui/chart.tsx b/frontend/components/ui/chart.tsx new file mode 100644 index 0000000..b73118d --- /dev/null +++ b/frontend/components/ui/chart.tsx @@ -0,0 +1,372 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; +import type { TooltipValueType } from "recharts"; +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + initialDimension?: { + width: number; + height: number; + }; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( + - - -
- -
{{ user_email }} · Logout
-
-
- {% block content %}{% endblock %} -
- - diff --git a/src/argus/dashboard/templates/event.html b/src/argus/dashboard/templates/event.html deleted file mode 100644 index c920662..0000000 --- a/src/argus/dashboard/templates/event.html +++ /dev/null @@ -1,130 +0,0 @@ -{% extends "_base.html" %} -{% block title %}{{ event_name }} — Argus Dashboard{% endblock %} -{% block content %} -← All events -
-

🎟️ {{ event_name }}

- -
-
- Channel: {{ channel }} - {% if start_at %}Start: {{ start_at }}{% endif %} - {% if capacity is not none %}Capacity: {{ capacity }}{% endif %} -
- - -
- -
- - - - -{% endblock %} diff --git a/tests/test_auth.py b/tests/test_auth.py index 24ade46..8e35701 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -134,108 +134,3 @@ async def test_api_me_returns_authenticated_email(dashboard_app, monkeypatch): assert me.json() == {"email": email} finally: auth.reset_oauth() - - -@pytest.mark.asyncio -async def test_dashboard_event_serves_static_file_when_slug_matches_one( - dashboard_app, monkeypatch, tmp_path -): - """Next's static-export payload files (e.g. index.txt) live under the same - /dashboard/events/* prefix as the legacy {slug} route. When the requested - "slug" actually corresponds to a real file on disk, it must be served - directly instead of being treated as an event-slug DB lookup. - """ - static_dir = tmp_path / "events" - static_dir.mkdir() - (static_dir / "index.txt").write_text("1:HL_JS\n") - monkeypatch.setattr(router, "_EVENTS_STATIC_DIR", static_dir) - - transport = httpx.ASGITransport(app=dashboard_app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - response = await client.get( - "/dashboard/events/index.txt", follow_redirects=False - ) - - assert response.status_code == 200 - assert response.text == "1:HL_JS\n" - - -@pytest.mark.asyncio -async def test_dashboard_event_still_renders_for_real_event_slug( - dashboard_app, monkeypatch, tmp_path -): - """The legacy Jinja2 per-event page still works end-to-end for an - authenticated user when the slug does NOT correspond to a static file. - """ - # Ensure _EVENTS_STATIC_DIR points at an existing-but-empty directory, so - # `.is_file()` reliably returns False without depending on a real build. - static_dir = tmp_path / "events" - static_dir.mkdir() - monkeypatch.setattr(router, "_EVENTS_STATIC_DIR", static_dir) - monkeypatch.setattr( - router.queries, - "get_event", - lambda slug: { - "event_slug": slug, - "event_name": "Test Event", - "channel": "SPRINT", - "start_at": None, - "capacity": None, - }, - ) - - email = "chester@example.com" - with run_server_in_thread( - user_claims=[User(sub=email, claims={"email": email})] - ) as server: - provider_url = f"http://localhost:{server.server_port}" - monkeypatch.setattr( - auth, - "_GOOGLE_SERVER_METADATA_URL", - f"{provider_url}/.well-known/openid-configuration", - ) - auth.reset_oauth() - - try: - transport = httpx.ASGITransport(app=dashboard_app) - async with httpx.AsyncClient( - transport=transport, base_url="http://test" - ) as client: - login = await client.get("/dashboard/login", follow_redirects=False) - - async with httpx.AsyncClient() as provider_client: - authorized = await provider_client.post( - login.headers["location"], data={"sub": email} - ) - - callback = urlsplit(authorized.headers["location"]) - await client.get( - f"{callback.path}?{callback.query}", follow_redirects=False - ) - - response = await client.get("/dashboard/events/test-event") - assert response.status_code == 200 - assert "Test Event" in response.text - finally: - auth.reset_oauth() - - -@pytest.mark.asyncio -async def test_dashboard_event_redirects_unauthenticated_request_to_login( - dashboard_app, monkeypatch, tmp_path -): - """An unauthenticated request to a real (non-static-file) event slug still - redirects to the login page — existing legacy-route behavior. - """ - static_dir = tmp_path / "events" - static_dir.mkdir() - monkeypatch.setattr(router, "_EVENTS_STATIC_DIR", static_dir) - - transport = httpx.ASGITransport(app=dashboard_app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - response = await client.get( - "/dashboard/events/test-event", follow_redirects=False - ) - - assert response.status_code == 302 - assert response.headers["location"] == "/dashboard/login" From 8de4c364d4052c192139e8fca619d0f65540e462 Mon Sep 17 00:00:00 2001 From: 5x Date: Thu, 27 Aug 2026 21:58:10 +0800 Subject: [PATCH 28/33] fix(frontend): stack webhook log rows into cards below tablet width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid row (ID/Created/Method/Channel/Body/Delete) doesn't fit a narrow viewport — Channel, Body, and Delete were getting clipped by the container's overflow-hidden. Below the tablet breakpoint (640px), each row now renders as a labeled, vertically-stacked card instead; the column header row is hidden at that width since it no longer applies. The existing grid layout is unchanged from tablet: up. Both layouts render in the DOM simultaneously, toggled via tablet:hidden / hidden tablet:grid — the hidden one is display:none, so it's excluded from the accessibility tree and tab order, and the shared Collapsible state keeps them in sync regardless of which is visible. Co-Authored-By: Claude Sonnet 5 --- frontend/app/webhook-logs/page.tsx | 63 +++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/frontend/app/webhook-logs/page.tsx b/frontend/app/webhook-logs/page.tsx index 7f78167..eb1b958 100644 --- a/frontend/app/webhook-logs/page.tsx +++ b/frontend/app/webhook-logs/page.tsx @@ -149,7 +149,7 @@ export default function WebhookLogsPage() { {page && page.total > 0 && ( <>
-
+
ID Created @@ -167,7 +167,66 @@ export default function WebhookLogsPage() { onOpenChange={() => toggleOpen(item.id)} className="border-b border-border last:border-b-0" > -
+ {/* Below `tablet:`, columns don't fit a single row — stack + each field as its own labeled line instead. */} +
+
+ + + + #{item.id} + + + + {item.method} + +
+
+ + Created + + + {formatTaipeiDateTime(item.created_at)} + +
+
+ + Channel + + + {item.channel ?? "—"} + +
+
+ + Body + + + {summarizeBody(item.body)} + +
+ +
+
Date: Thu, 27 Aug 2026 22:08:05 +0800 Subject: [PATCH 29/33] fix(frontend): wrap webhook log Body summary instead of truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces truncate (single-line ellipsis) with break-words so the full summary text (" · ") is visible across multiple lines instead of getting cut off with "...". Applies to both the tablet+ grid row and the mobile stacked card. min-w-0 is required alongside break-words on both — without it, the flex/grid item's default min-width: auto would let the intrinsic content width push past its track/container before wrapping ever kicks in. Co-Authored-By: Claude Sonnet 5 --- frontend/app/webhook-logs/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/webhook-logs/page.tsx b/frontend/app/webhook-logs/page.tsx index eb1b958..65ea046 100644 --- a/frontend/app/webhook-logs/page.tsx +++ b/frontend/app/webhook-logs/page.tsx @@ -211,7 +211,7 @@ export default function WebhookLogsPage() { Body - + {summarizeBody(item.body)}
@@ -250,7 +250,7 @@ export default function WebhookLogsPage() { {item.channel ?? "—"} - + {summarizeBody(item.body)}
-
- +
+ Created - + {formatTaipeiDateTime(item.created_at)}
-
- +
+ Channel - + {item.channel ?? "—"}
-
- +
+ Body - + {summarizeBody(item.body)}
From 32ad9562d936de5948cf3f55e686391a7fd77b80 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 5 Sep 2026 18:33:21 +0800 Subject: [PATCH 31/33] ci: add frontend LCOV coverage, uploaded to Codecov with a frontend flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov's project-coverage number only ever reflected the backend (coverage.xml) — the Frontend CI job ran vitest without coverage at all, so a PR adding a lot of untested frontend code (as this one does) wouldn't show it. - frontend/vitest.config.ts: enable v8 coverage (text + lcov reporters), scoped via `include` to the real source directories (apis/, app/, components/, configurations/, hooks/, lib/) and excluding components/ui/** (shadcn-generated primitives, not hand-authored) and *.d.ts. Vitest 4's v8 provider reports every included file — even ones no test imports — as 0%, so an untested page or component shows up in the report instead of being silently absent from it, matching how the backend's `pytest --cov=argus` already surfaces untested modules. - frontend/package.json: new `test:coverage` script (`vitest run --project unit --coverage`); `test` is left as-is for fast local iteration. - .github/workflows/ci.yml: the Frontend job now runs `test:coverage` and uploads frontend/coverage/lcov.info to Codecov with the `frontend` flag, alongside the existing unflagged backend upload. - frontend/eslint.config.mjs: ignore the generated coverage/ directory (was already gitignored, but lint was walking it directly). Verified locally: `pnpm test:coverage` produces frontend/coverage/lcov.info covering all 16 real source files (event-chart.tsx, lib/datetime.ts, and hooks/use-require-auth.ts show real per-line hits from their existing tests; untested pages/components correctly show 0%, not absent). Full lint/tsc/vitest(unit+storybook)/build suite still green. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 8 +++++++- frontend/eslint.config.mjs | 1 + frontend/package.json | 1 + frontend/vitest.config.ts | 22 ++++++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 022e52b..89afb5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,13 @@ jobs: # deliberately left as a local-only check for now. - name: Test working-directory: frontend - run: pnpm test + run: pnpm test:coverage + + - name: Upload coverage report + uses: codecov/codecov-action@v7 + with: + files: frontend/coverage/lcov.info + flags: frontend - name: Build working-directory: frontend diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 4600d10..8c38c2a 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -14,6 +14,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "coverage/**", ]), ...storybook.configs["flat/recommended"], ]); diff --git a/frontend/package.json b/frontend/package.json index 78b1d15..3782d98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run --project unit", + "test:coverage": "vitest run --project unit --coverage", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 14de21c..1317361 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -17,6 +17,28 @@ export default defineConfig({ }, }, test: { + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + // Vitest 4's v8 provider reports every file matched by `include` (as + // 0% if untested), not just files a test happened to import — so an + // untested page/component still shows up instead of being silently + // absent from the report. + include: [ + "apis/**/*.{ts,tsx}", + "app/**/*.{ts,tsx}", + "components/**/*.{ts,tsx}", + "configurations/**/*.{ts,tsx}", + "hooks/**/*.{ts,tsx}", + "lib/**/*.{ts,tsx}", + ], + exclude: [ + // Generated via `pnpm dlx shadcn@latest add`, not hand-authored — + // same reasoning as excluding a vendored dependency. + "components/ui/**", + "**/*.d.ts", + ], + }, projects: [ { extends: true, From 2f81ac23a696a8e1268552da160a69d5a3a5f5b9 Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 5 Sep 2026 22:23:11 +0800 Subject: [PATCH 32/33] ci: run corepack enable after actions/setup-node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit corepack writes its pnpm/yarn shims next to whichever node is first on PATH at the time it runs. Enabling it before setup-node targeted whatever Node the runner image happened to have preinstalled, not the version setup-node switches PATH to — it only worked here because the runner's default Node already matched the requested version 22. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89afb5e..91a2dfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,14 +46,14 @@ jobs: - 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: Enable corepack + run: corepack enable + - name: Install dependencies working-directory: frontend run: pnpm install --frozen-lockfile From 46b126c6d854de29b22650bb719bd65a48007efe Mon Sep 17 00:00:00 2001 From: 5x Date: Sat, 5 Sep 2026 23:01:09 +0800 Subject: [PATCH 33/33] chore: drop docs/superpowers and the finished migrate-radix-to-base skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on PR #18: - rockleona: docs/superpowers (the design spec + implementation plan for this monorepo migration) is done serving its purpose now that the migration has landed — remove it, and gitignore the directory so future superpowers-driven work doesn't recommit specs/plans here by default once they've shipped. - rockleona: frontend/.agents/skills/migrate-radix-to-base was only ever needed for this repo's one-time Radix→Base UI migration, which is done. Removed and gitignored. frontend/.agents/skills/shadcn stays tracked — chestercheng's comment on it was "not sure we need all of it, but okay to tune afterward", not a removal request, and it's still actively useful for any future shadcn/ui component work in this repo. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 + ...026-08-15-frontend-monorepo-integration.md | 1466 ----------------- ...15-frontend-monorepo-integration-design.md | 204 --- .../skills/migrate-radix-to-base/SKILL.md | 173 -- .../migrate-radix-to-base/class-mapping.md | 62 - .../migrate-radix-to-base/consumer-props.md | 58 - .../migrate-radix-to-base/disclosure.md | 353 ---- .../migrate-radix-to-base/display-misc.md | 410 ----- .../migrate-radix-to-base/form-controls.md | 390 ----- .../skills/migrate-radix-to-base/menus.md | 409 ----- .../skills/migrate-radix-to-base/overlays.md | 459 ------ .../universal-patterns.md | 286 ---- .../migrate-radix-to-base/wrapper-shapes.md | 110 -- frontend/.gitignore | 3 + 14 files changed, 7 insertions(+), 4380 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md delete mode 100644 docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/SKILL.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/class-mapping.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/consumer-props.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/disclosure.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/display-misc.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/form-controls.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/menus.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/overlays.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/universal-patterns.md delete mode 100644 frontend/.agents/skills/migrate-radix-to-base/wrapper-shapes.md diff --git a/.gitignore b/.gitignore index caffc4a..1c79f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ src/argus/dashboard/frontend/ # Superpowers scratch workspaces (SDD ledgers, brainstorm mockups) — local only .superpowers/ + +# Superpowers specs/plans — useful during active design/implementation, +# not worth keeping once the work they describe has landed +docs/superpowers/ diff --git a/docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md b/docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md deleted file mode 100644 index d6c5a39..0000000 --- a/docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md +++ /dev/null @@ -1,1466 +0,0 @@ -# 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 ( -
-
-

Webhook Logs

- -
- {page === null &&

Loading…

} - {page && ( - <> - - - - - - - - - - {page.items.map((item) => ( - - - - - - - ))} - -
MethodChannelCreated -
{item.method}{item.channel ?? "—"}{item.created_at} - -
-
- - - {offset + 1}–{Math.min(offset + PAGE_SIZE, page.total)} of{" "} - {page.total} - - -
- - )} -
- ); -} -``` - -Per-row delete and clear-all each get their own `useTransition` call site sharing one `isPending`/`startTransition` pair here since they're mutually exclusive user actions on the same page (not two *independent* concurrent operations) — this matches the spirit of `frontend/AGENTS.md`'s "each independent async operation gets its own `useTransition`" rule without over-splitting a single page's sequential actions into unnecessary separate transitions. If reviewing this, judge whether that reading holds; split into separate `useTransition` pairs if delete and clear-all ever need to be triggerable concurrently. - -- [ ] **Step 2: Format, lint, typecheck, build** - -```bash -cd frontend -pnpm exec prettier . --write -pnpm lint -pnpm exec tsc --noEmit -pnpm build -cd .. -``` - -- [ ] **Step 3: Extend the Docker integration test** - -```python -# tests/test_docker_integration.py -def test_docker_image_serves_webhook_logs_page(api_url: str) -> None: - """The webhook-logs page is served at /dashboard/webhook-logs.""" - with httpx.Client(base_url=api_url, timeout=5) as client: - response = client.get("/dashboard/webhook-logs") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] -``` - -- [ ] **Step 4: Run the full suite** - -Run: `uv run pytest tests/ -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add frontend/app/webhook-logs tests/test_docker_integration.py -git commit -m "feat(frontend): add webhook logs page - -Co-Authored-By: Claude Sonnet 5 " -``` - ---- - -### Task 10: Local-dev proxy - -**Files:** -- Modify: `frontend/next.config.ts` - -**Interfaces:** -- Consumes: nothing new. -- Produces: `next dev` (typically `localhost:3000`) transparently proxies `/dashboard/api/*` to a locally-running backend (`localhost:8000`), so the browser only ever talks to one origin, in dev exactly as in prod. - -- [ ] **Step 1: Add dev-only rewrites** - -```typescript -// frontend/next.config.ts -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - output: "export", - basePath: "/dashboard", - trailingSlash: true, - images: { - unoptimized: true, - }, - // `rewrites()` has no effect on `output: "export"` production builds - // (static export can't proxy at request time) — it only applies to - // `next dev`, which is exactly where it's needed: `next dev` and - // `uvicorn` run as separate processes on different ports locally, so - // without this the browser would see a cross-origin request. - async rewrites() { - return [ - { - source: "/dashboard/api/:path*", - destination: "http://localhost:8000/dashboard/api/:path*", - }, - ]; - }, -}; - -export default nextConfig; -``` - -- [ ] **Step 2: Verify manually** - -```bash -# Terminal 1 -uv run uvicorn argus.main:app --host 0.0.0.0 --port 8000 -# Terminal 2 -cd frontend && pnpm dev -``` - -Visit `http://localhost:3000/dashboard` — confirm the page loads and, once logged in, its `/dashboard/api/*` calls succeed (check the browser network tab shows requests to `localhost:3000/dashboard/api/*`, proxied server-side to `localhost:8000`, not a direct cross-origin browser request). - -- [ ] **Step 3: Format, lint, build** (confirm the dev-only `rewrites()` doesn't affect the static export build) - -```bash -cd frontend -pnpm exec prettier . --write -pnpm lint -pnpm build -cd .. -``` - -- [ ] **Step 4: Commit** - -```bash -git add frontend/next.config.ts -git commit -m "feat(frontend): proxy API calls to the backend during next dev - -Co-Authored-By: Claude Sonnet 5 " -``` - ---- - -### Task 11: End-to-end verification (merge gate) - -**Files:** none — verification only. - -**Interfaces:** none. - -- [ ] **Step 1: Full test suite** - -```bash -uv run ruff check src tests -uv run ruff format --check src tests -uv run pytest tests/ -v -cd frontend -pnpm exec prettier --check . -pnpm lint -pnpm exec tsc --noEmit -pnpm exec vitest run -pnpm build -cd .. -``` - -Expected: everything clean/passing. - -- [ ] **Step 2: Fresh-clone, real-container smoke test** - -```bash -docker build --tag argus-e2e-check . -docker run --rm -d --name argus-e2e \ - -p 18000:8000 \ - -e SESSION_SECRET=e2e-check-secret \ - -e WEBHOOK_SECRET=e2e-check-webhook \ - -e ALLOWED_EMAILS=e2e@example.com \ - -e DATABASE_URL=sqlite:////tmp/e2e.db \ - argus-e2e-check - -sleep 2 -curl -sf http://localhost:18000/health -curl -sf http://localhost:18000/dashboard | grep -qi "argus dashboard" && echo "home OK" -curl -sf "http://localhost:18000/dashboard/events?slug=anything" | grep -qi "html" && echo "event detail OK" -curl -sf http://localhost:18000/dashboard/webhook-logs | grep -qi "webhook logs" && echo "webhook logs OK" - -docker stop argus-e2e -docker image rm argus-e2e-check -``` - -Expected: `/health` returns 200, and each grep prints its "OK" line — confirming the real built image serves all three new/swapped pages correctly, not just that the test suite's mocked assertions pass. - -- [ ] **Step 3: Confirm nothing else regressed** - -Re-read `tests/test_kktix_handler.py`, `tests/test_report.py`, `tests/test_health.py` results from Step 1's full suite run — webhook ingestion and the Discord report feature must be untouched by anything in this plan (nothing in Tasks 1–10 touches `kktix/` or `report.py`). If the full suite passed, this is already confirmed; this step is a sanity re-read, not new test-writing. - -- [ ] **Step 4: This is the merge gate** - -Only once Steps 1–3 all pass does this branch merge to `main` (per the spec's "Sequencing & Merge Gate"). No commit is made in this task — it's a verification gate, not a code change. - ---- - -## Self-Review - -**1. Spec coverage:** -- Repository layout (Task 1) ✓, including the `AGENTS.md`/`CLAUDE.md` discoverability fix agreed on after the spec was written (not in the spec file itself — a refinement made during plan-writing; worth back-porting a note into the spec, but not blocking). -- Docker/CI/package-data (Tasks 2–3) ✓ -- `/dashboard/api/me` (Task 4) — the spec left this as "a call for whoever implements the plan"; this plan makes the call to add it, since Task 6's auth-check hook needs *some* endpoint and this is the smallest, most direct one. ✓ -- FastAPI static serving + route retirement (Task 5) ✓ -- Frontend pages + auth check (Tasks 6–9) ✓ -- Local-dev proxy (Task 10) ✓ -- Sequencing & merge gate (Task 11) ✓ -- Explicitly deferred in the spec (git history preservation, frontend test-suite CI step beyond build/lint, deleting the old `argus-dashboard` dir) — correctly not present anywhere in this plan. - -**2. Placeholder scan:** The one intentionally-loose spot is Task 8 Step 1 (shadcn CLI generates `chart.tsx` — its exact contents aren't hand-specified, by design, since it's a real generated file to be read rather than guessed) and the sketch-quality test assertions flagged explicitly as such in Tasks 4 and 8 (both call out, in their own text, exactly what needs filling in once the real API/DOM shape is visible, rather than silently leaving something vague). Every other step has literal, complete code. - -**3. Type consistency check:** -- `EventSummary`, `EventTimeseries`, `TimeseriesDataset` (Task 6) match their usage in Tasks 7–8 exactly (same field names, same nullability). -- `CurrentUser` (Task 6) matches `useRequireAuth`'s `AuthState` union and both pages' `auth.user.email` access. -- `WebhookLogsPage`/`WebhookLogEntry` (Task 6) match Task 9's usage (`page.items`, `page.total`, `item.id`/`.method`/`.channel`/`.created_at`). -- `getCurrentUser`, `listEvents`, `getEventTimeseries`, `deleteEvent`, `listWebhookLogs`, `deleteWebhookLog`, `clearWebhookLogs` — every call site in Tasks 7–9 matches the signature defined in Task 6. -- `useRequireAuth()`'s returned `AuthState` shape (`{status: "loading"|"authenticated"|"unauthenticated"}`) is destructured identically in Tasks 7, 8, 9. -- The `basePath` gotcha (Global Constraints) is applied consistently: Task 7's `Link href` and Task 8's route path both omit the `/dashboard` prefix; `useRequireAuth`'s redirect (Task 6) includes it, correctly, since it's a `window.location` navigation, not a Next `Link`. - ---- - -**Plan complete and saved to `docs/superpowers/plans/2026-08-15-frontend-monorepo-integration.md`. Two execution options:** - -**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints - -**Which approach?** 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 deleted file mode 100644 index c5aa566..0000000 --- a/docs/superpowers/specs/2026-08-15-frontend-monorepo-integration-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# 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. diff --git a/frontend/.agents/skills/migrate-radix-to-base/SKILL.md b/frontend/.agents/skills/migrate-radix-to-base/SKILL.md deleted file mode 100644 index 5eb5dc5..0000000 --- a/frontend/.agents/skills/migrate-radix-to-base/SKILL.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -name: migrate-radix-to-base -description: Migrates React projects and components from Radix UI to Base UI. Use when asked to migrate from radix, move to base-ui, convert radix primitives, or switch a shadcn project's base library. Handles single components ("migrate accordion") and whole projects. ---- - -# Radix UI -> Base UI migration - -You migrate shadcn wrappers, hand-rolled radix compositions, and their -consumers to `@base-ui/react`, keeping the project buildable at every step. -Be precise; never guess a mapping. When a prop or part is not in these -reference files, check `node_modules/@base-ui/react/**/*.d.ts` before -transforming, and record gaps in the report. - -## Preflight (always) - -1. `npx shadcn@latest info --json` (or the project's runner): gives the - current base, STYLE (e.g. `radix-lyra`), tailwind version, aliases, - installed components, and package manager. Trust it over inference. -2. Detect the package manager (packageManager field / lockfile: - pnpm-lock.yaml, bun.lock, yarn.lock, package-lock.json) and use IT for - every install. Never leave a stale lockfile. -3. Require a clean git tree; work on a branch; one commit per component. -4. Baseline check BEFORE touching dependencies: run the project's - typecheck/build so pre-existing failures are never attributed to you. -5. Install `@base-ui/react` alongside radix. Radix packages are removed only - after the LAST component is migrated (both coexist fine). - -## Strategy: golden pair first, transformation engine second - -- **Golden pair via the CLI (preferred).** If the project is shadcn with a - known style (`radix-