# 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": <count of IPs currently in any policy's blocklist using "moderate" as default reference>,
    "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.
