M09-ui-ips-history-dashboard.md 11 KB

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:

    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:

      {
      "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:

    {
    "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.phpGET /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

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:

    ## 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.