# M09 — UI: IPs, History, Dashboard > Fresh Claude Code agent prompt. M08 must be complete and committed. > Estimated effort: medium. ## Mission Build the three core read-only UI pages: Dashboard, IPs list (search/filter/paginate), and IP Detail (enrichment placeholder, scores per category, history timeline). Add the corresponding API admin endpoints for the data: an IP search endpoint, an IP detail endpoint, and a dashboard stats endpoint. **Read-only this milestone** — no manual block buttons or tokens UI yet (M10). ## Before you start 1. Verify M08: ```bash git log --oneline -8 cd api && composer test && cd .. cd ui && composer test && cd .. ``` 2. Read `SPEC.md` §6 (Admin API endpoints — `/api/v1/admin/ips/{ip}`, `/api/v1/admin/ips?...`, `/api/v1/admin/stats/dashboard`), §7 (Web UI Pages — Dashboard, IPs, IP Detail). 3. Confirm clean tree. ## Tasks ### 1. API: Admin IP endpoints In `api/src/Application/Admin/IpsController.php`: - `GET /api/v1/admin/ips?q=&category=&min_score=&max_score=&country=&asn=&status=&page=&page_size=` - `q`: substring match on `ip_text` (efficient with index — use `LIKE 'prefix%'` when the query looks like an IP prefix). - `category`: filter to IPs with score in this category above 0. - `min_score`, `max_score`: numeric range; applies to `MAX(score)` across all categories. - `country` (2-letter), `asn` (integer): from `ip_enrichment` (table exists; data lands in M11 — return null/blank gracefully now). - `status`: one of `scored | manual | allowlisted | clean` (uses `EffectiveStatusService` from M06 + this milestone's score check). - Returns `{items: [...], page, page_size, total}`. - `GET /api/v1/admin/ips/{ip}` — `ip` is URL-encoded; parse via `IpAddress::fromString` (404 on bad). - Returns: ```json { "ip": "203.0.113.42", "is_ipv4": true, "scores": [{"category":"brute_force","score":2.34,"last_report_at":"...","report_count_30d":12}, ...], "enrichment": {"country_code":null,"asn":null,"as_org":null,"enriched_at":null}, "status": "scored", "manual_block": null, "allowlist": null, "history": [ {"type":"report","received_at":"...","category":"brute_force","reporter":"web-prod-01","weight":1.0,"metadata":{...}}, {"type":"manual_block_added","at":"...","actor":"admin@example","reason":"..."}, ... ] } ``` - History combines `reports`, `manual_blocks` events, `allowlist` events, audit entries about this IP. Reports are the bulk; manual/allowlist events come from creation+deletion timestamps. - Limit history to most recent 200 entries; include `has_more: bool`. - `GET /api/v1/admin/stats/dashboard` — returns: ```json { "active_blocks": , "manual_blocks_count": ..., "allowlist_count": ..., "reports_24h": ..., "reports_24h_by_hour": [{"hour":"2026-04-27T15:00Z","count":42}, ...], "top_reporters_24h": [{"name":"web-prod-01","count":120}, ...], "top_categories_24h": [{"slug":"brute_force","count":300}, ...], "jobs_status": [{"name":"recompute-scores","last_finished_at":"...","status":"success","overdue":false}, ...] } ``` - Cache for 30s in-memory. RBAC: `Viewer` for all three. ### 2. UI: AdminClient extensions In `ui/src/ApiClient/AdminClient.php`: - `searchIps(array $filters, int $page, int $pageSize): IpListDto` - `getIp(string $ip): IpDetailDto` - `getDashboardStats(): DashboardStatsDto` DTOs match the API shapes. Use simple constructors. ### 3. UI: Pages In `ui/src/Controllers/`: - `DashboardController.php` — `GET /app/dashboard`. Renders `pages/dashboard.twig` with stats. Server-side render; the chart uses Chart.js via CDN-vendored static asset (already npm-installable via `chart.js`). - `IpsController.php`: - `GET /app/ips` — list page with filters (form fields with htmx for live filter optional). - `GET /app/ips/{ip}` — detail page. Templates in `ui/resources/views/pages/`: - `dashboard.twig` — top counts in cards; line chart of reports/hour for last 24h; tables for top reporters and top categories; jobs status list (don't add manual triggers — those are M12). - `ips/index.twig` — filter form (q, category dropdown, score range, country, ASN, status), paginated table. Columns: IP (link to detail), country flag (placeholder if no enrichment), ASN, top category, total score, last report relative time, status pill. - `ips/detail.twig` — header with IP and status pill; enrichment panel (greys out gracefully when null); score-per-category bars (CSS-only, no JS); history timeline (server-rendered; show "Load older" button or pagination if history is large). ### 4. Routes & nav Update `ui/src/App/Routes.php`: - After login, default redirect to `/app/dashboard` (was `/app/me` in M08). - All `/app/*` routes require an authenticated session; otherwise redirect to `/login` with a `next` parameter. Update `partials/sidebar.twig` to highlight the active section. ### 5. Dark mode polish - Verify all three pages render cleanly in both modes. - Status pills: use Tailwind color tokens that work in dark mode (e.g. `bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100`). ### 6. Accessibility - All interactive elements keyboard-reachable. - Form labels properly associated. - Color contrast meets WCAG AA in both modes. - The agent should run a Lighthouse accessibility check on the IPs list and detail pages and fix any score below 90. ### 7. RBAC visibility - Viewer: sees everything on these three pages. - Operator and Admin: same. (No write actions on these pages this milestone.) ## Implementation notes - **Pagination**: use page+page_size, default `page_size=25`, cap at `200`. Cursor pagination would be better for huge tables but isn't worth it here. - **Searching by IP prefix**: index `(ip_text)` on `reports`, `ip_scores`, `ip_enrichment`, `manual_blocks`, `allowlist` — most exist already; verify and add if missing via a small migration. Document in PROGRESS.md. - **Dashboard chart**: render a 24-bucket bar/line chart via Chart.js. Server pre-buckets by hour to avoid 1000s of points. - **History query performance**: when an IP has thousands of reports, joining history across multiple sources can be slow. Materialize the union via a single query that selects from each source with `received_at`/`created_at` aliased uniformly, then ORDER+LIMIT. - **`country_code` flag**: render as a Unicode regional indicator pair if present (e.g. "US" → 🇺🇸). Fall back to a 2-char text pill if the font doesn't render emoji flags. - **Tests**: - api: integration tests for each endpoint with seeded data. - ui: integration tests with mocked AdminClient verifying the templates render expected text/structures. No need for browser-level tests. ## Out of scope (DO NOT) - Manual block / allowlist creation buttons on the IP detail page. M10. - Token, reporter, consumer, policy, category management UI. M10. - Audit log UI. M12. - Settings page. M12. - Real GeoIP enrichment values. M11. The endpoint shape includes the fields; data is null until M11. - Charts beyond the dashboard line chart (no per-category trend page, no global trends, etc.). - New api endpoints beyond the three listed. - htmx live-search / infinite scroll. Pagination is fine. - New dependencies (Chart.js is allowed if not already present; record in PROGRESS.md). ## Acceptance ```bash cd api && composer cs && composer stan && composer test && cd .. cd ui && composer cs && composer stan && composer test && cd .. cd ui && npm ci && npm run build && cd .. docker compose down -v cp .env.example .env docker compose up -d sleep 20 # Seed some data via the API to make pages non-empty ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet) # Create a reporter + token; submit a few reports across categories; verify they show REPORTER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"name":"test-reporter","trust_weight":1.0}' \ http://localhost:8081/api/v1/admin/reporters) RID=$(echo "$REPORTER" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];') RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d "{\"kind\":\"reporter\",\"reporter_id\":$RID}" http://localhost:8081/api/v1/admin/tokens) RT=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];') for ip in 203.0.113.10 203.0.113.11 2001:db8::1; do curl -s -X POST -H "Authorization: Bearer $RT" -H "Content-Type: application/json" \ -d "{\"ip\":\"$ip\",\"category\":\"brute_force\"}" http://localhost:8081/api/v1/report > /dev/null done # Login as local admin (re-use the M08 acceptance flow), then: COOKIE_JAR=$(mktemp) CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4) curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&username=admin&password=test1234" \ http://localhost:8080/login/local -L > /dev/null # Dashboard renders with non-zero counts curl -s -b $COOKIE_JAR http://localhost:8080/app/dashboard | grep -qi "reports" curl -s -b $COOKIE_JAR http://localhost:8080/app/dashboard | grep -q "203.0.113" # at least one IP-related counter # IPs list shows our reported IPs curl -s -b $COOKIE_JAR http://localhost:8080/app/ips | grep -q "203.0.113.10" # Filter by IPv6 curl -s -b $COOKIE_JAR "http://localhost:8080/app/ips?q=2001" | grep -q "2001:db8::1" # IP detail page curl -s -b $COOKIE_JAR http://localhost:8080/app/ips/203.0.113.10 | grep -q "brute_force" # Bad IP → 404 page test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/ips/not-an-ip)" = "404" # Lighthouse-equivalent: at minimum, run htmx-side tests for accessibility attributes # (manual: open in a browser and run Lighthouse; aim ≥90) docker compose down -v ``` ## Handoff 1. Commit: ``` feat(M09): UI dashboard, IPs list, IP detail; matching admin API endpoints - GET /api/v1/admin/ips, /ips/{ip}, /stats/dashboard - dashboard with Chart.js (24h reports), top reporters/categories, jobs status - IP search with q/category/score/country/asn/status filters + pagination - IP detail: scores per category, history timeline (reports + manual events) - dark mode polished; Lighthouse a11y ≥90 on both pages ``` 2. Append to `PROGRESS.md`: ```markdown ## M09 — UI: IPs, history, dashboard (done) **Built:** read-only IP browsing UI; dashboard; matching admin endpoints. **Notes for next milestone:** - country flag and ASN show null/blank until M11 wires real GeoIP. - "active_blocks" count on the dashboard uses the seeded "moderate" policy as the reference; document this default. M10/M12 may add a config knob for which policy is the dashboard reference. - Manual block/allowlist buttons on IP detail are not present yet; the data is shown read-only. M10 adds the action buttons. - Lighthouse score: [insert measured number]. **Deviations from SPEC:** none. **Added dependencies:** chart.js (was in SPEC §2 React-libs note; here used directly via npm). ``` 3. **Stop.** Do not start M10.