Ver Fonte

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)
- EffectiveStatusService now distinguishes Scored from Clean
- migration: idx_ip_scores_ip_text for prefix-search performance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa há 1 semana atrás
pai
commit
aaeee67c98
41 ficheiros alterados com 2649 adições e 31 exclusões
  1. 32 0
      PROGRESS.md
  2. 35 0
      api/db/migrations/20260429100000_add_ip_text_indexes.php
  3. 15 0
      api/src/App/AppFactory.php
  4. 10 0
      api/src/App/Container.php
  5. 293 0
      api/src/Application/Admin/IpsController.php
  6. 78 0
      api/src/Application/Admin/StatsController.php
  7. 16 6
      api/src/Domain/Reputation/EffectiveStatusService.php
  8. 17 0
      api/src/Infrastructure/Allowlist/AllowlistRepository.php
  9. 20 0
      api/src/Infrastructure/ManualBlock/ManualBlockRepository.php
  10. 168 0
      api/src/Infrastructure/Reputation/DashboardStatsRepository.php
  11. 37 0
      api/src/Infrastructure/Reputation/IpEnrichmentRepository.php
  12. 142 0
      api/src/Infrastructure/Reputation/IpHistoryRepository.php
  13. 259 1
      api/src/Infrastructure/Reputation/IpScoreRepository.php
  14. 194 0
      api/tests/Integration/Admin/IpsControllerTest.php
  15. 102 0
      api/tests/Integration/Admin/StatsControllerTest.php
  16. 39 6
      api/tests/Unit/Reputation/EffectiveStatusServiceTest.php
  17. 19 0
      ui/package-lock.json
  18. 1 0
      ui/package.json
  19. 47 0
      ui/resources/js/app.js
  20. 117 0
      ui/resources/views/pages/dashboard.twig
  21. 8 5
      ui/resources/views/pages/error.twig
  22. 130 0
      ui/resources/views/pages/ips/detail.twig
  23. 143 0
      ui/resources/views/pages/ips/index.twig
  24. 6 4
      ui/resources/views/partials/sidebar.twig
  25. 36 2
      ui/src/ApiClient/AdminClient.php
  26. 69 0
      ui/src/ApiClient/DTOs/DashboardStatsDto.php
  27. 107 0
      ui/src/ApiClient/DTOs/IpDetailDto.php
  28. 53 0
      ui/src/ApiClient/DTOs/IpListDto.php
  29. 54 0
      ui/src/ApiClient/DTOs/IpListItem.php
  30. 12 0
      ui/src/App/AppFactory.php
  31. 4 0
      ui/src/App/Container.php
  32. 1 1
      ui/src/Auth/LocalLoginController.php
  33. 1 1
      ui/src/Auth/OidcController.php
  34. 52 0
      ui/src/Controllers/DashboardController.php
  35. 1 1
      ui/src/Controllers/HomeController.php
  36. 123 0
      ui/src/Controllers/IpsController.php
  37. 68 0
      ui/tests/Integration/App/DashboardPageTest.php
  38. 136 0
      ui/tests/Integration/App/IpsPageTest.php
  39. 2 2
      ui/tests/Integration/App/RoutesTest.php
  40. 1 1
      ui/tests/Integration/Auth/LocalLoginTest.php
  41. 1 1
      ui/tests/Integration/Auth/OidcFlowTest.php

+ 32 - 0
PROGRESS.md

@@ -199,3 +199,35 @@
 **Added dependencies:**
 - `esbuild` (devDependency, JS bundling for `app.js`). The SPEC §2 doesn't enumerate a JS bundler explicitly but allows "vanilla JS + Alpine.js + htmx where it simplifies forms"; the Tailwind-only build was insufficient since Alpine and htmx are imported modules. The Dockerfile build now runs both `tailwindcss` and `esbuild`.
 - `jumbojett/openid-connect-php` was already in SPEC §2 / `composer.json`; it's just being USED for the first time in M08.
+
+## M09 — UI: IPs, history, dashboard (done)
+
+**Built:** read-only IP browsing UI + matching admin endpoints. API: `GET /api/v1/admin/ips` (paginated search with q / category / score range / country / asn / status filters), `GET /api/v1/admin/ips/{ip}` (scores per category, enrichment placeholder, manual/allowlist panels, 200-entry history timeline with `has_more`), `GET /api/v1/admin/stats/dashboard` (active blocks + counters + 24h histogram + top reporters/categories + jobs status, 30s in-memory cache). UI: `/app/dashboard`, `/app/ips`, `/app/ips/{ip}`. Chart.js bundles via esbuild (tree-shaken to ~150kb of bar/linear pieces). Default post-login redirect now `/app/dashboard`. Sidebar highlights the active section.
+
+**Notes for next milestone:**
+- `EffectiveStatusService` was completed: it now distinguishes `Scored` (any non-zero score in `ip_scores`) from `Clean` (no rows or all zero). M07's policy-vs-score evaluation lives separately in `PolicyEvaluator` — the single-IP "is this scored?" question is policy-agnostic by design (you don't get to ask "scored against which policy?" when looking at one IP).
+- Dashboard `active_blocks` is an **approximation**: distinct IPs in `ip_scores` with score > 0 PLUS single-IP `manual_blocks`. Computing the exact count of IPs in the seeded `moderate` policy's blocklist would require running `BlocklistBuilder` per request, which is too expensive for a 30s-cached dashboard. The number is a stable proxy; the response carries `reference_policy: "moderate"` to make the caveat explicit. M10/M12 may add a config knob.
+- IP search is grounded in `ip_scores` — IPs that are *only* manually blocked (no reports yet) won't appear unless they have an `ip_scores` row. Manual subnets and allowlist subnets aren't expanded in search either; only single-IP entries from those tables intersect with the search via the `status` filter. The IP detail page shows the precise effective status. Documented limitation; a richer "show subnet members" view is out of scope.
+- Country flag rendering uses the regional-indicator emoji pair (`'🇦' ~ first ~ '🇦' ~ second`). Browsers without flag-emoji fonts (some Windows configs) render it as block letters; the fallback when `country_code` is null is a `??` pill.
+- Country / ASN columns are blank until M11 wires real GeoIP (the `ip_enrichment` table exists; the `enrich-pending` job is still a skeleton).
+- Manual-block / allowlist mutation buttons on the IP detail page are deliberately absent here. M10 adds them.
+- `IpHistoryRepository` UNIONs the three sources in PHP (separate queries → merge → sort) rather than in SQL — the per-source caps (500 reports max + small manual/allowlist tables) keep this fast at our dataset sizes; switching to a single SQL UNION ALL is straightforward if profiling later shows it matters.
+- Lighthouse not measured here — the acceptance environment has no headless browser. The pages use semantic HTML (`<table>` with proper `<thead>`/`<tbody>`, labelled form inputs, `aria-current="page"` on the active sidebar link, contrast tokens that pass WCAG AA in both modes by Tailwind's defaults). M13 will run the actual Lighthouse pass.
+- Slim's default segment regex disallows colons; `/{ip:.+}` is required for IPv6 paths to route on both the api and the ui.
+
+**Schema:**
+- `idx_ip_scores_ip_text` — single-column index on `ip_scores.ip_text` so the search's `LIKE 'prefix%'` path doesn't full-scan. `LIKE '%substr%'` falls back to a scan, acceptable at the dataset sizes covered by the SPEC.
+
+**Test surface added (api):** `tests/Integration/Admin/IpsControllerTest.php` (10 tests covering list ordering, prefix filter, category filter, pagination, validation error, detail success/empty/404, enrichment-null-by-default), `tests/Integration/Admin/StatsControllerTest.php` (3 tests covering empty shape, non-empty counters, manual/allowlist counters). Updated `EffectiveStatusServiceTest` to cover the new `Scored`-when-rows-exist branch with stub IpScoreRepository. Total: 285 tests / 787 assertions.
+
+**Test surface added (ui):** `tests/Integration/App/DashboardPageTest.php` (renders stats + chart canvas + degrades on api-down), `tests/Integration/App/IpsPageTest.php` (list, empty state, filter round-trip via form, detail page with scores+history, twig 404, anonymous redirect). Total: 55 tests / 133 assertions.
+
+**Acceptance script:** ran end-to-end against compose stack. Seeded 3 reports across 3 IPs (mix of v4 + v6); local admin login → /app/dashboard renders with "reports", "Active blocks", "test-reporter", and the chart canvas; /app/ips lists all three IPs with `brute_force` as top category; ?q=2001 narrows to the v6 IP only; /app/ips/203.0.113.10 shows "Score per category" and "History" sections with `brute_force`; /app/ips/not-an-ip returns 404 with the friendly error template.
+
+**Deviations from SPEC:**
+- `EffectiveStatusService` had an unused-but-final wiring left over from M06; making it usable for M09 required a constructor change (`+IpScoreRepository`) and broke the Unit-level test that constructed it with one arg. Updated tests accordingly. The fix also required dropping `IpScoreRepository`'s `final` modifier so test stubs can extend it — same pattern used for `CidrEvaluatorFactory` in M06.
+- The dashboard `active_blocks` figure is an approximation (see notes above), not the exact "moderate-policy blocklist size" the SPEC mentions. The response carries `reference_policy: "moderate"` to call this out and makes a follow-up "switch to exact computation" trivial when M12 or beyond decides it's worth the cost.
+- Sidebar's "My identity" link moved to the bottom of the nav (under the M10/M12 placeholders) since `/app/dashboard` is now the canonical landing page. Visual order only; no functional change.
+
+**Added dependencies:**
+- `chart.js` (npm dep). The SPEC's M09 doc explicitly allows it; tree-shaken to bar/linear/category controllers + element + tooltip + title via Chart.js's modular registration, keeping the impact at ~150kb of the final ~263kb bundle (Chart.js + Alpine + htmx + our own ~3kb).

+ 35 - 0
api/db/migrations/20260429100000_add_ip_text_indexes.php

@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * SPEC §M09.5: index `ip_text` so the admin IP search ("`q=` substring or
+ * prefix match") doesn't fall back to a full-table scan.
+ *
+ * `ip_scores.ip_text` is the primary search column (the search results are
+ * grouped by IP). `reports.ip_text` is denormalised alongside `ip_bin`
+ * but the search joins by `ip_bin`, so we don't add an index there.
+ *
+ * `LIKE 'prefix%'` uses the index on default-collation columns on both
+ * SQLite and MySQL; `LIKE '%substr%'` falls back to a scan, which is
+ * acceptable at the dataset sizes we expect (the search caps at 200
+ * rows per page).
+ */
+final class AddIpTextIndexes extends BaseMigration
+{
+    public function up(): void
+    {
+        $this->table('ip_scores')
+            ->addIndex(['ip_text'], ['name' => 'idx_ip_scores_ip_text'])
+            ->update();
+    }
+
+    public function down(): void
+    {
+        $this->table('ip_scores')
+            ->removeIndexByName('idx_ip_scores_ip_text')
+            ->update();
+    }
+}

+ 15 - 0
api/src/App/AppFactory.php

@@ -6,10 +6,12 @@ namespace App\App;
 
 use App\Application\Admin\AllowlistController;
 use App\Application\Admin\ConsumersController;
+use App\Application\Admin\IpsController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\PoliciesController;
 use App\Application\Admin\ReportersController;
+use App\Application\Admin\StatsController;
 use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
@@ -179,6 +181,19 @@ final class AppFactory
             $admin->delete('/allowlist/{id}', [$allowlist, 'delete'])
                 ->add(RbacMiddleware::require($rf, Role::Operator));
 
+            // IPs: list, detail, stats — all Viewer (read-only this milestone).
+            /** @var IpsController $ips */
+            $ips = $container->get(IpsController::class);
+            $admin->get('/ips', [$ips, 'list'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/ips/{ip:.+}', [$ips, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+
+            /** @var StatsController $stats */
+            $stats = $container->get(StatsController::class);
+            $admin->get('/stats/dashboard', [$stats, 'dashboard'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+
             // Policies: list/show/preview = Viewer; write = Admin.
             /** @var PoliciesController $policies */
             $policies = $container->get(PoliciesController::class);

+ 10 - 0
api/src/App/Container.php

@@ -6,10 +6,12 @@ namespace App\App;
 
 use App\Application\Admin\AllowlistController;
 use App\Application\Admin\ConsumersController;
+use App\Application\Admin\IpsController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\PoliciesController;
 use App\Application\Admin\ReportersController;
+use App\Application\Admin\StatsController;
 use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
@@ -51,6 +53,9 @@ use App\Infrastructure\Policy\PolicyRepository;
 use App\Infrastructure\Reporter\ReporterRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Infrastructure\Reputation\DashboardStatsRepository;
+use App\Infrastructure\Reputation\IpEnrichmentRepository;
+use App\Infrastructure\Reputation\IpHistoryRepository;
 use App\Infrastructure\Reputation\IpScoreRepository;
 use App\Infrastructure\Reputation\ReportRepository;
 
@@ -140,6 +145,9 @@ final class Container
             ManualBlockRepository::class => autowire(),
             AllowlistRepository::class => autowire(),
             PolicyRepository::class => autowire(),
+            IpEnrichmentRepository::class => autowire(),
+            IpHistoryRepository::class => autowire(),
+            DashboardStatsRepository::class => autowire(),
             CidrEvaluatorFactory::class => factory(static function (ContainerInterface $c): CidrEvaluatorFactory {
                 /** @var ManualBlockRepository $manual */
                 $manual = $c->get(ManualBlockRepository::class);
@@ -298,6 +306,8 @@ final class Container
             AllowlistController::class => autowire(),
             PoliciesController::class => autowire(),
             BlocklistController::class => autowire(),
+            IpsController::class => autowire(),
+            StatsController::class => autowire(),
         ]);
 
         return $builder->build();

+ 293 - 0
api/src/Application/Admin/IpsController.php

@@ -0,0 +1,293 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Category\Category;
+use App\Domain\Ip\InvalidIpException;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\EffectiveStatus;
+use App\Domain\Reputation\EffectiveStatusService;
+use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Infrastructure\Reputation\IpEnrichmentRepository;
+use App\Infrastructure\Reputation\IpHistoryRepository;
+use App\Infrastructure\Reputation\IpScoreRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin IP browser — search + detail.
+ *
+ * The search composes filters against `ip_scores` (joined with
+ * `ip_enrichment` when country/asn is set), with the manual-block /
+ * allowlist single-IP universes pre-resolved from `CidrEvaluatorFactory`
+ * so the SQL stays a single query. Subnet membership is NOT expanded
+ * here — IPs that are only "covered by" a manual /24 don't appear in
+ * the search unless they also have a row in `ip_scores`. The IP-detail
+ * page shows the precise effective status.
+ *
+ * Pagination: `page` (1-indexed) + `page_size` (default 25, max 200).
+ *
+ * RBAC: Viewer.
+ */
+final class IpsController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly IpScoreRepository $ipScores,
+        private readonly CategoryRepository $categories,
+        private readonly ManualBlockRepository $manualBlocks,
+        private readonly AllowlistRepository $allowlist,
+        private readonly IpEnrichmentRepository $enrichment,
+        private readonly IpHistoryRepository $history,
+        private readonly CidrEvaluatorFactory $cidrEvaluatorFactory,
+        private readonly EffectiveStatusService $effectiveStatus,
+    ) {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $query = $request->getQueryParams();
+        $filters = $this->parseSearchFilters($query);
+        $errors = $filters['errors'];
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $page = $filters['page'];
+        $pageSize = $filters['page_size'];
+
+        $evaluator = $this->cidrEvaluatorFactory->get();
+        $repoFilters = [
+            'q' => $filters['q'],
+            'category_id' => $filters['category_id'],
+            'min_score' => $filters['min_score'],
+            'max_score' => $filters['max_score'],
+            'country' => $filters['country'],
+            'asn' => $filters['asn'],
+            'status' => $filters['status'],
+            'manual_bins' => $evaluator->manualBlockedIpBins(),
+            'allow_bins' => $evaluator->allowlistedIpBins(),
+        ];
+
+        $result = $this->ipScores->searchIps($repoFilters, $pageSize, ($page - 1) * $pageSize);
+        $slugByCategoryId = $this->slugByCategoryId();
+
+        // Per-row: top category slug + raw enrichment (country flag rendering happens in the UI).
+        $items = [];
+        foreach ($result['items'] as $row) {
+            $top = $this->topCategoryFor($row['ip_bin']);
+            $enrichmentRow = $this->enrichment->findByIpBin($row['ip_bin']);
+            $effective = $this->effectiveStatusFor($row['ip_bin']);
+            $items[] = [
+                'ip' => $row['ip_text'],
+                'is_ipv4' => str_contains($row['ip_text'], '.') && !str_contains($row['ip_text'], ':'),
+                'max_score' => round($row['max_score'], 4),
+                'top_category' => $top !== null ? ($slugByCategoryId[$top] ?? null) : null,
+                'pair_count' => $row['pair_count'],
+                'last_report_at' => $this->formatTimestamp($row['last_report_at']),
+                'status' => $effective->value,
+                'enrichment' => $enrichmentRow,
+            ];
+        }
+
+        return self::json($response, 200, [
+            'items' => $items,
+            'page' => $page,
+            'page_size' => $pageSize,
+            'total' => $result['total'],
+        ]);
+    }
+
+    /**
+     * @param array{ip: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        try {
+            $ip = IpAddress::fromString(rawurldecode($args['ip']));
+        } catch (InvalidIpException) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $bin = $ip->binary();
+        $scoreRows = $this->ipScores->scoresForIp($bin);
+        $slugById = $this->slugByCategoryId();
+
+        $scores = [];
+        foreach ($scoreRows as $row) {
+            $scores[] = [
+                'category' => $slugById[$row['category_id']] ?? null,
+                'category_id' => $row['category_id'],
+                'score' => round($row['score'], 4),
+                'last_report_at' => $this->formatTimestamp($row['last_report_at']),
+                'report_count_30d' => $row['report_count_30d'],
+            ];
+        }
+
+        $manual = $this->manualBlocks->findByIpBin($bin);
+        $allow = $this->allowlist->findByIpBin($bin);
+        $enrichment = $this->enrichment->findByIpBin($bin) ?? [
+            'country_code' => null,
+            'asn' => null,
+            'as_org' => null,
+            'enriched_at' => null,
+        ];
+        $status = $this->effectiveStatus->forIp($ip);
+        $history = $this->history->forIp($bin);
+
+        return self::json($response, 200, [
+            'ip' => $ip->text(),
+            'is_ipv4' => $ip->isIpv4(),
+            'scores' => $scores,
+            'enrichment' => $enrichment,
+            'status' => $status->value,
+            'manual_block' => $manual?->toArray(),
+            'allowlist' => $allow?->toArray(),
+            'history' => $history['items'],
+            'has_more' => $history['has_more'],
+        ]);
+    }
+
+    /**
+     * @param array<string, mixed> $query
+     * @return array{
+     *     errors: array<string, string>,
+     *     page: int,
+     *     page_size: int,
+     *     q: ?string,
+     *     category_id: ?int,
+     *     min_score: ?float,
+     *     max_score: ?float,
+     *     country: ?string,
+     *     asn: ?int,
+     *     status: ?string,
+     * }
+     */
+    private function parseSearchFilters(array $query): array
+    {
+        $errors = [];
+        $page = isset($query['page']) && ctype_digit((string) $query['page']) ? max(1, (int) $query['page']) : 1;
+        $pageSize = isset($query['page_size']) && ctype_digit((string) $query['page_size'])
+            ? (int) $query['page_size']
+            : 25;
+        $pageSize = max(1, min(200, $pageSize));
+
+        $q = isset($query['q']) && is_string($query['q']) && trim($query['q']) !== '' ? trim((string) $query['q']) : null;
+
+        $categoryId = null;
+        if (isset($query['category']) && is_string($query['category']) && $query['category'] !== '') {
+            $cat = $this->categories->findActiveBySlug($query['category']);
+            if ($cat === null) {
+                $errors['category'] = 'unknown category slug';
+            } else {
+                $categoryId = $cat->id;
+            }
+        }
+
+        $minScore = $this->parseFloat($query['min_score'] ?? null);
+        $maxScore = $this->parseFloat($query['max_score'] ?? null);
+
+        $country = null;
+        if (isset($query['country']) && is_string($query['country']) && $query['country'] !== '') {
+            if (preg_match('/^[A-Za-z]{2}$/', $query['country']) !== 1) {
+                $errors['country'] = 'must be a 2-letter ISO code';
+            } else {
+                $country = strtoupper($query['country']);
+            }
+        }
+
+        $asn = null;
+        if (isset($query['asn']) && (string) $query['asn'] !== '') {
+            if (!ctype_digit((string) $query['asn'])) {
+                $errors['asn'] = 'must be a positive integer';
+            } else {
+                $asn = (int) $query['asn'];
+            }
+        }
+
+        $status = null;
+        if (isset($query['status']) && is_string($query['status']) && $query['status'] !== '') {
+            if (!in_array($query['status'], ['scored', 'manual', 'allowlisted', 'clean'], true)) {
+                $errors['status'] = 'must be scored | manual | allowlisted | clean';
+            } else {
+                $status = $query['status'];
+            }
+        }
+
+        return [
+            'errors' => $errors,
+            'page' => $page,
+            'page_size' => $pageSize,
+            'q' => $q,
+            'category_id' => $categoryId,
+            'min_score' => $minScore,
+            'max_score' => $maxScore,
+            'country' => $country,
+            'asn' => $asn,
+            'status' => $status,
+        ];
+    }
+
+    private function parseFloat(mixed $value): ?float
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+        if (is_numeric($value)) {
+            return (float) $value;
+        }
+
+        return null;
+    }
+
+    private function topCategoryFor(string $ipBin): ?int
+    {
+        $rows = $this->ipScores->scoresForIp($ipBin);
+        foreach ($rows as $row) {
+            if ($row['score'] > 0) {
+                return $row['category_id'];
+            }
+        }
+
+        return null;
+    }
+
+    private function effectiveStatusFor(string $ipBin): EffectiveStatus
+    {
+        return $this->effectiveStatus->forIp(IpAddress::fromBinary($ipBin));
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function slugByCategoryId(): array
+    {
+        $out = [];
+        foreach ($this->categories->listAll() as $cat) {
+            /** @var Category $cat */
+            $out[$cat->id] = $cat->slug;
+        }
+
+        return $out;
+    }
+
+    private function formatTimestamp(?string $value): ?string
+    {
+        if ($value === null) {
+            return null;
+        }
+        // Convert "YYYY-MM-DD HH:MM:SS" → ISO 8601 "YYYY-MM-DDTHH:MM:SSZ".
+        $s = trim($value);
+        if (str_contains($s, 'T')) {
+            return $s;
+        }
+
+        return str_replace(' ', 'T', $s) . 'Z';
+    }
+}

+ 78 - 0
api/src/Application/Admin/StatsController.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Time\Clock;
+use App\Infrastructure\Reputation\DashboardStatsRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `GET /api/v1/admin/stats/dashboard` — aggregate counters + 24h
+ * histogram + top reporters/categories + jobs status.
+ *
+ * In-process 30 s cache. Multi-replica deployments will see brief
+ * staleness across replicas — accepted; mirrors the BlocklistCache
+ * semantics from M07.
+ *
+ * RBAC: Viewer.
+ */
+final class StatsController
+{
+    use AdminControllerSupport;
+
+    /** @var array<string, mixed>|null */
+    private ?array $cached = null;
+    private ?float $cacheExpiresAt = null;
+    private const TTL_SECONDS = 30.0;
+
+    /**
+     * Expected job intervals (seconds). The status payload flips
+     * `overdue` when a job's `last_finished_at` is older than its
+     * interval. Mirrors the values backed by the env vars from §9.
+     *
+     * @var array<string, int>
+     */
+    private const EXPECTED_INTERVALS = [
+        'recompute-scores' => 600,        // 10 min
+        'cleanup-audit' => 86400,         // 1 day
+        'enrich-pending' => 3600,         // 1 hour
+        'refresh-geoip' => 7 * 86400,     // 1 week
+    ];
+
+    public function __construct(
+        private readonly DashboardStatsRepository $stats,
+        private readonly Clock $clock,
+    ) {
+    }
+
+    public function dashboard(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $now = $this->clock->now();
+        $nowFloat = (float) $now->getTimestamp();
+        if ($this->cached !== null && $this->cacheExpiresAt !== null && $nowFloat < $this->cacheExpiresAt) {
+            return self::json($response, 200, $this->cached);
+        }
+
+        $since = $now->modify('-24 hours');
+
+        $payload = [
+            'active_blocks' => $this->stats->activeBlocksApprox(),
+            'manual_blocks_count' => $this->stats->manualBlocksCount(),
+            'allowlist_count' => $this->stats->allowlistCount(),
+            'reports_24h' => $this->stats->reportsSince($since),
+            'reports_24h_by_hour' => $this->stats->reportsByHourSince($since),
+            'top_reporters_24h' => $this->stats->topReportersSince($since),
+            'top_categories_24h' => $this->stats->topCategoriesSince($since),
+            'jobs_status' => $this->stats->jobsStatus(self::EXPECTED_INTERVALS, $now),
+            'reference_policy' => 'moderate',
+        ];
+
+        $this->cached = $payload;
+        $this->cacheExpiresAt = $nowFloat + self::TTL_SECONDS;
+
+        return self::json($response, 200, $payload);
+    }
+}

+ 16 - 6
api/src/Domain/Reputation/EffectiveStatusService.php

@@ -6,19 +6,27 @@ namespace App\Domain\Reputation;
 
 use App\Domain\Ip\IpAddress;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Infrastructure\Reputation\IpScoreRepository;
 
 /**
  * Resolves an IP to one of `allowlisted | manually_blocked | scored | clean`.
  *
  * SPEC §5 resolution order — allowlist wins over everything; manual block
- * wins over scored; score-vs-policy is M07 territory and currently maps to
- * `Clean` for any non-overridden IP. The caller (admin "ip detail" or
- * distribution endpoint in M07) renders accordingly.
+ * wins over scored; otherwise we look at `ip_scores` to distinguish a
+ * `Scored` IP (any non-zero score in any category) from a `Clean` one
+ * (no rows, or all zero).
+ *
+ * M07 added the policy-evaluator for the distribution endpoint; the
+ * single-IP "is this scored?" question for the admin UI uses any non-
+ * zero score across all categories, which is a coarser but stable
+ * signal independent of which policy the caller is asking about.
  */
 final class EffectiveStatusService
 {
-    public function __construct(private readonly CidrEvaluatorFactory $evaluatorFactory)
-    {
+    public function __construct(
+        private readonly CidrEvaluatorFactory $evaluatorFactory,
+        private readonly IpScoreRepository $ipScores,
+    ) {
     }
 
     public function forIp(IpAddress $ip): EffectiveStatus
@@ -31,8 +39,10 @@ final class EffectiveStatusService
         if ($evaluator->isManuallyBlocked($ip)) {
             return EffectiveStatus::ManuallyBlocked;
         }
+        if ($this->ipScores->hasAnyScore($ip->binary())) {
+            return EffectiveStatus::Scored;
+        }
 
-        // M07 will replace this with score-vs-policy evaluation.
         return EffectiveStatus::Clean;
     }
 }

+ 17 - 0
api/src/Infrastructure/Allowlist/AllowlistRepository.php

@@ -19,6 +19,23 @@ use Doctrine\DBAL\ParameterType;
  */
 final class AllowlistRepository extends RepositoryBase
 {
+    /**
+     * Find a single-IP allowlist entry by exact `ip_bin` match. Used by
+     * the admin IP-detail endpoint to render the allowlist panel.
+     */
+    public function findByIpBin(string $ipBin): ?AllowlistEntry
+    {
+        $row = $this->fetchByIpBin('allowlist', $ipBin);
+        if ($row === null) {
+            return null;
+        }
+        if (($row['kind'] ?? '') !== AllowlistEntry::KIND_IP) {
+            return null;
+        }
+
+        return self::hydrate($row);
+    }
+
     public function findById(int $id): ?AllowlistEntry
     {
         /** @var array<string, mixed>|false $row */

+ 20 - 0
api/src/Infrastructure/ManualBlock/ManualBlockRepository.php

@@ -23,6 +23,26 @@ use Doctrine\DBAL\ParameterType;
  */
 final class ManualBlockRepository extends RepositoryBase
 {
+    /**
+     * Find a single-IP manual block by exact `ip_bin` match. Used by the
+     * admin IP-detail endpoint to render the manual-block panel.
+     */
+    public function findByIpBin(string $ipBin): ?ManualBlock
+    {
+        $row = $this->fetchByIpBin('manual_blocks', $ipBin);
+        if ($row === null) {
+            return null;
+        }
+        // The base helper matches WHERE ip_bin = ?, but `manual_blocks`
+        // stores network entries with `ip_bin = NULL` and a separate
+        // `network_bin`, so we additionally filter to kind=ip rows.
+        if (($row['kind'] ?? '') !== ManualBlock::KIND_IP) {
+            return null;
+        }
+
+        return self::hydrate($row);
+    }
+
     public function findById(int $id): ?ManualBlock
     {
         /** @var array<string, mixed>|false $row */

+ 168 - 0
api/src/Infrastructure/Reputation/DashboardStatsRepository.php

@@ -0,0 +1,168 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Infrastructure\Db\RepositoryBase;
+use DateTimeImmutable;
+use DateTimeZone;
+
+/**
+ * Aggregates for the admin dashboard. Each method returns the smallest
+ * shape the controller needs; the controller assembles the response.
+ *
+ * No caching here — the controller wraps the whole thing in a 30 s
+ * in-memory cache (SPEC §M09.1).
+ */
+final class DashboardStatsRepository extends RepositoryBase
+{
+    public function manualBlocksCount(): int
+    {
+        return (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM manual_blocks');
+    }
+
+    public function allowlistCount(): int
+    {
+        return (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM allowlist');
+    }
+
+    public function reportsSince(DateTimeImmutable $since): int
+    {
+        return (int) $this->connection()->fetchOne(
+            'SELECT COUNT(*) FROM reports WHERE received_at >= :since',
+            ['since' => $since->format('Y-m-d H:i:s')]
+        );
+    }
+
+    /**
+     * @return list<array{hour: string, count: int}>
+     */
+    public function reportsByHourSince(DateTimeImmutable $since): array
+    {
+        $platform = $this->connection()->getDatabasePlatform()::class;
+        $isMysql = stripos($platform, 'mysql') !== false || stripos($platform, 'mariadb') !== false;
+
+        // Group by truncated-to-hour timestamp. Both adapters have their
+        // own date functions; substr() works on ISO 8601 strings on
+        // SQLite, and MySQL has DATE_FORMAT.
+        $sql = $isMysql
+            ? "SELECT DATE_FORMAT(received_at, '%Y-%m-%dT%H:00:00Z') AS hour, COUNT(*) AS c "
+                . 'FROM reports WHERE received_at >= :since GROUP BY hour ORDER BY hour ASC'
+            : "SELECT substr(replace(received_at, ' ', 'T'), 1, 13) || ':00:00Z' AS hour, COUNT(*) AS c "
+                . 'FROM reports WHERE received_at >= :since GROUP BY hour ORDER BY hour ASC';
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, ['since' => $since->format('Y-m-d H:i:s')]);
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = ['hour' => (string) $row['hour'], 'count' => (int) $row['c']];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return list<array{name: string, count: int}>
+     */
+    public function topReportersSince(DateTimeImmutable $since, int $limit = 10): array
+    {
+        $sql = 'SELECT rep.name AS name, COUNT(*) AS c '
+            . 'FROM reports r JOIN reporters rep ON rep.id = r.reporter_id '
+            . 'WHERE r.received_at >= :since '
+            . 'GROUP BY rep.name '
+            . 'ORDER BY c DESC '
+            . 'LIMIT ' . max(1, $limit);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, ['since' => $since->format('Y-m-d H:i:s')]);
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = ['name' => (string) $row['name'], 'count' => (int) $row['c']];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return list<array{slug: string, count: int}>
+     */
+    public function topCategoriesSince(DateTimeImmutable $since, int $limit = 10): array
+    {
+        $sql = 'SELECT c.slug AS slug, COUNT(*) AS cnt '
+            . 'FROM reports r JOIN categories c ON c.id = r.category_id '
+            . 'WHERE r.received_at >= :since '
+            . 'GROUP BY c.slug '
+            . 'ORDER BY cnt DESC '
+            . 'LIMIT ' . max(1, $limit);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, ['since' => $since->format('Y-m-d H:i:s')]);
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = ['slug' => (string) $row['slug'], 'count' => (int) $row['cnt']];
+        }
+
+        return $out;
+    }
+
+    /**
+     * Last-finished status of each known job, keyed off the most-recent
+     * `job_runs` row per `job_name`. Returns an empty list if the table
+     * is empty (fresh deployment).
+     *
+     * @param array<string, int> $expectedIntervals job_name => seconds
+     * @return list<array{name: string, last_finished_at: ?string, status: string, overdue: bool}>
+     */
+    public function jobsStatus(array $expectedIntervals, DateTimeImmutable $now): array
+    {
+        $sql = 'SELECT job_name, status, finished_at, started_at FROM job_runs r1 '
+            . 'WHERE r1.id = ('
+            . '  SELECT MAX(id) FROM job_runs r2 WHERE r2.job_name = r1.job_name'
+            . ') '
+            . 'ORDER BY job_name';
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql);
+        $out = [];
+        foreach ($rows as $row) {
+            $name = (string) $row['job_name'];
+            $finishedAt = $row['finished_at'] !== null ? (string) $row['finished_at'] : null;
+            $overdue = false;
+            if ($finishedAt !== null && isset($expectedIntervals[$name])) {
+                $finished = new DateTimeImmutable($finishedAt, new DateTimeZone('UTC'));
+                $overdue = ($now->getTimestamp() - $finished->getTimestamp()) > $expectedIntervals[$name];
+            }
+            $out[] = [
+                'name' => $name,
+                'last_finished_at' => $finishedAt,
+                'status' => (string) $row['status'],
+                'overdue' => $overdue,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * Crude "active blocks" estimate: count of distinct IPs in `ip_scores`
+     * with at least one non-zero score, plus the count of single-IP
+     * manual blocks. Subnet manual blocks are not expanded.
+     *
+     * SPEC §M09.1 says "count of IPs currently in any policy's blocklist
+     * using `moderate` as default reference"; computing that exactly
+     * means evaluating the BlocklistBuilder, which is too heavy for a
+     * dashboard ping. The number here is a stable proxy.
+     */
+    public function activeBlocksApprox(): int
+    {
+        $scored = (int) $this->connection()->fetchOne(
+            'SELECT COUNT(DISTINCT ip_bin) FROM ip_scores WHERE score > 0'
+        );
+        $manualSingles = (int) $this->connection()->fetchOne(
+            "SELECT COUNT(*) FROM manual_blocks WHERE kind = 'ip'"
+        );
+
+        return $scored + $manualSingles;
+    }
+}

+ 37 - 0
api/src/Infrastructure/Reputation/IpEnrichmentRepository.php

@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Infrastructure\Db\RepositoryBase;
+
+/**
+ * Read-side gateway for `ip_enrichment`. Writes (and the enrichment job
+ * itself) land in M11; for now this exists so the IP-detail endpoint
+ * has a single place to fetch the country/ASN row to render.
+ *
+ * Returns null if the IP has no row yet — every JOIN on the search side
+ * is LEFT, and the IP-detail page degrades gracefully when fields are
+ * absent.
+ */
+final class IpEnrichmentRepository extends RepositoryBase
+{
+    /**
+     * @return array{country_code: ?string, asn: ?int, as_org: ?string, enriched_at: ?string}|null
+     */
+    public function findByIpBin(string $ipBin): ?array
+    {
+        $row = $this->fetchByIpBin('ip_enrichment', $ipBin);
+        if ($row === null) {
+            return null;
+        }
+
+        return [
+            'country_code' => $row['country_code'] !== null ? (string) $row['country_code'] : null,
+            'asn' => $row['asn'] !== null ? (int) $row['asn'] : null,
+            'as_org' => $row['as_org'] !== null ? (string) $row['as_org'] : null,
+            'enriched_at' => $row['enriched_at'] !== null ? (string) $row['enriched_at'] : null,
+        ];
+    }
+}

+ 142 - 0
api/src/Infrastructure/Reputation/IpHistoryRepository.php

@@ -0,0 +1,142 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Infrastructure\Db\RepositoryBase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Builds the per-IP history timeline for the admin IP-detail page.
+ *
+ * SPEC §M09.1: combines `reports`, `manual_blocks` events, and
+ * `allowlist` events for a given IP into one chronologically-ordered
+ * list. Audit-log events are added in M12 — until then this returns
+ * the report + manual + allowlist sources.
+ *
+ * The query is a UNION ALL with each source aliased onto a uniform
+ * `(at, type, payload_json)` shape so the caller doesn't have to
+ * branch by source. Limited to the most recent N entries (default 200)
+ * — the controller flips `has_more` when the underlying total exceeds
+ * the limit.
+ */
+final class IpHistoryRepository extends RepositoryBase
+{
+    public const DEFAULT_LIMIT = 200;
+
+    /**
+     * @return array{items: list<array<string, mixed>>, has_more: bool}
+     */
+    public function forIp(string $ipBin, int $limit = self::DEFAULT_LIMIT): array
+    {
+        $reports = $this->reportEvents($ipBin);
+        $manualAdded = $this->manualBlockEvents($ipBin);
+        $allowAdded = $this->allowlistEvents($ipBin);
+
+        $combined = array_merge($reports, $manualAdded, $allowAdded);
+        usort($combined, static fn (array $a, array $b): int => strcmp((string) $b['at'], (string) $a['at']));
+
+        $hasMore = count($combined) > $limit;
+        if ($hasMore) {
+            $combined = array_slice($combined, 0, $limit);
+        }
+
+        return ['items' => $combined, 'has_more' => $hasMore];
+    }
+
+    /**
+     * @return list<array<string, mixed>>
+     */
+    private function reportEvents(string $ipBin): array
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT r.received_at, r.weight_at_report, r.metadata_json, '
+            . '       c.slug AS category_slug, rep.name AS reporter_name '
+            . 'FROM reports r '
+            . 'LEFT JOIN categories c ON c.id = r.category_id '
+            . 'LEFT JOIN reporters  rep ON rep.id = r.reporter_id '
+            . 'WHERE r.ip_bin = :ip '
+            . 'ORDER BY r.received_at DESC '
+            . 'LIMIT 500'
+        );
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $stmt->executeQuery()->fetchAllAssociative();
+        $out = [];
+        foreach ($rows as $row) {
+            $metadata = null;
+            if ($row['metadata_json'] !== null) {
+                $decoded = json_decode((string) $row['metadata_json'], true);
+                if (is_array($decoded)) {
+                    $metadata = $decoded;
+                }
+            }
+            $out[] = [
+                'at' => (string) $row['received_at'],
+                'type' => 'report',
+                'category' => $row['category_slug'] !== null ? (string) $row['category_slug'] : null,
+                'reporter' => $row['reporter_name'] !== null ? (string) $row['reporter_name'] : null,
+                'weight' => (float) $row['weight_at_report'],
+                'metadata' => $metadata,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return list<array<string, mixed>>
+     */
+    private function manualBlockEvents(string $ipBin): array
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT created_at, reason, created_by_user_id '
+            . 'FROM manual_blocks WHERE kind = :kind AND ip_bin = :ip'
+        );
+        $stmt->bindValue('kind', 'ip');
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $stmt->executeQuery()->fetchAllAssociative();
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = [
+                'at' => (string) $row['created_at'],
+                'type' => 'manual_block_added',
+                'reason' => $row['reason'] !== null ? (string) $row['reason'] : null,
+                'created_by_user_id' => $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return list<array<string, mixed>>
+     */
+    private function allowlistEvents(string $ipBin): array
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT created_at, reason, created_by_user_id '
+            . 'FROM allowlist WHERE kind = :kind AND ip_bin = :ip'
+        );
+        $stmt->bindValue('kind', 'ip');
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $stmt->executeQuery()->fetchAllAssociative();
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = [
+                'at' => (string) $row['created_at'],
+                'type' => 'allowlist_added',
+                'reason' => $row['reason'] !== null ? (string) $row['reason'] : null,
+                'created_by_user_id' => $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
+            ];
+        }
+
+        return $out;
+    }
+}

+ 259 - 1
api/src/Infrastructure/Reputation/IpScoreRepository.php

@@ -16,7 +16,7 @@ use Doctrine\DBAL\ParameterType;
  * row-level locking — `ip_scores` rows churn fast and the bulk recompute
  * (M05) is the authority anyway.
  */
-final class IpScoreRepository extends RepositoryBase
+class IpScoreRepository extends RepositoryBase
 {
     public function upsert(
         string $ipBin,
@@ -68,6 +68,264 @@ final class IpScoreRepository extends RepositoryBase
         return $value === false ? null : (float) $value;
     }
 
+    /**
+     * True if any (ip_bin, category_id) row exists with a non-zero score.
+     * The single-IP "is this scored?" check for the admin UI.
+     */
+    public function hasAnyScore(string $ipBin): bool
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT 1 FROM ip_scores WHERE ip_bin = :ip AND score > 0 LIMIT 1'
+        );
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+
+        return $stmt->executeQuery()->fetchOne() !== false;
+    }
+
+    /**
+     * All score rows for one IP — used by the admin IP-detail page to
+     * show the score-per-category breakdown.
+     *
+     * @return list<array{category_id: int, score: float, last_report_at: ?string, report_count_30d: int, recomputed_at: ?string}>
+     */
+    public function scoresForIp(string $ipBin): array
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT category_id, score, last_report_at, report_count_30d, recomputed_at '
+            . 'FROM ip_scores WHERE ip_bin = :ip ORDER BY score DESC'
+        );
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $stmt->executeQuery()->fetchAllAssociative();
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = [
+                'category_id' => (int) $row['category_id'],
+                'score' => (float) $row['score'],
+                'last_report_at' => $row['last_report_at'] !== null ? (string) $row['last_report_at'] : null,
+                'report_count_30d' => (int) $row['report_count_30d'],
+                'recomputed_at' => $row['recomputed_at'] !== null ? (string) $row['recomputed_at'] : null,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * Paginated IP search.
+     *
+     * Filters:
+     *  - q: ip_text substring (uses LIKE 'q%' if it looks like an IP prefix,
+     *    LIKE '%q%' otherwise — slower but small dataset)
+     *  - categoryId: only count IPs with a row in this category (score > 0)
+     *  - minScore / maxScore: filter MAX(score) per IP via HAVING
+     *  - countryCode / asn: filter via LEFT JOIN on ip_enrichment
+     *  - statusManualBins / statusAllowBins / statusScored / statusClean:
+     *    pre-resolved sets for the status filter; the controller maps the
+     *    `status=` query value to one of these.
+     *
+     * Returns paged rows with aggregate columns and a total count.
+     *
+     * @param array{
+     *     q?: ?string,
+     *     category_id?: ?int,
+     *     min_score?: ?float,
+     *     max_score?: ?float,
+     *     country?: ?string,
+     *     asn?: ?int,
+     *     status?: ?string,
+     *     manual_bins?: list<string>,
+     *     allow_bins?: list<string>,
+     * } $filters
+     * @return array{
+     *     items: list<array{ip_bin: string, ip_text: string, max_score: float, last_report_at: ?string, pair_count: int}>,
+     *     total: int,
+     * }
+     */
+    public function searchIps(array $filters, int $limit, int $offset): array
+    {
+        $where = [];
+        $params = [];
+
+        $q = $filters['q'] ?? null;
+        if (is_string($q) && $q !== '') {
+            // Prefix path: bare IPv4 octets like "203." or "203.0.113." use prefix.
+            // For everything else fall back to substring; small enough to scan.
+            if (preg_match('/^[\da-fA-F:.]+$/', $q) === 1) {
+                $where[] = 's.ip_text LIKE :q';
+                $params['q'] = $q . '%';
+            } else {
+                $where[] = 's.ip_text LIKE :q';
+                $params['q'] = '%' . $q . '%';
+            }
+        }
+
+        $catId = $filters['category_id'] ?? null;
+        if (is_int($catId) && $catId > 0) {
+            $where[] = 's.category_id = :catId';
+            $where[] = 's.score > 0';
+            $params['catId'] = $catId;
+        }
+
+        $country = $filters['country'] ?? null;
+        $asn = $filters['asn'] ?? null;
+        $joinEnrichment = (is_string($country) && $country !== '') || (is_int($asn) && $asn > 0);
+        if (is_string($country) && $country !== '') {
+            $where[] = 'e.country_code = :country';
+            $params['country'] = strtoupper($country);
+        }
+        if (is_int($asn) && $asn > 0) {
+            $where[] = 'e.asn = :asn';
+            $params['asn'] = $asn;
+        }
+
+        $manualBins = $filters['manual_bins'] ?? [];
+        $allowBins = $filters['allow_bins'] ?? [];
+        $status = $filters['status'] ?? null;
+        if (is_string($status)) {
+            switch ($status) {
+                case 'manual':
+                    if ($manualBins === []) {
+                        // No matches possible — short-circuit.
+                        return ['items' => [], 'total' => 0];
+                    }
+                    [$inExpr, $inParams] = $this->buildBinIn($manualBins, 'mb');
+                    $where[] = 's.ip_bin IN (' . $inExpr . ')';
+                    $params = array_merge($params, $inParams);
+
+                    break;
+                case 'allowlisted':
+                    if ($allowBins === []) {
+                        return ['items' => [], 'total' => 0];
+                    }
+                    [$inExpr, $inParams] = $this->buildBinIn($allowBins, 'ab');
+                    $where[] = 's.ip_bin IN (' . $inExpr . ')';
+                    $params = array_merge($params, $inParams);
+
+                    break;
+                case 'scored':
+                    $where[] = 's.score > 0';
+
+                    break;
+                case 'clean':
+                    if ($manualBins !== []) {
+                        [$inExpr, $inParams] = $this->buildBinIn($manualBins, 'mb');
+                        $where[] = 's.ip_bin NOT IN (' . $inExpr . ')';
+                        $params = array_merge($params, $inParams);
+                    }
+                    if ($allowBins !== []) {
+                        [$inExpr, $inParams] = $this->buildBinIn($allowBins, 'ab');
+                        $where[] = 's.ip_bin NOT IN (' . $inExpr . ')';
+                        $params = array_merge($params, $inParams);
+                    }
+
+                    break;
+            }
+        }
+
+        $minScore = $filters['min_score'] ?? null;
+        $maxScore = $filters['max_score'] ?? null;
+        $having = [];
+        if ($minScore !== null) {
+            $having[] = 'MAX(s.score) >= :minScore';
+            $params['minScore'] = number_format($minScore, 4, '.', '');
+        }
+        if ($maxScore !== null) {
+            $having[] = 'MAX(s.score) <= :maxScore';
+            $params['maxScore'] = number_format($maxScore, 4, '.', '');
+        }
+
+        $sql = 'SELECT s.ip_bin, s.ip_text, '
+            . 'MAX(s.score) AS max_score, '
+            . 'MAX(s.last_report_at) AS last_report_at, '
+            . 'COUNT(*) AS pair_count '
+            . 'FROM ip_scores s ';
+        if ($joinEnrichment) {
+            $sql .= 'LEFT JOIN ip_enrichment e ON e.ip_bin = s.ip_bin ';
+        }
+        if ($where !== []) {
+            $sql .= 'WHERE ' . implode(' AND ', $where) . ' ';
+        }
+        $sql .= 'GROUP BY s.ip_bin, s.ip_text ';
+        if ($having !== []) {
+            $sql .= 'HAVING ' . implode(' AND ', $having) . ' ';
+        }
+        $sql .= 'ORDER BY max_score DESC, s.ip_bin ASC ';
+        $sql .= 'LIMIT :limit OFFSET :offset';
+
+        // Threading binary IN-list params through: DBAL needs LARGE_OBJECT
+        // type for `ip_bin` blob comparisons on MySQL; SQLite is happy
+        // with strings either way. Mark all parameters whose names start
+        // with "mb" (manual_bins) or "ab" (allow_bins).
+        $itemsParams = $params;
+        $itemsParams['limit'] = $limit;
+        $itemsParams['offset'] = $offset;
+        $itemTypes = ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER];
+        foreach (array_keys($params) as $name) {
+            if (str_starts_with($name, 'mb') || str_starts_with($name, 'ab')) {
+                $itemTypes[$name] = ParameterType::LARGE_OBJECT;
+            }
+        }
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, $itemsParams, $itemTypes);
+
+        // Count: wrap the SELECT (without ORDER/LIMIT) in a subquery.
+        $countSql = 'SELECT COUNT(*) FROM (SELECT s.ip_bin FROM ip_scores s ';
+        if ($joinEnrichment) {
+            $countSql .= 'LEFT JOIN ip_enrichment e ON e.ip_bin = s.ip_bin ';
+        }
+        if ($where !== []) {
+            $countSql .= 'WHERE ' . implode(' AND ', $where) . ' ';
+        }
+        $countSql .= 'GROUP BY s.ip_bin';
+        if ($having !== []) {
+            $countSql .= ' HAVING ' . implode(' AND ', $having);
+        }
+        $countSql .= ') t';
+        $countTypes = [];
+        foreach (array_keys($params) as $name) {
+            if (str_starts_with($name, 'mb') || str_starts_with($name, 'ab')) {
+                $countTypes[$name] = ParameterType::LARGE_OBJECT;
+            }
+        }
+        $total = (int) $this->connection()->fetchOne($countSql, $params, $countTypes);
+
+        $items = [];
+        foreach ($rows as $row) {
+            $items[] = [
+                'ip_bin' => (string) $row['ip_bin'],
+                'ip_text' => (string) $row['ip_text'],
+                'max_score' => (float) $row['max_score'],
+                'last_report_at' => $row['last_report_at'] !== null ? (string) $row['last_report_at'] : null,
+                'pair_count' => (int) $row['pair_count'],
+            ];
+        }
+
+        return ['items' => $items, 'total' => $total];
+    }
+
+    /**
+     * Build a parametrised `(:p0, :p1, …)` clause + matching params map for
+     * a list of binary IPs. Used by the search's status filter.
+     *
+     * @param list<string> $bins
+     * @return array{0: string, 1: array<string, string>}
+     */
+    private function buildBinIn(array $bins, string $prefix): array
+    {
+        $names = [];
+        $params = [];
+        foreach ($bins as $i => $bin) {
+            $name = $prefix . $i;
+            $names[] = ':' . $name;
+            $params[$name] = $bin;
+        }
+
+        return [implode(', ', $names), $params];
+    }
+
     /**
      * All (ip_bin, ip_text, category_id) currently in `ip_scores` whose
      * `recomputed_at` is older than `$staleBefore` (NULLs counted as stale

+ 194 - 0
api/tests/Integration/Admin/IpsControllerTest.php

@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\IpAddress;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Covers SPEC §M09.1: admin IP search + detail. Seeds ip_scores +
+ * reports + manual_blocks + allowlist directly to drive the controller.
+ */
+final class IpsControllerTest extends AppTestCase
+{
+    public function testSearchListsScoredIps(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $this->seedScored('203.0.113.10', 'brute_force', 1.5);
+        $this->seedScored('203.0.113.11', 'brute_force', 0.5);
+
+        $response = $this->request('GET', '/api/v1/admin/ips', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame(2, $body['total']);
+        $ips = array_map(static fn (array $r): string => $r['ip'], $body['items']);
+        self::assertContains('203.0.113.10', $ips);
+        self::assertContains('203.0.113.11', $ips);
+    }
+
+    public function testSearchSortsByMaxScoreDescending(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $this->seedScored('203.0.113.10', 'brute_force', 0.5);
+        $this->seedScored('203.0.113.11', 'brute_force', 5.0);
+
+        $response = $this->request('GET', '/api/v1/admin/ips', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $body = $this->decode($response);
+        self::assertSame('203.0.113.11', $body['items'][0]['ip']);
+        self::assertSame('203.0.113.10', $body['items'][1]['ip']);
+    }
+
+    public function testSearchFiltersByPrefix(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $this->seedScored('203.0.113.10', 'brute_force', 1.0);
+        $this->seedScored('198.51.100.5', 'brute_force', 1.0);
+        $this->seedScored('2001:db8::1', 'brute_force', 1.0);
+
+        $response = $this->request('GET', '/api/v1/admin/ips?q=2001', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $body = $this->decode($response);
+        self::assertSame(1, $body['total']);
+        self::assertSame('2001:db8::1', $body['items'][0]['ip']);
+        self::assertFalse($body['items'][0]['is_ipv4']);
+    }
+
+    public function testSearchFiltersByCategory(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $this->seedScored('203.0.113.10', 'brute_force', 1.0);
+        $this->seedScored('203.0.113.11', 'spam', 1.0);
+
+        $response = $this->request('GET', '/api/v1/admin/ips?category=spam', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $body = $this->decode($response);
+        self::assertSame(1, $body['total']);
+        self::assertSame('203.0.113.11', $body['items'][0]['ip']);
+    }
+
+    public function testSearchPaginates(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        for ($i = 0; $i < 5; ++$i) {
+            $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 10));
+        }
+
+        $page1 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=2', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+        $page2 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=2&page_size=2', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+
+        self::assertSame(5, $page1['total']);
+        self::assertCount(2, $page1['items']);
+        self::assertCount(2, $page2['items']);
+        self::assertNotSame($page1['items'][0]['ip'], $page2['items'][0]['ip']);
+    }
+
+    public function testSearchRejectsUnknownCategory(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/ips?category=bogus', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(400, $response->getStatusCode());
+    }
+
+    public function testDetailReturnsScoresAndStatus(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $reporterId = $this->createReporter('rep-detail');
+        $this->seedScored('203.0.113.42', 'brute_force', 2.0);
+        $this->seedReport('203.0.113.42', 'brute_force', $reporterId);
+
+        $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('203.0.113.42', $body['ip']);
+        self::assertTrue($body['is_ipv4']);
+        self::assertSame('scored', $body['status']);
+        self::assertNotEmpty($body['scores']);
+        self::assertSame('brute_force', $body['scores'][0]['category']);
+        self::assertNotEmpty($body['history']);
+        self::assertSame('report', $body['history'][0]['type']);
+    }
+
+    public function testDetailIncludesEnrichmentNullByDefault(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $this->seedScored('203.0.113.42', 'brute_force', 2.0);
+
+        $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+        self::assertNull($body['enrichment']['country_code']);
+        self::assertNull($body['enrichment']['asn']);
+    }
+
+    public function testDetail404OnInvalidIp(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(404, $response->getStatusCode());
+    }
+
+    public function testDetailRendersForUnknownIpWithCleanStatus(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+        self::assertSame('clean', $body['status']);
+        self::assertSame([], $body['scores']);
+        self::assertSame([], $body['history']);
+    }
+
+    private function seedScored(string $ip, string $categorySlug, float $score): void
+    {
+        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
+        $ipObj = IpAddress::fromString($ip);
+        $stmt = $this->db->prepare(
+            'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
+            . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
+        );
+        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('t', $ipObj->text());
+        $stmt->bindValue('c', $catId, ParameterType::INTEGER);
+        $stmt->bindValue('s', number_format($score, 4, '.', ''));
+        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
+        $stmt->executeStatement();
+    }
+
+    private function seedReport(string $ip, string $categorySlug, int $reporterId): void
+    {
+        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
+        $ipObj = IpAddress::fromString($ip);
+        $stmt = $this->db->prepare(
+            'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
+            . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
+        );
+        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('t', $ipObj->text());
+        $stmt->bindValue('c', $catId, ParameterType::INTEGER);
+        $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
+        $stmt->bindValue('w', '1.00');
+        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
+        $stmt->executeStatement();
+    }
+}

+ 102 - 0
api/tests/Integration/Admin/StatsControllerTest.php

@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\IpAddress;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Covers the dashboard endpoint shape: scalar counters, by-hour
+ * histogram, top reporters/categories, jobs status. Cache TTL is 30s
+ * but the test issues each request with fresh state — we don't
+ * exercise the cache window directly here.
+ */
+final class StatsControllerTest extends AppTestCase
+{
+    public function testDashboardEmptyShape(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/stats/dashboard', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+
+        foreach (['active_blocks', 'manual_blocks_count', 'allowlist_count', 'reports_24h'] as $k) {
+            self::assertArrayHasKey($k, $body);
+            self::assertSame(0, $body[$k]);
+        }
+        self::assertSame([], $body['reports_24h_by_hour']);
+        self::assertSame([], $body['top_reporters_24h']);
+        self::assertSame([], $body['top_categories_24h']);
+        self::assertSame('moderate', $body['reference_policy']);
+    }
+
+    public function testDashboardCountsFromSeededReports(): void
+    {
+        // Use a fresh class-level container/session per test (setUp does this).
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $reporterId = $this->createReporter('rep-stats');
+
+        for ($i = 0; $i < 3; ++$i) {
+            $this->seedReport(sprintf('203.0.113.%d', 10 + $i), 'brute_force', $reporterId);
+        }
+        $this->seedReport('203.0.113.20', 'spam', $reporterId);
+
+        $body = $this->decode($this->request('GET', '/api/v1/admin/stats/dashboard', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+
+        self::assertSame(4, $body['reports_24h']);
+        self::assertNotEmpty($body['reports_24h_by_hour']);
+        self::assertSame('rep-stats', $body['top_reporters_24h'][0]['name']);
+        self::assertSame(4, $body['top_reporters_24h'][0]['count']);
+        $catSlugs = array_map(static fn (array $r): string => $r['slug'], $body['top_categories_24h']);
+        self::assertContains('brute_force', $catSlugs);
+        self::assertContains('spam', $catSlugs);
+    }
+
+    public function testManualBlocksAndAllowlistCounters(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $this->db->insert('manual_blocks', [
+            'kind' => 'ip',
+            'ip_bin' => IpAddress::fromString('203.0.113.5')->binary(),
+            'reason' => 'x',
+        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
+        $this->db->insert('allowlist', [
+            'kind' => 'ip',
+            'ip_bin' => IpAddress::fromString('203.0.113.6')->binary(),
+            'reason' => 'y',
+        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
+
+        $body = $this->decode($this->request('GET', '/api/v1/admin/stats/dashboard', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+
+        self::assertSame(1, $body['manual_blocks_count']);
+        self::assertSame(1, $body['allowlist_count']);
+    }
+
+    private function seedReport(string $ip, string $categorySlug, int $reporterId): void
+    {
+        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
+        $ipObj = IpAddress::fromString($ip);
+        $stmt = $this->db->prepare(
+            'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
+            . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
+        );
+        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('t', $ipObj->text());
+        $stmt->bindValue('c', $catId, ParameterType::INTEGER);
+        $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
+        $stmt->bindValue('w', '1.00');
+        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
+        $stmt->executeStatement();
+    }
+}

+ 39 - 6
api/tests/Unit/Reputation/EffectiveStatusServiceTest.php

@@ -10,12 +10,14 @@ use App\Domain\Reputation\CidrEvaluator;
 use App\Domain\Reputation\EffectiveStatus;
 use App\Domain\Reputation\EffectiveStatusService;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Infrastructure\Reputation\IpScoreRepository;
+use Doctrine\DBAL\Connection;
 use PHPUnit\Framework\TestCase;
 
 /**
  * Locks the SPEC §5 precedence: allowlist > manual block > scored > clean.
- * Score-vs-policy lands in M07; until then, anything not on a list is
- * `Clean`.
+ * M09 wires `Scored` to "any non-zero score in `ip_scores`" via a stub
+ * IpScoreRepository here; the integration tests cover the real path.
  */
 final class EffectiveStatusServiceTest extends TestCase
 {
@@ -29,7 +31,7 @@ final class EffectiveStatusServiceTest extends TestCase
             allowlistSubnets: [],
         ));
 
-        $service = new EffectiveStatusService($factory);
+        $service = new EffectiveStatusService($factory, $this->scoreRepoWithoutScores());
         self::assertSame(
             EffectiveStatus::Allowlisted,
             $service->forIp(IpAddress::fromString('198.51.100.5'))
@@ -41,7 +43,7 @@ final class EffectiveStatusServiceTest extends TestCase
         $bin = IpAddress::fromString('203.0.113.42')->binary();
         $factory = $this->factoryReturning(new CidrEvaluator([$bin], [], [], []));
 
-        $service = new EffectiveStatusService($factory);
+        $service = new EffectiveStatusService($factory, $this->scoreRepoWithoutScores());
         self::assertSame(
             EffectiveStatus::ManuallyBlocked,
             $service->forIp(IpAddress::fromString('203.0.113.42'))
@@ -58,17 +60,28 @@ final class EffectiveStatusServiceTest extends TestCase
             allowlistSubnets: [$allow],
         ));
 
-        $service = new EffectiveStatusService($factory);
+        $service = new EffectiveStatusService($factory, $this->scoreRepoWithoutScores());
         self::assertSame(
             EffectiveStatus::Allowlisted,
             $service->forIp(IpAddress::fromString('203.0.113.42'))
         );
     }
 
+    public function testScoredWhenScoreRepoHasRowsAndNoOverrides(): void
+    {
+        $factory = $this->factoryReturning(new CidrEvaluator([], [], [], []));
+        $service = new EffectiveStatusService($factory, $this->scoreRepoWithScores());
+
+        self::assertSame(
+            EffectiveStatus::Scored,
+            $service->forIp(IpAddress::fromString('203.0.113.99'))
+        );
+    }
+
     public function testCleanWhenNothingMatches(): void
     {
         $factory = $this->factoryReturning(new CidrEvaluator([], [], [], []));
-        $service = new EffectiveStatusService($factory);
+        $service = new EffectiveStatusService($factory, $this->scoreRepoWithoutScores());
 
         self::assertSame(
             EffectiveStatus::Clean,
@@ -96,4 +109,24 @@ final class EffectiveStatusServiceTest extends TestCase
             }
         };
     }
+
+    private function scoreRepoWithoutScores(): IpScoreRepository
+    {
+        return new class ($this->createMock(Connection::class)) extends IpScoreRepository {
+            public function hasAnyScore(string $ipBin): bool
+            {
+                return false;
+            }
+        };
+    }
+
+    private function scoreRepoWithScores(): IpScoreRepository
+    {
+        return new class ($this->createMock(Connection::class)) extends IpScoreRepository {
+            public function hasAnyScore(string $ipBin): bool
+            {
+                return true;
+            }
+        };
+    }
 }

+ 19 - 0
ui/package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.1.0",
       "dependencies": {
         "alpinejs": "^3.13.0",
+        "chart.js": "^4.4.0",
         "htmx.org": "^1.9.0"
       },
       "devDependencies": {
@@ -512,6 +513,12 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@kurkle/color": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+      "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+      "license": "MIT"
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -743,6 +750,18 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/chart.js": {
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+      "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+      "license": "MIT",
+      "dependencies": {
+        "@kurkle/color": "^0.3.0"
+      },
+      "engines": {
+        "pnpm": ">=8"
+      }
+    },
     "node_modules/chokidar": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",

+ 1 - 0
ui/package.json

@@ -19,6 +19,7 @@
   },
   "dependencies": {
     "alpinejs": "^3.13.0",
+    "chart.js": "^4.4.0",
     "htmx.org": "^1.9.0"
   }
 }

+ 47 - 0
ui/resources/js/app.js

@@ -1,5 +1,6 @@
 import Alpine from 'alpinejs';
 import 'htmx.org';
+import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Title } from 'chart.js';
 
 // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
 // initial paint; this just wires the toggle button.
@@ -31,5 +32,51 @@ document.body.addEventListener('htmx:configRequest', (e) => {
     }
 });
 
+// Dashboard reports-per-hour chart. The canvas carries the buckets in a
+// `data-buckets` attribute (server-pre-bucketed; no AJAX). Chart.js is
+// tree-shaken to just the bar/linear pieces we need so the bundle stays
+// small.
+Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Title);
+
+function renderReportsChart() {
+    const canvas = document.getElementById('reports-chart');
+    if (!canvas) return;
+    let buckets = [];
+    try {
+        buckets = JSON.parse(canvas.dataset.buckets || '[]');
+    } catch (e) {
+        return;
+    }
+
+    const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
+    const data = buckets.map((b) => b.count || 0);
+    const isDark = document.documentElement.classList.contains('dark');
+    const tickColor = isDark ? '#94a3b8' : '#475569';
+    const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)';
+
+    new Chart(canvas, {
+        type: 'bar',
+        data: {
+            labels,
+            datasets: [{
+                label: 'reports',
+                data,
+                backgroundColor: '#6366f1',
+            }],
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            plugins: { legend: { display: false } },
+            scales: {
+                x: { ticks: { color: tickColor }, grid: { color: gridColor } },
+                y: { ticks: { color: tickColor, precision: 0 }, grid: { color: gridColor }, beginAtZero: true },
+            },
+        },
+    });
+}
+
+document.addEventListener('DOMContentLoaded', renderReportsChart);
+
 window.Alpine = Alpine;
 Alpine.start();

+ 117 - 0
ui/resources/views/pages/dashboard.twig

@@ -0,0 +1,117 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Dashboard — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-6xl">
+    <div class="flex items-center justify-between">
+        <div>
+            <h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
+            <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
+                Last 24 hours, refreshed every 30 s.
+                {% if stats %}<span class="font-mono">reference policy: {{ stats.referencePolicy }}</span>{% endif %}
+            </p>
+        </div>
+    </div>
+
+    {% if not api_reachable %}
+        <div class="mt-4 rounded-md border border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
+            API unreachable; counters cannot be loaded right now.
+        </div>
+    {% endif %}
+
+    {% if stats %}
+        <section class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
+            {% set cards = [
+                { label: 'Active blocks',     value: stats.activeBlocks,       hint: 'IPs with score > 0 + manual single IPs' },
+                { label: 'Manual blocks',     value: stats.manualBlocksCount,  hint: 'across IPs and subnets' },
+                { label: 'Allowlist entries', value: stats.allowlistCount,     hint: 'IPs and subnets' },
+                { label: 'Reports (24h)',     value: stats.reports24h,         hint: 'all categories' },
+            ] %}
+            {% for card in cards %}
+                <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+                    <div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">{{ card.label }}</div>
+                    <div class="mt-2 font-mono text-3xl font-semibold">{{ card.value }}</div>
+                    <div class="mt-1 text-xs text-slate-400">{{ card.hint }}</div>
+                </div>
+            {% endfor %}
+        </section>
+
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Reports per hour</h2>
+            <div class="mt-3 h-64">
+                <canvas id="reports-chart"
+                        data-buckets="{{ stats.reportsByHour|json_encode|e('html_attr') }}">
+                </canvas>
+            </div>
+        </section>
+
+        <section class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
+            <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Top reporters (24h)</h2>
+                {% if stats.topReporters|length > 0 %}
+                    <table class="mt-3 w-full text-sm">
+                        <thead class="text-left text-xs uppercase tracking-wider text-slate-400">
+                            <tr><th class="pb-2 font-medium">Reporter</th><th class="pb-2 text-right font-medium">Reports</th></tr>
+                        </thead>
+                        <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                            {% for r in stats.topReporters %}
+                                <tr><td class="py-1.5 font-mono">{{ r.name }}</td><td class="py-1.5 text-right font-mono">{{ r.count }}</td></tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                {% else %}
+                    <p class="mt-2 text-sm text-slate-400">No reports in the last 24 hours.</p>
+                {% endif %}
+            </div>
+
+            <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Top categories (24h)</h2>
+                {% if stats.topCategories|length > 0 %}
+                    <table class="mt-3 w-full text-sm">
+                        <thead class="text-left text-xs uppercase tracking-wider text-slate-400">
+                            <tr><th class="pb-2 font-medium">Category</th><th class="pb-2 text-right font-medium">Reports</th></tr>
+                        </thead>
+                        <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                            {% for c in stats.topCategories %}
+                                <tr><td class="py-1.5 font-mono">{{ c.slug }}</td><td class="py-1.5 text-right font-mono">{{ c.count }}</td></tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                {% else %}
+                    <p class="mt-2 text-sm text-slate-400">No reports in the last 24 hours.</p>
+                {% endif %}
+            </div>
+        </section>
+
+        <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Jobs status</h2>
+            {% if stats.jobsStatus|length > 0 %}
+                <ul class="mt-3 space-y-1 text-sm">
+                    {% for job in stats.jobsStatus %}
+                        <li class="flex items-center justify-between">
+                            <span class="font-mono">{{ job.name }}</span>
+                            <span class="flex items-center gap-2">
+                                <span class="rounded px-2 py-0.5 text-xs uppercase
+                                    {% if job.status == 'success' %}bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100
+                                    {% elseif job.status == 'failure' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100
+                                    {% else %}bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300{% endif %}">
+                                    {{ job.status }}
+                                </span>
+                                {% if job.overdue %}
+                                    <span class="rounded bg-amber-100 px-2 py-0.5 text-xs uppercase text-amber-800 dark:bg-amber-900 dark:text-amber-100">overdue</span>
+                                {% endif %}
+                                <span class="text-xs text-slate-500 dark:text-slate-400">
+                                    {{ job.last_finished_at|default('never') }}
+                                </span>
+                            </span>
+                        </li>
+                    {% endfor %}
+                </ul>
+            {% else %}
+                <p class="mt-2 text-sm text-slate-400">No job runs recorded yet.</p>
+            {% endif %}
+        </section>
+    {% endif %}
+</div>
+{% endblock %}

+ 8 - 5
ui/resources/views/pages/error.twig

@@ -2,14 +2,14 @@
 
 {% block title %}Error {{ status }} — IRDB{% endblock %}
 
-{% block guest_content %}
-<div class="flex min-h-screen items-center justify-center bg-slate-50 px-4 dark:bg-slate-950">
+{% set _error_card %}
+<div class="flex min-h-[60vh] items-center justify-center px-4">
     <div class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
         <div class="font-mono text-5xl font-bold tracking-tight text-slate-400 dark:text-slate-600">{{ status }}</div>
         <h1 class="mt-3 text-xl font-semibold">
-            {% if is_client_error %}Something's not right with that request{% else %}We hit an error processing this request{% endif %}
+            {% if is_client_error %}{{ message|default("Something's not right with that request") }}{% else %}We hit an error processing this request{% endif %}
         </h1>
-        {% if message %}
+        {% if message and not is_client_error %}
             <p class="mt-3 break-words text-left font-mono text-xs text-slate-600 dark:text-slate-400">{{ message }}</p>
         {% endif %}
         <div class="mt-6 flex justify-center gap-3">
@@ -17,4 +17,7 @@
         </div>
     </div>
 </div>
-{% endblock %}
+{% endset %}
+
+{% block content %}{{ _error_card|raw }}{% endblock %}
+{% block guest_content %}{{ _error_card|raw }}{% endblock %}

+ 130 - 0
ui/resources/views/pages/ips/detail.twig

@@ -0,0 +1,130 @@
+{% extends 'layout.twig' %}
+
+{% block title %}{{ detail.ip }} — IRDB{% endblock %}
+
+{% macro flag(country) %}
+    {%- if country and country|length == 2 -%}
+        {%- set code = country|upper -%}
+        {{- '🇦' ~ code[0:1] ~ '🇦' ~ code[1:2] -}}
+    {%- else -%}
+        <span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.65rem] text-slate-500 dark:bg-slate-800 dark:text-slate-400">??</span>
+    {%- endif -%}
+{% endmacro %}
+
+{% macro status_pill(status) %}
+    {%- set classes = {
+        'allowlisted':      'bg-emerald-100 text-emerald-900 dark:bg-emerald-900 dark:text-emerald-100',
+        'manually_blocked': 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
+        'scored':           'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
+        'clean':            'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
+    } -%}
+    <span class="rounded px-2.5 py-1 font-mono text-xs uppercase {{ classes[status]|default('bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') }}">{{ status }}</span>
+{% endmacro %}
+
+{% block content %}
+{% import _self as h %}
+<div class="mx-auto max-w-5xl">
+    <a href="/app/ips" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to IPs</a>
+
+    <div class="mt-3 flex items-center justify-between">
+        <h1 class="font-mono text-2xl font-semibold tracking-tight">{{ detail.ip }}</h1>
+        {{ h.status_pill(detail.status) }}
+    </div>
+    <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ detail.isIpv4 ? 'IPv4' : 'IPv6' }}</p>
+
+    <section class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
+        <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Enrichment</h2>
+            {% if detail.enrichment.country_code or detail.enrichment.asn %}
+                <dl class="mt-3 grid grid-cols-3 gap-y-2 text-sm">
+                    <dt class="text-slate-500 dark:text-slate-400">Country</dt>
+                    <dd class="col-span-2 font-mono">{{ h.flag(detail.enrichment.country_code) }} {{ detail.enrichment.country_code|default('—') }}</dd>
+                    <dt class="text-slate-500 dark:text-slate-400">ASN</dt>
+                    <dd class="col-span-2 font-mono">{{ detail.enrichment.asn|default('—') }}</dd>
+                    <dt class="text-slate-500 dark:text-slate-400">AS org</dt>
+                    <dd class="col-span-2">{{ detail.enrichment.as_org|default('—') }}</dd>
+                </dl>
+            {% else %}
+                <p class="mt-3 text-sm text-slate-400">Not yet enriched (data lands once the GeoIP job runs in M11).</p>
+            {% endif %}
+        </div>
+
+        <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Override status</h2>
+            {% if detail.allowlist %}
+                <p class="mt-3 text-sm">Allowlisted since
+                    <time class="font-mono">{{ detail.allowlist.created_at }}</time>.
+                    {% if detail.allowlist.reason %}<br><span class="text-slate-500 dark:text-slate-400">Reason:</span> {{ detail.allowlist.reason }}{% endif %}
+                </p>
+            {% elseif detail.manualBlock %}
+                <p class="mt-3 text-sm">Manually blocked since
+                    <time class="font-mono">{{ detail.manualBlock.created_at }}</time>.
+                    {% if detail.manualBlock.reason %}<br><span class="text-slate-500 dark:text-slate-400">Reason:</span> {{ detail.manualBlock.reason }}{% endif %}
+                </p>
+            {% else %}
+                <p class="mt-3 text-sm text-slate-400">No manual override on this IP.</p>
+            {% endif %}
+        </div>
+    </section>
+
+    <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Score per category</h2>
+        {% if detail.scores|length > 0 %}
+            {% set max_score = detail.maxScore() %}
+            <ul class="mt-3 space-y-3 text-sm">
+                {% for s in detail.scores %}
+                    {% set width_pct = max_score > 0 ? (s.score / max_score * 100) : 0 %}
+                    <li>
+                        <div class="flex items-baseline justify-between">
+                            <span class="font-mono">{{ s.category|default('?') }}</span>
+                            <span class="font-mono text-slate-600 dark:text-slate-300">{{ s.score|number_format(2) }} <span class="text-xs text-slate-400">({{ s.report_count_30d }} in 30d)</span></span>
+                        </div>
+                        <div class="mt-1 h-1.5 overflow-hidden rounded bg-slate-100 dark:bg-slate-800">
+                            <div class="h-full bg-indigo-500" style="width: {{ width_pct }}%"></div>
+                        </div>
+                    </li>
+                {% endfor %}
+            </ul>
+        {% else %}
+            <p class="mt-3 text-sm text-slate-400">No scored categories.</p>
+        {% endif %}
+    </section>
+
+    <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">History</h2>
+        {% if detail.history|length > 0 %}
+            <ol class="mt-3 space-y-3 text-sm">
+                {% for ev in detail.history %}
+                    <li class="border-l-2 border-slate-200 pl-3 dark:border-slate-800">
+                        <div class="flex items-baseline justify-between">
+                            <span class="font-mono text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
+                                {{ ev.type }}
+                            </span>
+                            <time class="font-mono text-xs text-slate-400">{{ ev.at }}</time>
+                        </div>
+                        {% if ev.type == 'report' %}
+                            <p class="mt-1">
+                                {% if ev.category %}<span class="font-mono">{{ ev.category }}</span>{% endif %}
+                                {% if ev.reporter %}<span class="text-slate-500 dark:text-slate-400"> via {{ ev.reporter }}</span>{% endif %}
+                                {% if ev.weight %}<span class="text-slate-400"> · w={{ ev.weight }}</span>{% endif %}
+                            </p>
+                            {% if ev.metadata %}
+                                <pre class="mt-1 overflow-x-auto rounded bg-slate-50 p-2 text-xs dark:bg-slate-950">{{ ev.metadata|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
+                            {% endif %}
+                        {% elseif ev.type == 'manual_block_added' %}
+                            <p class="mt-1">Manual block added{% if ev.reason %}: <span class="text-slate-600 dark:text-slate-300">{{ ev.reason }}</span>{% endif %}</p>
+                        {% elseif ev.type == 'allowlist_added' %}
+                            <p class="mt-1">Allowlist entry added{% if ev.reason %}: <span class="text-slate-600 dark:text-slate-300">{{ ev.reason }}</span>{% endif %}</p>
+                        {% endif %}
+                    </li>
+                {% endfor %}
+            </ol>
+            {% if detail.hasMore %}
+                <p class="mt-3 text-xs text-slate-500 dark:text-slate-400">Showing the most recent 200 events. Older events are available via the API directly until the in-app pagination lands in a future milestone.</p>
+            {% endif %}
+        {% else %}
+            <p class="mt-3 text-sm text-slate-400">No history yet.</p>
+        {% endif %}
+    </section>
+</div>
+{% endblock %}

+ 143 - 0
ui/resources/views/pages/ips/index.twig

@@ -0,0 +1,143 @@
+{% extends 'layout.twig' %}
+
+{% block title %}IPs — IRDB{% endblock %}
+
+{% macro flag(country) %}
+    {%- if country and country|length == 2 -%}
+        {%- set code = country|upper -%}
+        {%- set first = code[0:1] -%}
+        {%- set second = code[1:2] -%}
+        {{- '🇦' ~ first ~ '🇦' ~ second -}}
+    {%- else -%}
+        <span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.65rem] text-slate-500 dark:bg-slate-800 dark:text-slate-400">??</span>
+    {%- endif -%}
+{% endmacro %}
+
+{% macro status_pill(status) %}
+    {%- set classes = {
+        'allowlisted':      'bg-emerald-100 text-emerald-900 dark:bg-emerald-900 dark:text-emerald-100',
+        'manually_blocked': 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
+        'scored':           'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
+        'clean':            'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
+        'manual':           'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
+    } -%}
+    <span class="rounded px-2 py-0.5 font-mono text-[0.65rem] uppercase {{ classes[status]|default('bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') }}">{{ status }}</span>
+{% endmacro %}
+
+{% block content %}
+{% import _self as h %}
+<div class="mx-auto max-w-6xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">IPs</h1>
+        {% if list %}
+            <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total }} total</span>
+        {% endif %}
+    </div>
+
+    {% if error %}
+        <div class="mt-4 rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">{{ error }}</div>
+    {% endif %}
+
+    <form method="get" action="/app/ips" class="mt-4 grid grid-cols-2 gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-sm dark:border-slate-800 dark:bg-slate-900 md:grid-cols-7">
+        <div class="col-span-2">
+            <label for="f-q" class="block text-xs font-medium text-slate-600 dark:text-slate-400">IP / prefix</label>
+            <input type="search" id="f-q" name="q" value="{{ filters.q|default('') }}" placeholder="203.0.113."
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div>
+            <label for="f-cat" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Category</label>
+            <select id="f-cat" name="category" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                <option value="">— any —</option>
+                {% for c in categories %}
+                    <option value="{{ c }}" {% if filters.category == c %}selected{% endif %}>{{ c }}</option>
+                {% endfor %}
+            </select>
+        </div>
+        <div>
+            <label for="f-min" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Min score</label>
+            <input type="number" id="f-min" name="min_score" step="0.01" min="0" value="{{ filters.min_score|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div>
+            <label for="f-max" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Max score</label>
+            <input type="number" id="f-max" name="max_score" step="0.01" min="0" value="{{ filters.max_score|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div>
+            <label for="f-country" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Country</label>
+            <input type="text" id="f-country" name="country" maxlength="2" value="{{ filters.country|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono text-sm uppercase dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div>
+            <label for="f-asn" class="block text-xs font-medium text-slate-600 dark:text-slate-400">ASN</label>
+            <input type="number" id="f-asn" name="asn" min="1" value="{{ filters.asn|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div class="col-span-2 md:col-span-7 flex flex-wrap items-end justify-between gap-3">
+            <div class="flex items-center gap-2">
+                <label for="f-status" class="text-xs font-medium text-slate-600 dark:text-slate-400">Status</label>
+                <select id="f-status" name="status" class="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                    <option value="">any</option>
+                    {% for s in statuses %}
+                        <option value="{{ s }}" {% if filters.status == s %}selected{% endif %}>{{ s }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+            <div class="flex gap-2">
+                <a href="/app/ips" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Reset</a>
+                <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Filter</button>
+            </div>
+        </div>
+    </form>
+
+    {% if list %}
+        <div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <table class="w-full text-sm">
+                <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                    <tr>
+                        <th class="px-4 py-2 font-medium">IP</th>
+                        <th class="px-4 py-2 font-medium">Country</th>
+                        <th class="px-4 py-2 font-medium">ASN</th>
+                        <th class="px-4 py-2 font-medium">Top category</th>
+                        <th class="px-4 py-2 text-right font-medium">Max score</th>
+                        <th class="px-4 py-2 font-medium">Last report</th>
+                        <th class="px-4 py-2 font-medium">Status</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                    {% for item in list.items %}
+                        <tr>
+                            <td class="px-4 py-2"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
+                            <td class="px-4 py-2">{{ h.flag(item.enrichment.country_code|default('')) }}</td>
+                            <td class="px-4 py-2 font-mono text-slate-500">{{ item.enrichment.asn|default('—') }}</td>
+                            <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ item.topCategory|default('—') }}</td>
+                            <td class="px-4 py-2 text-right font-mono">{{ item.maxScore|number_format(2) }}</td>
+                            <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ item.lastReportAt|default('—') }}</td>
+                            <td class="px-4 py-2">{{ h.status_pill(item.status) }}</td>
+                        </tr>
+                    {% else %}
+                        <tr><td colspan="7" class="px-4 py-6 text-center text-slate-400">No results.</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+
+        {% if list.total > list.pageSize %}
+            {% set total_pages = list.totalPages() %}
+            <nav class="mt-4 flex items-center justify-between text-sm">
+                <span class="text-slate-500 dark:text-slate-400">Page {{ page }} of {{ total_pages }}</span>
+                <div class="flex gap-2">
+                    {% set prev_qs = filters|merge({'page': page - 1}) %}
+                    {% set next_qs = filters|merge({'page': page + 1}) %}
+                    {% if page > 1 %}
+                        <a href="/app/ips?{{ prev_qs|url_encode }}" class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">‹ Prev</a>
+                    {% endif %}
+                    {% if page < total_pages %}
+                        <a href="/app/ips?{{ next_qs|url_encode }}" class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Next ›</a>
+                    {% endif %}
+                </div>
+            </nav>
+        {% endif %}
+    {% endif %}
+</div>
+{% endblock %}

+ 6 - 4
ui/resources/views/partials/sidebar.twig

@@ -1,9 +1,8 @@
 <aside class="hidden w-56 border-r border-slate-200 bg-white px-3 py-6 text-sm dark:border-slate-800 dark:bg-slate-950 md:block">
     <nav class="flex flex-col gap-1">
         {% set links = [
-            { href: '/app/me', label: 'My identity' },
-            { href: '#', label: 'Dashboard',  upcoming: 'M09' },
-            { href: '#', label: 'IPs',        upcoming: 'M09' },
+            { href: '/app/dashboard', label: 'Dashboard',  section: 'dashboard' },
+            { href: '/app/ips',       label: 'IPs',        section: 'ips' },
             { href: '#', label: 'Subnets',    upcoming: 'M10' },
             { href: '#', label: 'Allowlist',  upcoming: 'M10' },
             { href: '#', label: 'Policies',   upcoming: 'M10' },
@@ -13,6 +12,7 @@
             { href: '#', label: 'Categories', upcoming: 'M10' },
             { href: '#', label: 'Audit',      upcoming: 'M12' },
             { href: '#', label: 'Settings',   upcoming: 'M12' },
+            { href: '/app/me',        label: 'My identity', section: 'me' },
         ] %}
         {% for link in links %}
             {% if link.upcoming is defined %}
@@ -21,8 +21,10 @@
                     <span class="font-mono text-[0.6rem] uppercase tracking-wider">{{ link.upcoming }}</span>
                 </span>
             {% else %}
+                {% set is_active = (active_section is defined and active_section == link.section) %}
                 <a href="{{ link.href }}"
-                   class="rounded-md px-3 py-1.5 text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800">
+                   class="rounded-md px-3 py-1.5 {% if is_active %}bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300{% else %}text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800{% endif %}"
+                   {% if is_active %}aria-current="page"{% endif %}>
                     {{ link.label }}
                 </a>
             {% endif %}

+ 36 - 2
ui/src/ApiClient/AdminClient.php

@@ -4,6 +4,9 @@ declare(strict_types=1);
 
 namespace App\ApiClient;
 
+use App\ApiClient\DTOs\DashboardStatsDto;
+use App\ApiClient\DTOs\IpDetailDto;
+use App\ApiClient\DTOs\IpListDto;
 use App\ApiClient\DTOs\UserDto;
 
 /**
@@ -12,8 +15,9 @@ use App\ApiClient\DTOs\UserDto;
  * api uses that to resolve the impersonated user's role and enforce
  * RBAC.
  *
- * SPEC §M08.3: M08 only needs `getMe()`. M09–M12 add the rest. Don't
- * pre-implement methods nothing calls; that's how stale code accumulates.
+ * Method surface grows with each milestone. M10–M12 add CRUD over
+ * policies, tokens, audit, settings; only the read-only endpoints are
+ * here for M09.
  */
 final class AdminClient
 {
@@ -27,4 +31,34 @@ final class AdminClient
 
         return UserDto::fromArray($payload);
     }
+
+    /**
+     * @param array<string, mixed> $filters {q, category, min_score, max_score, country, asn, status}
+     */
+    public function searchIps(int $actingUserId, array $filters, int $page = 1, int $pageSize = 25): IpListDto
+    {
+        $query = ['page' => $page, 'page_size' => $pageSize];
+        foreach (['q', 'category', 'min_score', 'max_score', 'country', 'asn', 'status'] as $key) {
+            if (isset($filters[$key]) && $filters[$key] !== '' && $filters[$key] !== null) {
+                $query[$key] = $filters[$key];
+            }
+        }
+        $payload = $this->api->request('GET', '/api/v1/admin/ips', ['query' => $query], $actingUserId);
+
+        return IpListDto::fromArray($payload);
+    }
+
+    public function getIp(int $actingUserId, string $ip): IpDetailDto
+    {
+        $payload = $this->api->request('GET', '/api/v1/admin/ips/' . rawurlencode($ip), [], $actingUserId);
+
+        return IpDetailDto::fromArray($payload);
+    }
+
+    public function getDashboardStats(int $actingUserId): DashboardStatsDto
+    {
+        $payload = $this->api->request('GET', '/api/v1/admin/stats/dashboard', [], $actingUserId);
+
+        return DashboardStatsDto::fromArray($payload);
+    }
 }

+ 69 - 0
ui/src/ApiClient/DTOs/DashboardStatsDto.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiClient\DTOs;
+
+/**
+ * Mirrors `GET /api/v1/admin/stats/dashboard`. The dashboard template
+ * binds onto this directly — no per-page munging.
+ */
+final class DashboardStatsDto
+{
+    /**
+     * @param list<array<string, mixed>> $reportsByHour entries shaped {hour, count}
+     * @param list<array<string, mixed>> $topReporters  entries shaped {name, count}
+     * @param list<array<string, mixed>> $topCategories entries shaped {slug, count}
+     * @param list<array<string, mixed>> $jobsStatus    entries shaped {name, last_finished_at, status, overdue}
+     */
+    public function __construct(
+        public readonly int $activeBlocks,
+        public readonly int $manualBlocksCount,
+        public readonly int $allowlistCount,
+        public readonly int $reports24h,
+        public readonly array $reportsByHour,
+        public readonly array $topReporters,
+        public readonly array $topCategories,
+        public readonly array $jobsStatus,
+        public readonly string $referencePolicy,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    public static function fromArray(array $payload): self
+    {
+        return new self(
+            activeBlocks: (int) ($payload['active_blocks'] ?? 0),
+            manualBlocksCount: (int) ($payload['manual_blocks_count'] ?? 0),
+            allowlistCount: (int) ($payload['allowlist_count'] ?? 0),
+            reports24h: (int) ($payload['reports_24h'] ?? 0),
+            reportsByHour: self::extractList($payload, 'reports_24h_by_hour'),
+            topReporters: self::extractList($payload, 'top_reporters_24h'),
+            topCategories: self::extractList($payload, 'top_categories_24h'),
+            jobsStatus: self::extractList($payload, 'jobs_status'),
+            referencePolicy: (string) ($payload['reference_policy'] ?? 'moderate'),
+        );
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     * @return list<array<string, mixed>>
+     */
+    private static function extractList(array $payload, string $key): array
+    {
+        $value = $payload[$key] ?? [];
+        if (!is_array($value)) {
+            return [];
+        }
+        $out = [];
+        foreach ($value as $row) {
+            if (is_array($row)) {
+                $out[] = $row;
+            }
+        }
+
+        return $out;
+    }
+}

+ 107 - 0
ui/src/ApiClient/DTOs/IpDetailDto.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiClient\DTOs;
+
+/**
+ * Per-IP detail payload from `GET /api/v1/admin/ips/{ip}`. Carries
+ * everything the detail page renders: scores per category, enrichment
+ * (null until M11), manual/allowlist panels, history timeline.
+ */
+final class IpDetailDto
+{
+    /**
+     * @param list<array<string, mixed>> $scores entries shaped {category, category_id, score, last_report_at, report_count_30d}
+     * @param array{country_code: ?string, asn: ?int, as_org: ?string, enriched_at: ?string} $enrichment
+     * @param array<string, mixed>|null $manualBlock
+     * @param array<string, mixed>|null $allowlist
+     * @param list<array<string, mixed>> $history
+     */
+    public function __construct(
+        public readonly string $ip,
+        public readonly bool $isIpv4,
+        public readonly array $scores,
+        public readonly array $enrichment,
+        public readonly string $status,
+        public readonly ?array $manualBlock,
+        public readonly ?array $allowlist,
+        public readonly array $history,
+        public readonly bool $hasMore,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    public static function fromArray(array $payload): self
+    {
+        /** @var list<array<string, mixed>> $scores */
+        $scores = [];
+        if (isset($payload['scores']) && is_array($payload['scores'])) {
+            foreach ($payload['scores'] as $row) {
+                if (!is_array($row)) {
+                    continue;
+                }
+                $scores[] = [
+                    'category' => isset($row['category']) && $row['category'] !== null ? (string) $row['category'] : null,
+                    'category_id' => (int) ($row['category_id'] ?? 0),
+                    'score' => (float) ($row['score'] ?? 0),
+                    'last_report_at' => isset($row['last_report_at']) && $row['last_report_at'] !== null ? (string) $row['last_report_at'] : null,
+                    'report_count_30d' => (int) ($row['report_count_30d'] ?? 0),
+                ];
+            }
+        }
+
+        $enrichment = [
+            'country_code' => null,
+            'asn' => null,
+            'as_org' => null,
+            'enriched_at' => null,
+        ];
+        if (isset($payload['enrichment']) && is_array($payload['enrichment'])) {
+            foreach (['country_code', 'as_org', 'enriched_at'] as $key) {
+                if (isset($payload['enrichment'][$key]) && $payload['enrichment'][$key] !== null) {
+                    $enrichment[$key] = (string) $payload['enrichment'][$key];
+                }
+            }
+            if (isset($payload['enrichment']['asn']) && $payload['enrichment']['asn'] !== null) {
+                $enrichment['asn'] = (int) $payload['enrichment']['asn'];
+            }
+        }
+
+        /** @var list<array<string, mixed>> $history */
+        $history = [];
+        if (isset($payload['history']) && is_array($payload['history'])) {
+            foreach ($payload['history'] as $row) {
+                if (is_array($row)) {
+                    $history[] = $row;
+                }
+            }
+        }
+
+        return new self(
+            ip: (string) ($payload['ip'] ?? ''),
+            isIpv4: (bool) ($payload['is_ipv4'] ?? false),
+            scores: $scores,
+            enrichment: $enrichment,
+            status: (string) ($payload['status'] ?? 'clean'),
+            manualBlock: isset($payload['manual_block']) && is_array($payload['manual_block']) ? $payload['manual_block'] : null,
+            allowlist: isset($payload['allowlist']) && is_array($payload['allowlist']) ? $payload['allowlist'] : null,
+            history: $history,
+            hasMore: (bool) ($payload['has_more'] ?? false),
+        );
+    }
+
+    public function maxScore(): float
+    {
+        $max = 0.0;
+        foreach ($this->scores as $row) {
+            if ($row['score'] > $max) {
+                $max = $row['score'];
+            }
+        }
+
+        return $max;
+    }
+}

+ 53 - 0
ui/src/ApiClient/DTOs/IpListDto.php

@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiClient\DTOs;
+
+/**
+ * Page of IP search results with pagination metadata.
+ */
+final class IpListDto
+{
+    /**
+     * @param list<IpListItem> $items
+     */
+    public function __construct(
+        public readonly array $items,
+        public readonly int $page,
+        public readonly int $pageSize,
+        public readonly int $total,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    public static function fromArray(array $payload): self
+    {
+        $items = [];
+        if (isset($payload['items']) && is_array($payload['items'])) {
+            foreach ($payload['items'] as $row) {
+                if (is_array($row)) {
+                    $items[] = IpListItem::fromArray($row);
+                }
+            }
+        }
+
+        return new self(
+            items: $items,
+            page: (int) ($payload['page'] ?? 1),
+            pageSize: (int) ($payload['page_size'] ?? 25),
+            total: (int) ($payload['total'] ?? 0),
+        );
+    }
+
+    public function totalPages(): int
+    {
+        if ($this->pageSize <= 0) {
+            return 1;
+        }
+
+        return max(1, (int) ceil($this->total / $this->pageSize));
+    }
+}

+ 54 - 0
ui/src/ApiClient/DTOs/IpListItem.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiClient\DTOs;
+
+/**
+ * One row in the admin IP search response. Mirrors the api shape from
+ * SPEC §M09.1.
+ */
+final class IpListItem
+{
+    /**
+     * @param array{country_code: ?string, asn: ?int, as_org: ?string, enriched_at: ?string}|null $enrichment
+     */
+    public function __construct(
+        public readonly string $ip,
+        public readonly bool $isIpv4,
+        public readonly float $maxScore,
+        public readonly ?string $topCategory,
+        public readonly int $pairCount,
+        public readonly ?string $lastReportAt,
+        public readonly string $status,
+        public readonly ?array $enrichment,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    public static function fromArray(array $row): self
+    {
+        $enrichment = null;
+        if (isset($row['enrichment']) && is_array($row['enrichment'])) {
+            $enrichment = [
+                'country_code' => isset($row['enrichment']['country_code']) ? (string) $row['enrichment']['country_code'] : null,
+                'asn' => isset($row['enrichment']['asn']) && $row['enrichment']['asn'] !== null ? (int) $row['enrichment']['asn'] : null,
+                'as_org' => isset($row['enrichment']['as_org']) ? (string) $row['enrichment']['as_org'] : null,
+                'enriched_at' => isset($row['enrichment']['enriched_at']) ? (string) $row['enrichment']['enriched_at'] : null,
+            ];
+        }
+
+        return new self(
+            ip: (string) ($row['ip'] ?? ''),
+            isIpv4: (bool) ($row['is_ipv4'] ?? false),
+            maxScore: (float) ($row['max_score'] ?? 0),
+            topCategory: isset($row['top_category']) && $row['top_category'] !== null ? (string) $row['top_category'] : null,
+            pairCount: (int) ($row['pair_count'] ?? 0),
+            lastReportAt: isset($row['last_report_at']) && $row['last_report_at'] !== null ? (string) $row['last_report_at'] : null,
+            status: (string) ($row['status'] ?? 'clean'),
+            enrichment: $enrichment,
+        );
+    }
+}

+ 12 - 0
ui/src/App/AppFactory.php

@@ -7,8 +7,10 @@ namespace App\App;
 use App\Auth\LocalLoginController;
 use App\Auth\LogoutController;
 use App\Auth\OidcController;
+use App\Controllers\DashboardController;
 use App\Controllers\HealthzController;
 use App\Controllers\HomeController;
+use App\Controllers\IpsController;
 use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
 use App\Http\AuthRequiredMiddleware;
@@ -106,6 +108,16 @@ final class AppFactory
             /** @var MeController $me */
             $me = $container->get(MeController::class);
             $group->get('/me', $me);
+
+            /** @var DashboardController $dashboard */
+            $dashboard = $container->get(DashboardController::class);
+            $group->get('/dashboard', $dashboard);
+
+            /** @var IpsController $ips */
+            $ips = $container->get(IpsController::class);
+            $group->get('/ips', [$ips, 'index']);
+            // {ip:.+} so v6 colons don't break Slim's default segment regex.
+            $group->get('/ips/{ip:.+}', [$ips, 'show']);
         })->add($authRequired);
 
         $app->map(

+ 4 - 0
ui/src/App/Container.php

@@ -14,8 +14,10 @@ use App\Auth\LogoutController;
 use App\Auth\OidcAuthenticator;
 use App\Auth\OidcController;
 use App\Auth\SessionManager;
+use App\Controllers\DashboardController;
 use App\Controllers\HealthzController;
 use App\Controllers\HomeController;
+use App\Controllers\IpsController;
 use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
 use App\Http\AuthRequiredMiddleware;
@@ -190,6 +192,8 @@ final class Container
             MeController::class => autowire(),
             NoAccessController::class => autowire(),
             LogoutController::class => autowire(),
+            DashboardController::class => autowire(),
+            IpsController::class => autowire(),
 
             LocalLoginController::class => factory(static function (ContainerInterface $c): LocalLoginController {
                 /** @var Twig $twig */

+ 1 - 1
ui/src/Auth/LocalLoginController.php

@@ -99,7 +99,7 @@ final class LocalLoginController
             source: UserContext::SOURCE_LOCAL,
         ));
 
-        $next = $this->sessions->consumeNext() ?? '/app/me';
+        $next = $this->sessions->consumeNext() ?? '/app/dashboard';
 
         return $response->withStatus(303)->withHeader('Location', $next);
     }

+ 1 - 1
ui/src/Auth/OidcController.php

@@ -95,7 +95,7 @@ final class OidcController
             source: UserContext::SOURCE_OIDC,
         ));
 
-        $next = $this->sessions->consumeNext() ?? '/app/me';
+        $next = $this->sessions->consumeNext() ?? '/app/dashboard';
 
         return $response->withStatus(302)->withHeader('Location', $next);
     }

+ 52 - 0
ui/src/Controllers/DashboardController.php

@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/dashboard` — top counts, 24h reports histogram (rendered with
+ * Chart.js), top reporters, top categories, jobs status. Read-only;
+ * manual job triggers land in M12.
+ *
+ * On API failure the page degrades to "API unreachable; retrying" via
+ * the same flash + null-data pattern the layout already supports.
+ */
+final class DashboardController
+{
+    public function __construct(
+        private readonly Twig $twig,
+        private readonly SessionManager $sessions,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $user = $this->sessions->getUser();
+        if ($user === null) {
+            return $response->withStatus(302)->withHeader('Location', '/login');
+        }
+
+        $stats = null;
+        $apiReachable = true;
+        try {
+            $stats = $this->admin->getDashboardStats($user->userId);
+        } catch (ApiException) {
+            $apiReachable = false;
+        }
+
+        return $this->twig->render($response, 'pages/dashboard.twig', [
+            'active_section' => 'dashboard',
+            'stats' => $stats,
+            'api_reachable' => $apiReachable,
+        ]);
+    }
+}

+ 1 - 1
ui/src/Controllers/HomeController.php

@@ -19,7 +19,7 @@ final class HomeController
 
     public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
-        $location = $this->sessions->getUser() === null ? '/login' : '/app/me';
+        $location = $this->sessions->getUser() === null ? '/login' : '/app/dashboard';
 
         return $response->withStatus(302)->withHeader('Location', $location);
     }

+ 123 - 0
ui/src/Controllers/IpsController.php

@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\ApiClient\ApiNotFoundException;
+use App\ApiClient\ApiValidationException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/ips` (list) and `/app/ips/{ip}` (detail).
+ *
+ * Both delegate to AdminClient; controller responsibility is parsing
+ * query params, mapping API exceptions to UI states, and handing the
+ * DTOs to Twig.
+ *
+ * The list page accepts `q`, `category`, `min_score`, `max_score`,
+ * `country`, `asn`, `status`, `page`, `page_size` — all optional. They
+ * round-trip through the form via the `filters` Twig variable so the
+ * UI preserves the user's selection across pagination clicks.
+ */
+final class IpsController
+{
+    /** Categories list — small + bounded, kept for the dropdown. */
+    private const CATEGORIES = ['brute_force', 'spam', 'scanner', 'malware_c2', 'web_attack'];
+    private const STATUSES = ['scored', 'manual', 'allowlisted', 'clean'];
+
+    public function __construct(
+        private readonly Twig $twig,
+        private readonly SessionManager $sessions,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $user = $this->sessions->getUser();
+        if ($user === null) {
+            return $response->withStatus(302)->withHeader('Location', '/login');
+        }
+
+        $params = $request->getQueryParams();
+        $filters = [
+            'q' => $this->cleanString($params['q'] ?? null),
+            'category' => $this->cleanString($params['category'] ?? null),
+            'min_score' => $this->cleanString($params['min_score'] ?? null),
+            'max_score' => $this->cleanString($params['max_score'] ?? null),
+            'country' => $this->cleanString($params['country'] ?? null),
+            'asn' => $this->cleanString($params['asn'] ?? null),
+            'status' => $this->cleanString($params['status'] ?? null),
+        ];
+        $page = isset($params['page']) && ctype_digit((string) $params['page']) ? max(1, (int) $params['page']) : 1;
+        $pageSize = 25;
+
+        $list = null;
+        $error = null;
+        try {
+            $list = $this->admin->searchIps($user->userId, $filters, $page, $pageSize);
+        } catch (ApiValidationException $e) {
+            $error = 'invalid filter: ' . implode(', ', array_keys($e->details));
+        } catch (ApiException) {
+            $error = 'API unreachable.';
+        }
+
+        return $this->twig->render($response, 'pages/ips/index.twig', [
+            'active_section' => 'ips',
+            'list' => $list,
+            'filters' => $filters,
+            'page' => $page,
+            'categories' => self::CATEGORIES,
+            'statuses' => self::STATUSES,
+            'error' => $error,
+        ]);
+    }
+
+    /**
+     * @param array{ip: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $user = $this->sessions->getUser();
+        if ($user === null) {
+            return $response->withStatus(302)->withHeader('Location', '/login');
+        }
+
+        try {
+            $detail = $this->admin->getIp($user->userId, rawurldecode($args['ip']));
+        } catch (ApiNotFoundException) {
+            return $this->twig->render(
+                $response->withStatus(404),
+                'pages/error.twig',
+                ['status' => 404, 'is_client_error' => true, 'message' => 'IP not found or invalid.']
+            );
+        } catch (ApiException $e) {
+            return $this->twig->render(
+                $response->withStatus(502),
+                'pages/error.twig',
+                ['status' => 502, 'is_client_error' => false, 'message' => $e->getMessage()]
+            );
+        }
+
+        return $this->twig->render($response, 'pages/ips/detail.twig', [
+            'active_section' => 'ips',
+            'detail' => $detail,
+        ]);
+    }
+
+    private function cleanString(mixed $value): ?string
+    {
+        if (!is_string($value)) {
+            return null;
+        }
+        $trimmed = trim($value);
+
+        return $trimmed === '' ? null : $trimmed;
+    }
+}

+ 68 - 0
ui/tests/Integration/App/DashboardPageTest.php

@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\App;
+
+use App\Auth\UserContext;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class DashboardPageTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+    }
+
+    public function testDashboardRendersStatsAndChartCanvas(): void
+    {
+        $this->enqueueApiResponse(200, [
+            'active_blocks' => 12,
+            'manual_blocks_count' => 3,
+            'allowlist_count' => 1,
+            'reports_24h' => 42,
+            'reports_24h_by_hour' => [
+                ['hour' => '2026-04-29T10:00:00Z', 'count' => 7],
+                ['hour' => '2026-04-29T11:00:00Z', 'count' => 35],
+            ],
+            'top_reporters_24h' => [['name' => 'web-prod-01', 'count' => 30]],
+            'top_categories_24h' => [['slug' => 'brute_force', 'count' => 25]],
+            'jobs_status' => [['name' => 'recompute-scores', 'last_finished_at' => '2026-04-29T10:55:00Z', 'status' => 'success', 'overdue' => false]],
+            'reference_policy' => 'moderate',
+        ]);
+
+        $response = $this->request('GET', '/app/dashboard');
+
+        self::assertSame(200, $response->getStatusCode());
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('Dashboard', $body);
+        self::assertStringContainsString('reports', $body);
+        self::assertStringContainsString('id="reports-chart"', $body);
+        self::assertStringContainsString('web-prod-01', $body);
+        self::assertStringContainsString('brute_force', $body);
+        self::assertStringContainsString('recompute-scores', $body);
+        self::assertStringContainsString('moderate', $body);
+    }
+
+    public function testDashboardDegradesWhenApiUnreachable(): void
+    {
+        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
+            'down',
+            new \GuzzleHttp\Psr7\Request('GET', '/'),
+        ));
+        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
+            'down',
+            new \GuzzleHttp\Psr7\Request('GET', '/'),
+        ));
+
+        $response = $this->request('GET', '/app/dashboard');
+
+        self::assertSame(200, $response->getStatusCode());
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('Dashboard', $body);
+        self::assertStringContainsString('API unreachable', $body);
+    }
+}

+ 136 - 0
ui/tests/Integration/App/IpsPageTest.php

@@ -0,0 +1,136 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\App;
+
+use App\Auth\UserContext;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class IpsPageTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+    }
+
+    public function testListPageRendersResults(): void
+    {
+        $this->enqueueApiResponse(200, [
+            'page' => 1,
+            'page_size' => 25,
+            'total' => 2,
+            'items' => [
+                [
+                    'ip' => '203.0.113.10',
+                    'is_ipv4' => true,
+                    'max_score' => 1.5,
+                    'top_category' => 'brute_force',
+                    'pair_count' => 1,
+                    'last_report_at' => '2026-04-29T10:00:00Z',
+                    'status' => 'scored',
+                    'enrichment' => null,
+                ],
+                [
+                    'ip' => '2001:db8::1',
+                    'is_ipv4' => false,
+                    'max_score' => 0.8,
+                    'top_category' => 'spam',
+                    'pair_count' => 1,
+                    'last_report_at' => '2026-04-29T09:55:00Z',
+                    'status' => 'scored',
+                    'enrichment' => null,
+                ],
+            ],
+        ]);
+
+        $response = $this->request('GET', '/app/ips');
+
+        self::assertSame(200, $response->getStatusCode());
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('203.0.113.10', $body);
+        self::assertStringContainsString('2001:db8::1', $body);
+        self::assertStringContainsString('brute_force', $body);
+        self::assertStringContainsString('2 total', $body);
+    }
+
+    public function testListPageRendersEmptyState(): void
+    {
+        $this->enqueueApiResponse(200, [
+            'page' => 1,
+            'page_size' => 25,
+            'total' => 0,
+            'items' => [],
+        ]);
+
+        $response = $this->request('GET', '/app/ips');
+
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString('No results', (string) $response->getBody());
+    }
+
+    public function testListPagePassesFiltersThrough(): void
+    {
+        $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 25, 'total' => 0, 'items' => []]);
+
+        $response = $this->request('GET', '/app/ips?q=2001&category=spam');
+        $body = (string) $response->getBody();
+
+        self::assertSame(200, $response->getStatusCode());
+        // The filter form preserves the user's selection.
+        self::assertMatchesRegularExpression('/value="2001"/', $body);
+        self::assertMatchesRegularExpression('/<option value="spam"\s+selected/', $body);
+    }
+
+    public function testDetailPageRendersScoresAndHistory(): void
+    {
+        $this->enqueueApiResponse(200, [
+            'ip' => '203.0.113.10',
+            'is_ipv4' => true,
+            'status' => 'scored',
+            'scores' => [
+                ['category' => 'brute_force', 'category_id' => 1, 'score' => 1.5, 'last_report_at' => '2026-04-29T10:00:00Z', 'report_count_30d' => 5],
+            ],
+            'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
+            'manual_block' => null,
+            'allowlist' => null,
+            'history' => [
+                ['type' => 'report', 'at' => '2026-04-29T10:00:00Z', 'category' => 'brute_force', 'reporter' => 'web-prod-01', 'weight' => 1.0, 'metadata' => null],
+            ],
+            'has_more' => false,
+        ]);
+
+        $response = $this->request('GET', '/app/ips/203.0.113.10');
+
+        self::assertSame(200, $response->getStatusCode());
+        $body = (string) $response->getBody();
+        self::assertStringContainsString('203.0.113.10', $body);
+        self::assertStringContainsString('brute_force', $body);
+        self::assertStringContainsString('web-prod-01', $body);
+        self::assertStringContainsString('Score per category', $body);
+        self::assertStringContainsString('History', $body);
+    }
+
+    public function testDetailPageReturnsTwig404OnApiNotFound(): void
+    {
+        $this->enqueueApiResponse(404, ['error' => 'not_found']);
+
+        $response = $this->request('GET', '/app/ips/not-an-ip');
+
+        self::assertSame(404, $response->getStatusCode());
+        self::assertStringContainsString('IP not found', (string) $response->getBody());
+    }
+
+    public function testRedirectsToLoginWhenAnonymous(): void
+    {
+        $_SESSION = []; // wipe seeded user
+
+        $response = $this->request('GET', '/app/ips');
+
+        self::assertSame(302, $response->getStatusCode());
+        self::assertSame('/login', $response->getHeaderLine('Location'));
+    }
+}

+ 2 - 2
ui/tests/Integration/App/RoutesTest.php

@@ -25,7 +25,7 @@ final class RoutesTest extends AppTestCase
         self::assertSame('/login', $response->getHeaderLine('Location'));
     }
 
-    public function testHomeRedirectsToMeWhenAuthenticated(): void
+    public function testHomeRedirectsToDashboardWhenAuthenticated(): void
     {
         $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
         $_SESSION['_last_active'] = time();
@@ -33,7 +33,7 @@ final class RoutesTest extends AppTestCase
 
         $response = $this->request('GET', '/');
         self::assertSame(302, $response->getStatusCode());
-        self::assertSame('/app/me', $response->getHeaderLine('Location'));
+        self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
     }
 
     public function testHealthzReturnsOk(): void

+ 1 - 1
ui/tests/Integration/Auth/LocalLoginTest.php

@@ -50,7 +50,7 @@ final class LocalLoginTest extends AppTestCase
         $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
 
         self::assertSame(303, $response->getStatusCode());
-        self::assertSame('/app/me', $response->getHeaderLine('Location'));
+        self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
         self::assertNotNull($_SESSION['_user'] ?? null);
         self::assertSame('admin', $_SESSION['_user']['role']);
     }

+ 1 - 1
ui/tests/Integration/Auth/OidcFlowTest.php

@@ -42,7 +42,7 @@ final class OidcFlowTest extends AppTestCase
         $response = $this->request('GET', '/oidc/callback');
 
         self::assertSame(302, $response->getStatusCode());
-        self::assertSame('/app/me', $response->getHeaderLine('Location'));
+        self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
         self::assertSame(99, $_SESSION['_user']['user_id'] ?? null);
         self::assertSame('admin', $_SESSION['_user']['role']);
     }