Преглед изворни кода

feat: per-category blocked-IP dashboard chart + token purge

Dashboard "Bans (7 days)" line chart is replaced with a stacked bar
chart of distinct blocked IPs per UTC day, broken down by category.
Bans driven by manual-block creations were a noisy proxy; counting
distinct IPs that received reports per (day, category) is a much more
useful signal. The api now emits `blocked_ips_by_day_7d` as
`{days, series}` so the UI can render Chart.js datasets directly,
and categories with zero activity still appear as flat zero series so
the legend stays stable across renders.

Tokens table grows a "Remove" action on revoked rows. The api side
exposes `DELETE /api/v1/admin/tokens/{id}/purge` which hard-deletes
the row (refusing 409 on still-active tokens; service tokens stay
forbidden) and emits a `token.deleted` audit entry. Existing revoke
behaviour is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa пре 1 недеља
родитељ
комит
7622fd201b

+ 5 - 1
api/CHANGELOG.md

@@ -19,7 +19,11 @@ with the UI's tags in this monorepo.
 - Public-endpoint audit emission: `POST /api/v1/report` writes a `report.received` entry attributed to the reporter, and `GET /api/v1/blocklist` writes a `blocklist.requested` entry (including 304s) attributed to the consumer.
 - `app_settings` key/value table plus `GET/PATCH /api/v1/admin/app-settings` (admin-only) exposing the two audit toggles (`audit_report_received_enabled`, `audit_blocklist_request_enabled`) so the high-volume rows can be silenced at runtime without a container restart.
 - Per-entity `audit_enabled` boolean on `reporters` and `consumers` (default true) editable via the admin PATCH endpoints. Audit emits only when both the global toggle and the entity-level flag are true (AND, not OR).
-- New audit actions: `report.received`, `blocklist.requested`, `app_settings.updated`.
+- `DELETE /api/v1/admin/tokens/{id}/purge` — hard-deletes a previously revoked, non-service token row. Returns 409 on still-active tokens.
+- New audit actions: `report.received`, `blocklist.requested`, `app_settings.updated`, `token.deleted`.
+
+### Changed
+- Dashboard `/api/v1/admin/stats/dashboard` replaces the single-series `bans_by_day_7d` (manual-block creations per day) with `blocked_ips_by_day_7d`, a per-category time series of distinct IPs reported per UTC day. Shape is `{days: string[], series: [{category, counts}]}`. Categories with zero activity in the window still appear as flat-zero series so the legend stays stable.
 
 ## [1.0.0] — 2026-05-01
 

+ 28 - 0
api/public/openapi.yaml

@@ -725,6 +725,34 @@ paths:
       responses:
         '204':
           description: Revoked
+  '/api/v1/admin/tokens/{id}/purge':
+    delete:
+      tags:
+        - Admin
+      summary: Permanently delete a revoked token
+      description: |
+        Hard-deletes the row. Requires the token to be already revoked
+        (`revoked_at` set); active tokens return 409 to keep "revoke first,
+        then prune" the only path that removes data. Service tokens cannot
+        be deleted.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+        '403':
+          description: Service token; not deletable via API
+        '404':
+          description: Not found
+        '409':
+          description: Token still active; revoke it first
   '/api/v1/admin/categories':
     get:
       tags:

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

@@ -217,6 +217,7 @@ final class AppFactory
                 $r->get('', [$tokens, 'list']);
                 $r->post('', [$tokens, 'create']);
                 $r->delete('/{id}', [$tokens, 'delete']);
+                $r->delete('/{id}/purge', [$tokens, 'purge']);
             })->add(RbacMiddleware::require($rf, Role::Admin));
 
             // Manual blocks: list/show require Viewer, create/delete require Operator.

+ 49 - 18
api/src/Application/Admin/StatsController.php

@@ -68,8 +68,9 @@ final class StatsController
             'reports_24h_by_hour' => $this->stats->reportsByHourSince($since),
             'top_reporters_24h' => $this->stats->topReportersSince($since),
             'top_categories_24h' => $this->stats->topCategoriesSince($since),
-            'bans_by_day_7d' => self::fillMissingDays(
-                $this->stats->bansByDaySince($weekAgo),
+            'blocked_ips_by_day_7d' => self::buildBlockedSeries(
+                $this->stats->blockedIpsByDayCategorySince($weekAgo),
+                $this->stats->activeCategorySlugs(),
                 $weekAgo,
                 $now,
             ),
@@ -84,29 +85,59 @@ final class StatsController
     }
 
     /**
-     * Pad a sparse "days that had bans" series so the chart always shows
-     * every calendar day in `[from, to]`, including zeros. The repo only
-     * emits rows for days with at least one creation.
+     * Pivot a sparse `(day, category, count)` triple stream into the
+     * stacked-bar shape consumed by the dashboard chart.
      *
-     * @param list<array{day: string, count: int}> $rows
-     * @return list<array{day: string, count: int}>
+     * The output guarantees:
+     *  - `days` covers every calendar day in `[from, to]`, in order.
+     *  - `series` contains one entry per category that either appears
+     *    in the rows or in `activeCategories` (so categories with zero
+     *    activity still render as flat zero bars instead of vanishing).
+     *  - Each series carries a counts array aligned 1:1 with `days`.
+     *
+     * @param list<array{day: string, category: string, count: int}> $rows
+     * @param list<string>                                            $activeCategories
+     * @return array{days: list<string>, series: list<array{category: string, counts: list<int>}>}
      */
-    private static function fillMissingDays(array $rows, DateTimeImmutable $from, DateTimeImmutable $to): array
-    {
-        $byDay = [];
-        foreach ($rows as $row) {
-            $byDay[$row['day']] = $row['count'];
-        }
-
-        $out = [];
+    private static function buildBlockedSeries(
+        array $rows,
+        array $activeCategories,
+        DateTimeImmutable $from,
+        DateTimeImmutable $to,
+    ): array {
+        $days = [];
         $cursor = $from->setTime(0, 0, 0);
         $end = $to->setTime(0, 0, 0);
         while ($cursor <= $end) {
-            $key = $cursor->format('Y-m-d');
-            $out[] = ['day' => $key, 'count' => (int) ($byDay[$key] ?? 0)];
+            $days[] = $cursor->format('Y-m-d');
             $cursor = $cursor->modify('+1 day');
         }
 
-        return $out;
+        $byCategoryDay = [];
+        $seenCategories = [];
+        foreach ($rows as $row) {
+            $cat = $row['category'];
+            $seenCategories[$cat] = true;
+            $byCategoryDay[$cat][$row['day']] = $row['count'];
+        }
+
+        // Combine seen + active so categories without any reports still
+        // appear in the legend; sort for stable order across renders.
+        $allCategories = array_keys(array_merge(
+            array_flip($activeCategories),
+            $seenCategories,
+        ));
+        sort($allCategories);
+
+        $series = [];
+        foreach ($allCategories as $cat) {
+            $counts = [];
+            foreach ($days as $day) {
+                $counts[] = (int) ($byCategoryDay[$cat][$day] ?? 0);
+            }
+            $series[] = ['category' => $cat, 'counts' => $counts];
+        }
+
+        return ['days' => $days, 'series' => $series];
     }
 }

+ 41 - 0
api/src/Application/Admin/TokensController.php

@@ -241,6 +241,47 @@ final class TokensController
         return $response->withStatus(204);
     }
 
+    /**
+     * Hard-delete a revoked, non-service token. Refuses (409) on tokens
+     * that are still active to keep "revoke first, then prune" the only
+     * code path that removes a row.
+     *
+     * @param array{id: string} $args
+     */
+    public function purge(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $token = $this->tokens->findById($id);
+        if ($token === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        if ($token->kind === TokenKind::Service) {
+            return self::error($response, 403, 'cannot delete service tokens via API');
+        }
+        if ($token->revokedAt === null) {
+            return self::json($response, 409, [
+                'error' => 'not_revoked',
+                'message' => 'Revoke the token first; only revoked tokens can be deleted.',
+            ]);
+        }
+
+        $this->tokens->deleteRow($id);
+
+        $this->audit->emit(
+            AuditAction::TOKEN_DELETED,
+            'token',
+            $id,
+            ['kind' => $token->kind->value, 'prefix' => $token->prefix],
+            self::auditContext($request),
+            self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
+        );
+
+        return $response->withStatus(204);
+    }
+
     private static function optInt(mixed $value): ?int
     {
         if (is_int($value) && $value > 0) {

+ 1 - 0
api/src/Domain/Audit/AuditAction.php

@@ -27,6 +27,7 @@ final class AuditAction
 
     public const TOKEN_CREATED = 'token.created';
     public const TOKEN_REVOKED = 'token.revoked';
+    public const TOKEN_DELETED = 'token.deleted';
 
     public const POLICY_CREATED = 'policy.created';
     public const POLICY_UPDATED = 'policy.updated';

+ 10 - 0
api/src/Infrastructure/Auth/TokenRepository.php

@@ -142,6 +142,16 @@ final class TokenRepository
         );
     }
 
+    /**
+     * Hard-delete a token row. Caller MUST have already revoked it; the
+     * controller enforces that the token is non-service and revoked
+     * before reaching this method.
+     */
+    public function deleteRow(int $id): void
+    {
+        $this->connection->delete('api_tokens', ['id' => $id]);
+    }
+
     /**
      * Used by ServiceTokenBootstrap to detect rotation scenarios — i.e. an
      * existing service-kind row whose hash differs from the one we are

+ 40 - 11
api/src/Infrastructure/Reputation/DashboardStatsRepository.php

@@ -85,33 +85,62 @@ final class DashboardStatsRepository extends RepositoryBase
     }
 
     /**
-     * Manual-block creations bucketed by UTC calendar day across the
-     * window `[since, now]`. Days with zero creations are filled in by
-     * the caller — the SQL only emits days that have at least one row.
+     * Distinct blocked IPs per UTC calendar day per category across the
+     * window `[since, now]`. "Blocked" here is approximated by "received
+     * at least one report on that day in that category" — every such IP
+     * is contributing to its score for the category, so the histogram is
+     * a faithful proxy for "which categories saw activity each day".
      *
-     * @return list<array{day: string, count: int}>
+     * Days/categories with zero IPs are filled in by the caller — the
+     * SQL only emits (day, category) pairs that have at least one row.
+     *
+     * @return list<array{day: string, category: string, count: int}>
      */
-    public function bansByDaySince(DateTimeImmutable $since): array
+    public function blockedIpsByDayCategorySince(DateTimeImmutable $since): array
     {
         $platform = $this->connection()->getDatabasePlatform()::class;
         $isMysql = stripos($platform, 'mysql') !== false || stripos($platform, 'mariadb') !== false;
 
-        $sql = $isMysql
-            ? "SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(*) AS c "
-                . 'FROM manual_blocks WHERE created_at >= :since GROUP BY d ORDER BY d ASC'
-            : "SELECT substr(replace(created_at, ' ', 'T'), 1, 10) AS d, COUNT(*) AS c "
-                . 'FROM manual_blocks WHERE created_at >= :since GROUP BY d ORDER BY d ASC';
+        $dayExpr = $isMysql
+            ? "DATE_FORMAT(r.received_at, '%Y-%m-%d')"
+            : "substr(replace(r.received_at, ' ', 'T'), 1, 10)";
+
+        $sql = 'SELECT ' . $dayExpr . ' AS d, c.slug AS slug, COUNT(DISTINCT r.ip_bin) AS cnt '
+            . 'FROM reports r JOIN categories c ON c.id = r.category_id '
+            . 'WHERE r.received_at >= :since '
+            . 'GROUP BY d, c.slug '
+            . 'ORDER BY d ASC, c.slug 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[] = ['day' => (string) $row['d'], 'count' => (int) $row['c']];
+            $out[] = [
+                'day' => (string) $row['d'],
+                'category' => (string) $row['slug'],
+                'count' => (int) $row['cnt'],
+            ];
         }
 
         return $out;
     }
 
+    /**
+     * Active categories — used to ensure the dashboard chart shows zeros
+     * for categories that exist but had no activity in the window.
+     *
+     * @return list<string>
+     */
+    public function activeCategorySlugs(): array
+    {
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT slug FROM categories WHERE is_active = :a ORDER BY slug ASC',
+            ['a' => 1],
+        );
+
+        return array_map(static fn (array $r): string => (string) $r['slug'], $rows);
+    }
+
     /**
      * @return list<array{slug: string, count: int}>
      */

+ 43 - 11
api/tests/Integration/Admin/StatsControllerTest.php

@@ -34,12 +34,20 @@ final class StatsControllerTest extends AppTestCase
         self::assertSame([], $body['reports_24h_by_hour']);
         self::assertSame([], $body['top_reporters_24h']);
         self::assertSame([], $body['top_categories_24h']);
-        // bans_by_day_7d always emits 8 days (today + the 7 prior, zero-filled).
-        self::assertCount(8, $body['bans_by_day_7d']);
-        foreach ($body['bans_by_day_7d'] as $row) {
-            self::assertArrayHasKey('day', $row);
-            self::assertArrayHasKey('count', $row);
-            self::assertSame(0, $row['count']);
+        // blocked_ips_by_day_7d always emits 8 days (today + the 7 prior, zero-filled).
+        self::assertArrayHasKey('blocked_ips_by_day_7d', $body);
+        $blocked = $body['blocked_ips_by_day_7d'];
+        self::assertArrayHasKey('days', $blocked);
+        self::assertArrayHasKey('series', $blocked);
+        self::assertCount(8, $blocked['days']);
+        // Seeders create the default categories — every active one should
+        // appear as a flat-zero series even with no reports yet.
+        self::assertNotEmpty($blocked['series']);
+        foreach ($blocked['series'] as $row) {
+            self::assertArrayHasKey('category', $row);
+            self::assertArrayHasKey('counts', $row);
+            self::assertCount(8, $row['counts']);
+            self::assertSame(0, array_sum(array_map('intval', $row['counts'])));
         }
         self::assertSame('moderate', $body['reference_policy']);
     }
@@ -88,12 +96,36 @@ final class StatsControllerTest extends AppTestCase
 
         self::assertSame(1, $body['manual_blocks_count']);
         self::assertSame(1, $body['allowlist_count']);
+    }
+
+    public function testBlockedIpsByDayBucketsByCategory(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $reporterId = $this->createReporter('rep-cat-bucket');
+
+        // Two distinct IPs in `brute_force`, one in `spam`. The same IP
+        // reported twice in `brute_force` must still count as one (the
+        // sum is COUNT DISTINCT ip_bin per category per day).
+        $this->seedReport('203.0.113.10', 'brute_force', $reporterId);
+        $this->seedReport('203.0.113.10', 'brute_force', $reporterId);
+        $this->seedReport('203.0.113.11', 'brute_force', $reporterId);
+        $this->seedReport('203.0.113.20', 'spam', $reporterId);
 
-        // The manual block we just inserted (created_at = NOW per DB
-        // default) should land in today's bucket of bans_by_day_7d.
-        self::assertCount(8, $body['bans_by_day_7d']);
-        $totalBans = array_sum(array_map(static fn (array $r): int => (int) $r['count'], $body['bans_by_day_7d']));
-        self::assertSame(1, $totalBans);
+        $body = $this->decode($this->request('GET', '/api/v1/admin/stats/dashboard', [
+            'Authorization' => 'Bearer ' . $token,
+        ]));
+
+        $blocked = $body['blocked_ips_by_day_7d'];
+        self::assertCount(8, $blocked['days']);
+
+        $byCategory = [];
+        foreach ($blocked['series'] as $row) {
+            $byCategory[$row['category']] = array_sum(array_map('intval', $row['counts']));
+        }
+        self::assertArrayHasKey('brute_force', $byCategory);
+        self::assertArrayHasKey('spam', $byCategory);
+        self::assertSame(2, $byCategory['brute_force']);
+        self::assertSame(1, $byCategory['spam']);
     }
 
     private function seedReport(string $ip, string $categorySlug, int $reporterId): void

+ 54 - 0
api/tests/Integration/Admin/TokensControllerTest.php

@@ -99,4 +99,58 @@ final class TokensControllerTest extends AppTestCase
         self::assertIsArray($row);
         self::assertNotNull($row['revoked_at']);
     }
+
+    public function testPurgeDeletesRevokedToken(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-purge');
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
+        );
+        $tokenId = (int) $this->decode($created)['id'];
+
+        // Revoke first.
+        self::assertSame(204, $this->request('DELETE', "/api/v1/admin/tokens/{$tokenId}", [
+            'Authorization' => 'Bearer ' . $admin,
+        ])->getStatusCode());
+
+        $purge = $this->request('DELETE', "/api/v1/admin/tokens/{$tokenId}/purge", [
+            'Authorization' => 'Bearer ' . $admin,
+        ]);
+        self::assertSame(204, $purge->getStatusCode());
+
+        $count = (int) $this->db->fetchOne('SELECT COUNT(*) FROM api_tokens WHERE id = :id', ['id' => $tokenId]);
+        self::assertSame(0, $count);
+
+        $audit = $this->db->fetchAssociative(
+            "SELECT action, target_id FROM audit_log WHERE action = 'token.deleted' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($audit);
+        self::assertSame((string) $tokenId, $audit['target_id']);
+    }
+
+    public function testPurgeRefusesActiveToken(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-still-active');
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
+        );
+        $tokenId = (int) $this->decode($created)['id'];
+
+        $purge = $this->request('DELETE', "/api/v1/admin/tokens/{$tokenId}/purge", [
+            'Authorization' => 'Bearer ' . $admin,
+        ]);
+        self::assertSame(409, $purge->getStatusCode());
+        self::assertSame('not_revoked', $this->decode($purge)['error']);
+
+        $count = (int) $this->db->fetchOne('SELECT COUNT(*) FROM api_tokens WHERE id = :id', ['id' => $tokenId]);
+        self::assertSame(1, $count);
+    }
 }

+ 4 - 0
ui/CHANGELOG.md

@@ -19,6 +19,10 @@ with the api's tags in this monorepo.
 ### Added
 - Settings page now shows two **Audit toggles** for switching off the public-endpoint audit emissions (reporter `POST /report` and consumer `GET /blocklist`) without restarting the api. Posts to a new `/app/settings/audit-toggles` BFF route that PATCHes `/api/v1/admin/app-settings`.
 - Per-entity audit-log toggle on the reporter and consumer edit pages. Combined with the global Settings toggle via AND so either side is sufficient to silence the audit row.
+- Tokens page now shows a **Remove** action on revoked rows that hard-deletes the row via `POST /app/tokens/{id}/purge`.
+
+### Changed
+- Dashboard "Bans (7 days)" line chart replaced by a stacked bar chart of distinct blocked IPs per day broken down by category (last 7 days). Empty categories still render as zero series so the legend doesn't churn between renders.
 
 ## [1.0.0] — 2026-05-01
 

+ 42 - 31
ui/resources/js/app.js

@@ -170,51 +170,62 @@ function renderPieChart(canvasId) {
     });
 }
 
-function renderBansTrendChart() {
-    const canvas = document.getElementById('bans-trend-chart');
+function renderBlockedIpsChart() {
+    const canvas = document.getElementById('blocked-ips-chart');
     if (!canvas) return;
-    const series = parseSeries(canvas, 'series');
-    if (series.length === 0) return;
-
-    // Show day-of-month as label; the full ISO date stays in the tooltip
-    // title via the dataset.
-    const labels = series.map((row) => {
-        const d = new Date(row.day + 'T00:00:00Z');
-        return isNaN(d.getTime()) ? row.day : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+    let payload;
+    try {
+        payload = JSON.parse(canvas.dataset.blocked || '{}');
+    } catch (e) {
+        return;
+    }
+    const days = Array.isArray(payload.days) ? payload.days : [];
+    const series = Array.isArray(payload.series) ? payload.series : [];
+    if (days.length === 0 || series.length === 0) return;
+
+    // Day-of-month for axis ticks; the ISO date is preserved in the
+    // tooltip title via the closure on `days`.
+    const labels = days.map((iso) => {
+        const d = new Date(iso + 'T00:00:00Z');
+        return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+    });
+    const datasets = series.map((row, i) => {
+        const colour = PIE_COLORS[i % PIE_COLORS.length];
+        return {
+            label: row.category || '—',
+            data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
+            backgroundColor: colour,
+            borderColor: colour,
+            borderWidth: 0,
+        };
     });
-    const data = series.map((row) => row.count || 0);
     const t = chartTheme();
 
     new Chart(canvas, {
-        type: 'line',
-        data: {
-            labels,
-            datasets: [{
-                label: 'bans',
-                data,
-                borderColor: '#ef4444',
-                backgroundColor: 'rgba(239,68,68,0.18)',
-                tension: 0.3,
-                fill: true,
-                pointRadius: 3,
-                pointHoverRadius: 5,
-                pointBackgroundColor: '#ef4444',
-            }],
-        },
+        type: 'bar',
+        data: { labels, datasets },
         options: {
             responsive: true,
             maintainAspectRatio: false,
             plugins: {
-                legend: { display: false },
+                legend: {
+                    position: 'bottom',
+                    labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
+                },
                 tooltip: {
                     callbacks: {
-                        title: (items) => (items[0] && series[items[0].dataIndex]?.day) || '',
+                        title: (items) => (items[0] && days[items[0].dataIndex]) || '',
                     },
                 },
             },
             scales: {
-                x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
-                y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
+                x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
+                y: {
+                    stacked: true,
+                    ticks: { color: t.tickColor, precision: 0 },
+                    grid: { color: t.gridColor },
+                    beginAtZero: true,
+                },
             },
         },
     });
@@ -224,7 +235,7 @@ document.addEventListener('DOMContentLoaded', () => {
     renderReportsChart();
     renderPieChart('top-reporters-chart');
     renderPieChart('top-categories-chart');
-    renderBansTrendChart();
+    renderBlockedIpsChart();
 });
 
 // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"

+ 5 - 5
ui/resources/views/pages/dashboard.twig

@@ -76,15 +76,15 @@
             </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">Bans (7 days)</h2>
-                {% if stats.bansByDay|length > 0 %}
+                <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Blocked IPs (7 days, by category)</h2>
+                {% if stats.blockedByDay.series|default([])|length > 0 %}
                     <div class="mt-3 h-64">
-                        <canvas id="bans-trend-chart"
-                                data-series="{{ stats.bansByDay|json_encode|e('html_attr') }}">
+                        <canvas id="blocked-ips-chart"
+                                data-blocked="{{ stats.blockedByDay|json_encode|e('html_attr') }}">
                         </canvas>
                     </div>
                 {% else %}
-                    <p class="mt-2 text-sm text-slate-400">No manual blocks recorded in the last week.</p>
+                    <p class="mt-2 text-sm text-slate-400">No reports recorded in the last week.</p>
                 {% endif %}
             </div>
         </section>

+ 6 - 0
ui/resources/views/pages/tokens/index.twig

@@ -101,6 +101,12 @@
                                         label: 'Revoke',
                                         description: 'Revoke this token immediately. Clients using it will start getting 401.',
                                     } only %}
+                                {% else %}
+                                    {% include 'partials/confirm_form.twig' with {
+                                        action: '/app/tokens/' ~ t.id ~ '/purge',
+                                        label: 'Remove',
+                                        description: 'Permanently delete this revoked token row. The audit log entry referring to its prefix will remain.',
+                                    } only %}
                                 {% endif %}
                             </td>
                         {% endif %}

+ 5 - 0
ui/src/ApiClient/AdminClient.php

@@ -317,6 +317,11 @@ final class AdminClient
         $this->api->request('DELETE', '/api/v1/admin/tokens/' . $id, [], $actingUserId);
     }
 
+    public function purgeToken(int $actingUserId, int $id): void
+    {
+        $this->api->request('DELETE', '/api/v1/admin/tokens/' . $id . '/purge', [], $actingUserId);
+    }
+
     // ---- categories (M10) ----
 
     /**

+ 39 - 7
ui/src/ApiClient/DTOs/DashboardStatsDto.php

@@ -11,11 +11,11 @@ namespace App\ApiClient\DTOs;
 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>> $bansByDay     entries shaped {day, count} — last 7 calendar days, zero-filled
-     * @param list<array<string, mixed>> $jobsStatus    entries shaped {name, last_finished_at, status, overdue}
+     * @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 array{days: list<string>, series: list<array{category: string, counts: list<int>}>} $blockedByDay
+     * @param list<array<string, mixed>>                                                 $jobsStatus    entries shaped {name, last_finished_at, status, overdue}
      */
     public function __construct(
         public readonly int $activeBlocks,
@@ -25,7 +25,7 @@ final class DashboardStatsDto
         public readonly array $reportsByHour,
         public readonly array $topReporters,
         public readonly array $topCategories,
-        public readonly array $bansByDay,
+        public readonly array $blockedByDay,
         public readonly array $jobsStatus,
         public readonly string $referencePolicy,
     ) {
@@ -44,12 +44,44 @@ final class DashboardStatsDto
             reportsByHour: self::extractList($payload, 'reports_24h_by_hour'),
             topReporters: self::extractList($payload, 'top_reporters_24h'),
             topCategories: self::extractList($payload, 'top_categories_24h'),
-            bansByDay: self::extractList($payload, 'bans_by_day_7d'),
+            blockedByDay: self::extractBlocked($payload),
             jobsStatus: self::extractList($payload, 'jobs_status'),
             referencePolicy: (string) ($payload['reference_policy'] ?? 'moderate'),
         );
     }
 
+    /**
+     * @param array<string, mixed> $payload
+     * @return array{days: list<string>, series: list<array{category: string, counts: list<int>}>}
+     */
+    private static function extractBlocked(array $payload): array
+    {
+        $raw = $payload['blocked_ips_by_day_7d'] ?? null;
+        if (!is_array($raw)) {
+            return ['days' => [], 'series' => []];
+        }
+        $days = [];
+        foreach ((array) ($raw['days'] ?? []) as $d) {
+            $days[] = (string) $d;
+        }
+        $series = [];
+        foreach ((array) ($raw['series'] ?? []) as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $counts = [];
+            foreach ((array) ($row['counts'] ?? []) as $c) {
+                $counts[] = (int) $c;
+            }
+            $series[] = [
+                'category' => (string) ($row['category'] ?? ''),
+                'counts' => $counts,
+            ];
+        }
+
+        return ['days' => $days, 'series' => $series];
+    }
+
     /**
      * @param array<string, mixed> $payload
      * @return list<array<string, mixed>>

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

@@ -176,6 +176,7 @@ final class AppFactory
             $group->get('/tokens', [$tokens, 'index']);
             $group->post('/tokens', [$tokens, 'create']);
             $group->post('/tokens/{id}/delete', [$tokens, 'delete']);
+            $group->post('/tokens/{id}/purge', [$tokens, 'purge']);
 
             /** @var CategoriesController $categories */
             $categories = $container->get(CategoriesController::class);

+ 29 - 0
ui/src/Controllers/TokensController.php

@@ -146,4 +146,33 @@ final class TokensController
 
         return $response->withStatus(303)->withHeader('Location', '/app/tokens');
     }
+
+    /**
+     * Hard-delete a previously revoked token. The api side rejects with 409
+     * on still-active tokens; we surface that as a flash on the redirect.
+     *
+     * @param array{id: string} $args
+     */
+    public function purge(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/tokens');
+        }
+        try {
+            $this->admin->purgeToken($user->userId, $id);
+            $this->sessionManager->flash('success', 'Token deleted.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/tokens');
+    }
 }

+ 11 - 9
ui/tests/Integration/App/DashboardPageTest.php

@@ -30,14 +30,15 @@ final class DashboardPageTest extends AppTestCase
             ],
             'top_reporters_24h' => [['name' => 'web-prod-01', 'count' => 30]],
             'top_categories_24h' => [['slug' => 'brute_force', 'count' => 25]],
-            'bans_by_day_7d' => [
-                ['day' => '2026-04-23', 'count' => 0],
-                ['day' => '2026-04-24', 'count' => 2],
-                ['day' => '2026-04-25', 'count' => 1],
-                ['day' => '2026-04-26', 'count' => 0],
-                ['day' => '2026-04-27', 'count' => 3],
-                ['day' => '2026-04-28', 'count' => 5],
-                ['day' => '2026-04-29', 'count' => 1],
+            'blocked_ips_by_day_7d' => [
+                'days' => [
+                    '2026-04-23', '2026-04-24', '2026-04-25', '2026-04-26',
+                    '2026-04-27', '2026-04-28', '2026-04-29',
+                ],
+                'series' => [
+                    ['category' => 'brute_force', 'counts' => [0, 2, 1, 0, 3, 5, 1]],
+                    ['category' => 'spam',        'counts' => [0, 1, 0, 0, 1, 2, 0]],
+                ],
             ],
             'jobs_status' => [['name' => 'recompute-scores', 'last_finished_at' => '2026-04-29T10:55:00Z', 'status' => 'success', 'overdue' => false]],
             'reference_policy' => 'moderate',
@@ -52,7 +53,8 @@ final class DashboardPageTest extends AppTestCase
         self::assertStringContainsString('id="reports-chart"', $body);
         self::assertStringContainsString('id="top-reporters-chart"', $body);
         self::assertStringContainsString('id="top-categories-chart"', $body);
-        self::assertStringContainsString('id="bans-trend-chart"', $body);
+        self::assertStringContainsString('id="blocked-ips-chart"', $body);
+        self::assertStringContainsString('Blocked IPs', $body);
         self::assertStringContainsString('web-prod-01', $body);
         self::assertStringContainsString('brute_force', $body);
         self::assertStringContainsString('recompute-scores', $body);