Fresh Claude Code agent prompt. M08 must be complete and committed. Estimated effort: medium.
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).
Verify M08:
git log --oneline -8
cd api && composer test && cd ..
cd ui && composer test && cd ..
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).
Confirm clean tree.
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).{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}, ...]
}
RBAC: Viewer for all three.
In ui/src/ApiClient/AdminClient.php:
searchIps(array $filters, int $page, int $pageSize): IpListDtogetIp(string $ip): IpDetailDtogetDashboardStats(): DashboardStatsDtoDTOs match the API shapes. Use simple constructors.
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).Update ui/src/App/Routes.php:
/app/dashboard (was /app/me in M08)./app/* routes require an authenticated session; otherwise redirect to /login with a next parameter.Update partials/sidebar.twig to highlight the active section.
bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100).page_size=25, cap at 200. Cursor pagination would be better for huge tables but isn't worth it here.(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.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.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
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
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).
Stop. Do not start M10.