فهرست منبع

feat(demo+ui): expand demo data and pie/line dashboard charts

Demo seed: 70 IPs (was 22) drawn from a wider geographic spread,
10 reporters and 7 consumers (was 3 and 2), 12 manual blocks +
5 allowlist entries, and ~3,000 reports staggered across 90 days
of history with per-IP volume tiers and category specialisation.
The bulk insert is wrapped in a single transaction so SQLite
finishes the seed in seconds rather than tens of seconds.

Dashboard: replace the "Top reporters" and "Top categories"
tables with pie charts, and add a 7-day "Bans" line chart on the
same row. The api now exposes `bans_by_day_7d` (zero-filled per
calendar day) backed by a new DashboardStatsRepository::bansByDaySince.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 هفته پیش
والد
کامیت
8bbbb4685a

+ 205 - 40
api/src/Application/Admin/MaintenanceController.php

@@ -44,49 +44,150 @@ final class MaintenanceController
     use AdminControllerSupport;
 
     private const DEMO_REPORTERS = [
-        ['name' => 'demo-web-edge', 'description' => 'Demo: edge webserver', 'trust_weight' => 1.0],
-        ['name' => 'demo-fail2ban', 'description' => 'Demo: fail2ban agents', 'trust_weight' => 1.2],
-        ['name' => 'demo-honeypot', 'description' => 'Demo: research honeypot', 'trust_weight' => 1.5],
+        ['name' => 'demo-web-edge',     'description' => 'Demo: edge webserver',           'trust_weight' => 1.0],
+        ['name' => 'demo-web-eu',       'description' => 'Demo: EU edge webserver',        'trust_weight' => 1.0],
+        ['name' => 'demo-web-us',       'description' => 'Demo: US edge webserver',        'trust_weight' => 1.0],
+        ['name' => 'demo-fail2ban',     'description' => 'Demo: fail2ban agents',          'trust_weight' => 1.2],
+        ['name' => 'demo-fail2ban-prod','description' => 'Demo: fail2ban prod fleet',      'trust_weight' => 1.3],
+        ['name' => 'demo-honeypot',     'description' => 'Demo: research honeypot',        'trust_weight' => 1.5],
+        ['name' => 'demo-honeypot-asia','description' => 'Demo: APAC honeypot mesh',       'trust_weight' => 1.6],
+        ['name' => 'demo-ids-suricata', 'description' => 'Demo: Suricata IDS sensors',     'trust_weight' => 1.4],
+        ['name' => 'demo-mail-gateway', 'description' => 'Demo: anti-spam mail gateway',   'trust_weight' => 1.1],
+        ['name' => 'demo-waf-cloud',    'description' => 'Demo: cloud WAF aggregator',     'trust_weight' => 1.3],
     ];
 
     private const DEMO_CONSUMERS = [
-        ['name' => 'demo-firewall-prod', 'description' => 'Demo: production edge firewall', 'policy' => 'moderate'],
-        ['name' => 'demo-proxy-staging', 'description' => 'Demo: staging proxy', 'policy' => 'strict'],
+        ['name' => 'demo-firewall-prod',  'description' => 'Demo: production edge firewall', 'policy' => 'moderate'],
+        ['name' => 'demo-firewall-dmz',   'description' => 'Demo: DMZ perimeter firewall',   'policy' => 'paranoid'],
+        ['name' => 'demo-proxy-staging',  'description' => 'Demo: staging proxy',            'policy' => 'strict'],
+        ['name' => 'demo-haproxy-public', 'description' => 'Demo: public HAProxy LB',        'policy' => 'moderate'],
+        ['name' => 'demo-nginx-api',      'description' => 'Demo: API nginx ingress',        'policy' => 'strict'],
+        ['name' => 'demo-vpn-gateway',    'description' => 'Demo: VPN gateway',              'policy' => 'paranoid'],
+        ['name' => 'demo-mail-relay',     'description' => 'Demo: outbound mail relay',      'policy' => 'moderate'],
     ];
 
     private const DEMO_MANUAL_BLOCKS = [
-        ['kind' => 'ip', 'value' => '198.51.100.42', 'reason' => 'Demo: known malicious actor'],
-        ['kind' => 'subnet', 'value' => '198.51.100.0/24', 'reason' => 'Demo: blanket block on test net'],
+        ['kind' => 'ip',     'value' => '198.51.100.42',  'reason' => 'Demo: known malicious actor',    'age_days' => 1],
+        ['kind' => 'ip',     'value' => '198.51.100.55',  'reason' => 'Demo: persistent brute-force',   'age_days' => 2],
+        ['kind' => 'ip',     'value' => '203.0.113.190',  'reason' => 'Demo: WAF auto-block',           'age_days' => 3],
+        ['kind' => 'ip',     'value' => '192.0.2.244',    'reason' => 'Demo: spam relay',               'age_days' => 5],
+        ['kind' => 'ip',     'value' => '198.51.100.140', 'reason' => 'Demo: scanner cluster member',   'age_days' => 7],
+        ['kind' => 'ip',     'value' => '203.0.113.230',  'reason' => 'Demo: confirmed C2 endpoint',    'age_days' => 10],
+        ['kind' => 'ip',     'value' => '192.0.2.99',     'reason' => 'Demo: malware dropper host',     'age_days' => 14],
+        ['kind' => 'ip',     'value' => '198.51.100.250', 'reason' => 'Demo: botnet member',            'age_days' => 21],
+        ['kind' => 'ip',     'value' => '203.0.113.120',  'reason' => 'Demo: credential stuffing',      'age_days' => 30],
+        ['kind' => 'ip',     'value' => '192.0.2.42',     'reason' => 'Demo: reflective DDoS source',   'age_days' => 45],
+        ['kind' => 'subnet', 'value' => '198.51.100.0/24','reason' => 'Demo: blanket block on test net','age_days' => 60],
+        ['kind' => 'subnet', 'value' => '192.0.2.128/26', 'reason' => 'Demo: hostile subnet range',     'age_days' => 75],
     ];
 
     private const DEMO_ALLOWLIST = [
-        ['kind' => 'ip', 'value' => '203.0.113.10', 'reason' => 'Demo: trusted partner'],
+        ['kind' => 'ip',     'value' => '203.0.113.10',   'reason' => 'Demo: trusted partner'],
+        ['kind' => 'ip',     'value' => '203.0.113.50',   'reason' => 'Demo: monitoring probe'],
+        ['kind' => 'ip',     'value' => '203.0.113.77',   'reason' => 'Demo: corporate VPN exit'],
         ['kind' => 'subnet', 'value' => '203.0.113.0/28', 'reason' => 'Demo: office NAT range'],
+        ['kind' => 'subnet', 'value' => '198.18.0.0/16',  'reason' => 'Demo: lab benchmark range'],
     ];
 
+    /**
+     * 70 demo IPs spanning many countries / ASNs / address blocks. RFC 5737
+     * documentation prefixes plus the 100.64.0.0/10 CGNAT space and a
+     * handful of 2001:db8::/32 doc IPv6 endpoints. These IPs are not
+     * routable on the internet so they're safe to seed.
+     */
     private const DEMO_IPS = [
-        ['ip' => '192.0.2.5',    'country' => 'US', 'asn' => 13335, 'org' => 'Cloudflare, Inc.'],
-        ['ip' => '192.0.2.17',   'country' => 'DE', 'asn' => 24940, 'org' => 'Hetzner Online GmbH'],
-        ['ip' => '192.0.2.42',   'country' => 'RU', 'asn' => 12389, 'org' => 'Rostelecom'],
-        ['ip' => '192.0.2.99',   'country' => 'CN', 'asn' => 4134,  'org' => 'China Telecom'],
-        ['ip' => '192.0.2.130',  'country' => 'BR', 'asn' => 8167,  'org' => 'V tal'],
-        ['ip' => '192.0.2.180',  'country' => 'FR', 'asn' => 16276, 'org' => 'OVH SAS'],
-        ['ip' => '192.0.2.211',  'country' => 'NL', 'asn' => 60781, 'org' => 'LeaseWeb Netherlands'],
-        ['ip' => '198.51.100.7', 'country' => 'IN', 'asn' => 9498,  'org' => 'BHARTI Airtel'],
-        ['ip' => '198.51.100.55','country' => 'GB', 'asn' => 5089,  'org' => 'Virgin Media'],
-        ['ip' => '198.51.100.88','country' => 'JP', 'asn' => 4713,  'org' => 'NTT Communications'],
-        ['ip' => '198.51.100.140','country' => 'KR','asn' => 4766,  'org' => 'Korea Telecom'],
-        ['ip' => '198.51.100.200','country' => 'TR','asn' => 9121,  'org' => 'Turk Telekom'],
-        ['ip' => '203.0.113.50', 'country' => 'CA', 'asn' => 577,   'org' => 'Bell Canada'],
-        ['ip' => '203.0.113.77', 'country' => 'AU', 'asn' => 7545,  'org' => 'TPG Telecom'],
-        ['ip' => '203.0.113.120','country' => 'IT', 'asn' => 12874, 'org' => 'Fastweb'],
-        ['ip' => '203.0.113.150','country' => 'ES', 'asn' => 3352,  'org' => 'Telefonica de Espana'],
-        ['ip' => '203.0.113.190','country' => 'PL', 'asn' => 5617,  'org' => 'Orange Polska'],
-        ['ip' => '203.0.113.230','country' => 'UA', 'asn' => 13188, 'org' => 'Content Delivery Network'],
-        ['ip' => '192.0.2.244',  'country' => 'VN', 'asn' => 7552,  'org' => 'Viettel Group'],
-        ['ip' => '198.51.100.250','country' => 'ID','asn' => 17974, 'org' => 'PT Telekomunikasi Indonesia'],
-        ['ip' => '2001:db8::1',  'country' => 'US', 'asn' => 15169, 'org' => 'Google LLC'],
-        ['ip' => '2001:db8::abcd','country' => 'IE','asn' => 16509, 'org' => 'Amazon.com, Inc.'],
+        ['ip' => '192.0.2.5',     'country' => 'US', 'asn' => 13335, 'org' => 'Cloudflare, Inc.'],
+        ['ip' => '192.0.2.17',    'country' => 'DE', 'asn' => 24940, 'org' => 'Hetzner Online GmbH'],
+        ['ip' => '192.0.2.31',    'country' => 'DE', 'asn' => 8881,  'org' => '1&1 Versatel Deutschland'],
+        ['ip' => '192.0.2.42',    'country' => 'RU', 'asn' => 12389, 'org' => 'Rostelecom'],
+        ['ip' => '192.0.2.58',    'country' => 'RU', 'asn' => 8359,  'org' => 'MTS PJSC'],
+        ['ip' => '192.0.2.71',    'country' => 'BY', 'asn' => 6697,  'org' => 'Beltelecom'],
+        ['ip' => '192.0.2.86',    'country' => 'CN', 'asn' => 4134,  'org' => 'China Telecom'],
+        ['ip' => '192.0.2.99',    'country' => 'CN', 'asn' => 4837,  'org' => 'China Unicom'],
+        ['ip' => '192.0.2.114',   'country' => 'CN', 'asn' => 9808,  'org' => 'China Mobile'],
+        ['ip' => '192.0.2.130',   'country' => 'BR', 'asn' => 8167,  'org' => 'V.tal'],
+        ['ip' => '192.0.2.142',   'country' => 'BR', 'asn' => 26599, 'org' => 'TELEFONICA BRASIL'],
+        ['ip' => '192.0.2.156',   'country' => 'AR', 'asn' => 10481, 'org' => 'Telefonica de Argentina'],
+        ['ip' => '192.0.2.170',   'country' => 'MX', 'asn' => 8151,  'org' => 'Uninet S.A. de C.V.'],
+        ['ip' => '192.0.2.180',   'country' => 'FR', 'asn' => 16276, 'org' => 'OVH SAS'],
+        ['ip' => '192.0.2.196',   'country' => 'FR', 'asn' => 3215,  'org' => 'Orange S.A.'],
+        ['ip' => '192.0.2.211',   'country' => 'NL', 'asn' => 60781, 'org' => 'LeaseWeb Netherlands'],
+        ['ip' => '192.0.2.225',   'country' => 'NL', 'asn' => 1136,  'org' => 'KPN B.V.'],
+        ['ip' => '192.0.2.239',   'country' => 'BE', 'asn' => 5432,  'org' => 'Proximus NV'],
+        ['ip' => '192.0.2.244',   'country' => 'VN', 'asn' => 7552,  'org' => 'Viettel Group'],
+        ['ip' => '192.0.2.250',   'country' => 'TH', 'asn' => 23969, 'org' => 'TOT Public Company'],
+
+        ['ip' => '198.51.100.7',   'country' => 'IN', 'asn' => 9498,  'org' => 'BHARTI Airtel'],
+        ['ip' => '198.51.100.21',  'country' => 'IN', 'asn' => 55836, 'org' => 'Reliance Jio'],
+        ['ip' => '198.51.100.34',  'country' => 'PK', 'asn' => 17557, 'org' => 'Pakistan Telecom'],
+        ['ip' => '198.51.100.42',  'country' => 'BD', 'asn' => 24389, 'org' => 'Grameenphone Ltd.'],
+        ['ip' => '198.51.100.55',  'country' => 'GB', 'asn' => 5089,  'org' => 'Virgin Media'],
+        ['ip' => '198.51.100.66',  'country' => 'GB', 'asn' => 2856,  'org' => 'BT Group'],
+        ['ip' => '198.51.100.79',  'country' => 'IE', 'asn' => 5466,  'org' => 'eircom Limited'],
+        ['ip' => '198.51.100.88',  'country' => 'JP', 'asn' => 4713,  'org' => 'NTT Communications'],
+        ['ip' => '198.51.100.103', 'country' => 'JP', 'asn' => 17676, 'org' => 'SoftBank Corp.'],
+        ['ip' => '198.51.100.117', 'country' => 'KR', 'asn' => 4766,  'org' => 'Korea Telecom'],
+        ['ip' => '198.51.100.140', 'country' => 'KR', 'asn' => 9318,  'org' => 'SK Broadband'],
+        ['ip' => '198.51.100.155', 'country' => 'TW', 'asn' => 3462,  'org' => 'Chunghwa Telecom'],
+        ['ip' => '198.51.100.168', 'country' => 'HK', 'asn' => 4760,  'org' => 'HKT Limited'],
+        ['ip' => '198.51.100.180', 'country' => 'SG', 'asn' => 3758,  'org' => 'Singtel'],
+        ['ip' => '198.51.100.193', 'country' => 'MY', 'asn' => 4788,  'org' => 'TM Net'],
+        ['ip' => '198.51.100.200', 'country' => 'TR', 'asn' => 9121,  'org' => 'Turk Telekom'],
+        ['ip' => '198.51.100.213', 'country' => 'GR', 'asn' => 3329,  'org' => 'OTE SA'],
+        ['ip' => '198.51.100.225', 'country' => 'RO', 'asn' => 8708,  'org' => 'RCS & RDS'],
+        ['ip' => '198.51.100.240', 'country' => 'BG', 'asn' => 8866,  'org' => 'Vivacom'],
+        ['ip' => '198.51.100.250', 'country' => 'ID', 'asn' => 17974, 'org' => 'PT Telekomunikasi Indonesia'],
+
+        ['ip' => '203.0.113.7',   'country' => 'US', 'asn' => 7922,  'org' => 'Comcast Cable'],
+        ['ip' => '203.0.113.18',  'country' => 'US', 'asn' => 20115, 'org' => 'Charter Communications'],
+        ['ip' => '203.0.113.29',  'country' => 'US', 'asn' => 32934, 'org' => 'Meta Platforms, Inc.'],
+        ['ip' => '203.0.113.50',  'country' => 'CA', 'asn' => 577,   'org' => 'Bell Canada'],
+        ['ip' => '203.0.113.63',  'country' => 'CA', 'asn' => 812,   'org' => 'Rogers Communications'],
+        ['ip' => '203.0.113.77',  'country' => 'AU', 'asn' => 7545,  'org' => 'TPG Telecom'],
+        ['ip' => '203.0.113.91',  'country' => 'AU', 'asn' => 1221,  'org' => 'Telstra Corporation'],
+        ['ip' => '203.0.113.105', 'country' => 'NZ', 'asn' => 4648,  'org' => 'Spark New Zealand'],
+        ['ip' => '203.0.113.120', 'country' => 'IT', 'asn' => 12874, 'org' => 'Fastweb'],
+        ['ip' => '203.0.113.135', 'country' => 'IT', 'asn' => 1267,  'org' => 'Wind Tre S.p.A.'],
+        ['ip' => '203.0.113.150', 'country' => 'ES', 'asn' => 3352,  'org' => 'Telefonica de Espana'],
+        ['ip' => '203.0.113.165', 'country' => 'ES', 'asn' => 12479, 'org' => 'Orange Espagne'],
+        ['ip' => '203.0.113.178', 'country' => 'PT', 'asn' => 8657,  'org' => 'MEO'],
+        ['ip' => '203.0.113.190', 'country' => 'PL', 'asn' => 5617,  'org' => 'Orange Polska'],
+        ['ip' => '203.0.113.205', 'country' => 'CZ', 'asn' => 6855,  'org' => 'O2 Czech Republic'],
+        ['ip' => '203.0.113.218', 'country' => 'HU', 'asn' => 5483,  'org' => 'Magyar Telekom'],
+        ['ip' => '203.0.113.230', 'country' => 'UA', 'asn' => 13188, 'org' => 'Content Delivery Network'],
+        ['ip' => '203.0.113.245', 'country' => 'EG', 'asn' => 8452,  'org' => 'TE Data'],
+
+        ['ip' => '100.64.5.10',   'country' => 'ZA', 'asn' => 36937, 'org' => 'MTN South Africa'],
+        ['ip' => '100.64.32.55',  'country' => 'NG', 'asn' => 36873, 'org' => 'MTN Nigeria'],
+        ['ip' => '100.64.71.7',   'country' => 'KE', 'asn' => 33771, 'org' => 'Safaricom'],
+        ['ip' => '100.64.99.42',  'country' => 'AE', 'asn' => 5384,  'org' => 'Emirates Telecom'],
+        ['ip' => '100.64.128.3',  'country' => 'SA', 'asn' => 25019, 'org' => 'Saudi Telecom'],
+        ['ip' => '100.64.155.88', 'country' => 'IL', 'asn' => 8551,  'org' => 'Bezeq International'],
+        ['ip' => '100.64.180.21', 'country' => 'CL', 'asn' => 7418,  'org' => 'Movistar Chile'],
+
+        ['ip' => '2001:db8::1',     'country' => 'US', 'asn' => 15169, 'org' => 'Google LLC'],
+        ['ip' => '2001:db8::42',    'country' => 'US', 'asn' => 8075,  'org' => 'Microsoft Corporation'],
+        ['ip' => '2001:db8::abcd',  'country' => 'IE', 'asn' => 16509, 'org' => 'Amazon.com, Inc.'],
+        ['ip' => '2001:db8::cafe',  'country' => 'NL', 'asn' => 14061, 'org' => 'DigitalOcean LLC'],
+        ['ip' => '2001:db8::beef',  'country' => 'SG', 'asn' => 16509, 'org' => 'Amazon AWS Asia'],
+        ['ip' => '2001:db8:1::55',  'country' => 'JP', 'asn' => 2497,  'org' => 'IIJ Inc.'],
+    ];
+
+    /**
+     * Reports span this many days into the past. Older reports decay more,
+     * which yields a long-tail distribution in the dashboard chart.
+     */
+    private const DEMO_REPORT_HORIZON_DAYS = 90;
+
+    /**
+     * Per-IP report-volume tiers. Indexed by `ip_index % count`. Each tuple
+     * is `[low, high]` inclusive — actual count is jittered between them.
+     */
+    private const DEMO_REPORT_VOLUME_TIERS = [
+        [15, 22],   // mostly-quiet IPs
+        [25, 40],   // moderate offenders
+        [45, 70],   // heavy offenders
+        [80, 110],  // hostile / persistent
     ];
 
     public function __construct(
@@ -205,6 +306,12 @@ final class MaintenanceController
             'enrichment' => 0,
         ];
 
+        // Wrap the bulk seed in a single transaction. The new dataset is
+        // ~3,000 reports — without a transaction, SQLite fsyncs per insert
+        // and the seed takes tens of seconds.
+        $this->connection->beginTransaction();
+
+        try {
         $reporterIds = [];
         foreach (self::DEMO_REPORTERS as $r) {
             $reporterIds[$r['name']] = $this->reporters->create(
@@ -227,6 +334,12 @@ final class MaintenanceController
             ++$summary['consumers'];
         }
 
+        $horizonHours = self::DEMO_REPORT_HORIZON_DAYS * 24;
+        $reporterCount = count(self::DEMO_REPORTERS);
+        $tierCount = count(self::DEMO_REPORT_VOLUME_TIERS);
+        $categorySlugs = array_keys($categoryIds);
+        $catCount = count($categorySlugs);
+
         foreach (self::DEMO_IPS as $i => $row) {
             try {
                 $ip = IpAddress::fromString($row['ip']);
@@ -243,20 +356,49 @@ final class MaintenanceController
             ++$summary['enrichment'];
             ++$summary['ips'];
 
-            // Stagger reports across the past 30 days so decay produces
-            // visually distinct scores in the dashboard.
-            $reportCount = 2 + ($i % 6) * 3;
+            // Stagger reports across the past DEMO_REPORT_HORIZON_DAYS so
+            // decay produces visually distinct scores. Each IP draws from a
+            // volume tier so the dataset has a realistic long-tail shape.
+            [$tierLow, $tierHigh] = self::DEMO_REPORT_VOLUME_TIERS[$i % $tierCount];
+            $reportCount = $tierLow + (($i * 7 + 3) % max(1, $tierHigh - $tierLow + 1));
+
+            // Each IP "specialises" in one or two primary categories so the
+            // category breakdown isn't perfectly uniform.
+            $primaryCatIdx = $i % $catCount;
+            $secondaryCatIdx = ($i * 3 + 1) % $catCount;
+
             for ($n = 0; $n < $reportCount; ++$n) {
-                $slugIdx = ($i + $n) % count($categoryIds);
-                $slug = array_keys($categoryIds)[$slugIdx];
+                // 70% primary, 20% secondary, 10% any other category.
+                $rolled = ($n * 13 + $i * 5) % 10;
+                if ($rolled < 7) {
+                    $slugIdx = $primaryCatIdx;
+                } elseif ($rolled < 9) {
+                    $slugIdx = $secondaryCatIdx;
+                } else {
+                    $slugIdx = ($i + $n * 2) % $catCount;
+                }
+                $slug = $categorySlugs[$slugIdx];
                 $catId = $categoryIds[$slug];
-                $reporterIdx = $n % count(self::DEMO_REPORTERS);
+
+                $reporterIdx = ($n + $i) % $reporterCount;
                 $reporterName = self::DEMO_REPORTERS[$reporterIdx]['name'];
                 $reporterId = $reporterIds[$reporterName];
                 $weight = self::DEMO_REPORTERS[$reporterIdx]['trust_weight'];
 
-                $ageHours = (int) (($n * 17 + $i * 23) % (30 * 24));
-                $receivedAt = $now->modify('-' . $ageHours . ' hours');
+                // Ages spread across the horizon with a bias toward "recent"
+                // (more activity in last 14 days), and minute-level jitter.
+                $bucket = ($n * 31 + $i * 19) % 100;
+                if ($bucket < 35) {
+                    $ageHours = ($n * 11 + $i * 7) % (14 * 24);
+                } elseif ($bucket < 70) {
+                    $ageHours = (14 * 24) + (($n * 17 + $i * 23) % (30 * 24));
+                } else {
+                    $ageHours = (44 * 24) + (($n * 29 + $i * 13) % max(1, $horizonHours - 44 * 24));
+                }
+                $ageMinutes = ($n * 7 + $i * 11) % 60;
+                $receivedAt = $now
+                    ->modify('-' . $ageHours . ' hours')
+                    ->modify('-' . $ageMinutes . ' minutes');
 
                 $this->reports->insert(
                     $ip->binary(),
@@ -277,14 +419,14 @@ final class MaintenanceController
         foreach (self::DEMO_MANUAL_BLOCKS as $mb) {
             try {
                 if ($mb['kind'] === 'ip') {
-                    $this->manualBlocks->createIp(
+                    $blockId = $this->manualBlocks->createIp(
                         IpAddress::fromString($mb['value']),
                         $mb['reason'],
                         null,
                         $actingUserId,
                     );
                 } else {
-                    $this->manualBlocks->createSubnet(
+                    $blockId = $this->manualBlocks->createSubnet(
                         Cidr::fromString($mb['value']),
                         $mb['reason'],
                         null,
@@ -292,6 +434,21 @@ final class MaintenanceController
                     );
                 }
                 ++$summary['manual_blocks'];
+
+                // Backfill `created_at` so the bans-by-day chart on the
+                // dashboard shows realistic spread instead of a single
+                // spike at seed time.
+                $ageDays = isset($mb['age_days']) ? (int) $mb['age_days'] : 0;
+                if ($ageDays > 0) {
+                    $createdAt = $now->modify('-' . $ageDays . ' days');
+                    $this->connection->executeStatement(
+                        'UPDATE manual_blocks SET created_at = :created_at WHERE id = :id',
+                        [
+                            'created_at' => $createdAt->format('Y-m-d H:i:s'),
+                            'id' => $blockId,
+                        ],
+                    );
+                }
             } catch (\Throwable) {
                 // Ignore — demo data shouldn't block setup on a single bad row.
             }
@@ -318,6 +475,14 @@ final class MaintenanceController
             }
         }
 
+        $this->connection->commit();
+        } catch (\Throwable $e) {
+            if ($this->connection->isTransactionActive()) {
+                $this->connection->rollBack();
+            }
+            throw $e;
+        }
+
         // Recompute so dashboards show the seeded scores immediately.
         $outcome = $this->jobRunner->run($this->recomputeJob, ['full' => true], 'manual');
 

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

@@ -6,6 +6,7 @@ namespace App\Application\Admin;
 
 use App\Domain\Time\Clock;
 use App\Infrastructure\Reputation\DashboardStatsRepository;
+use DateTimeImmutable;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -57,6 +58,7 @@ final class StatsController
         }
 
         $since = $now->modify('-24 hours');
+        $weekAgo = $now->modify('-7 days');
 
         $payload = [
             'active_blocks' => $this->stats->activeBlocksApprox(),
@@ -66,6 +68,11 @@ 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),
+                $weekAgo,
+                $now,
+            ),
             'jobs_status' => $this->stats->jobsStatus(self::EXPECTED_INTERVALS, $now),
             'reference_policy' => 'moderate',
         ];
@@ -75,4 +82,31 @@ final class StatsController
 
         return self::json($response, 200, $payload);
     }
+
+    /**
+     * 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.
+     *
+     * @param list<array{day: string, count: int}> $rows
+     * @return list<array{day: string, count: int}>
+     */
+    private static function fillMissingDays(array $rows, DateTimeImmutable $from, DateTimeImmutable $to): array
+    {
+        $byDay = [];
+        foreach ($rows as $row) {
+            $byDay[$row['day']] = $row['count'];
+        }
+
+        $out = [];
+        $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)];
+            $cursor = $cursor->modify('+1 day');
+        }
+
+        return $out;
+    }
 }

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

@@ -84,6 +84,34 @@ final class DashboardStatsRepository extends RepositoryBase
         return $out;
     }
 
+    /**
+     * 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.
+     *
+     * @return list<array{day: string, count: int}>
+     */
+    public function bansByDaySince(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';
+
+        /** @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']];
+        }
+
+        return $out;
+    }
+
     /**
      * @return list<array{slug: string, count: int}>
      */

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

@@ -34,6 +34,13 @@ 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']);
+        }
         self::assertSame('moderate', $body['reference_policy']);
     }
 
@@ -81,6 +88,12 @@ final class StatsControllerTest extends AppTestCase
 
         self::assertSame(1, $body['manual_blocks_count']);
         self::assertSame(1, $body['allowlist_count']);
+
+        // 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);
     }
 
     private function seedReport(string $ip, string $categorySlug, int $reporterId): void

+ 163 - 18
ui/resources/js/app.js

@@ -1,6 +1,21 @@
 import Alpine from 'alpinejs';
 import 'htmx.org';
-import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Title } from 'chart.js';
+import {
+    Chart,
+    BarController,
+    BarElement,
+    LineController,
+    LineElement,
+    PointElement,
+    PieController,
+    ArcElement,
+    CategoryScale,
+    LinearScale,
+    Tooltip,
+    Title,
+    Legend,
+    Filler,
+} from 'chart.js';
 
 // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
 // initial paint; this just wires the toggle button.
@@ -32,27 +47,57 @@ 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);
+// Dashboard charts. Each canvas carries its data in a data-attribute
+// (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the
+// pieces we use so the bundle stays small.
+Chart.register(
+    BarController,
+    BarElement,
+    LineController,
+    LineElement,
+    PointElement,
+    PieController,
+    ArcElement,
+    CategoryScale,
+    LinearScale,
+    Tooltip,
+    Title,
+    Legend,
+    Filler,
+);
 
-function renderReportsChart() {
-    const canvas = document.getElementById('reports-chart');
-    if (!canvas) return;
-    let buckets = [];
+// Palette tuned for both light and dark backgrounds. Reused across the
+// pie charts so categories/reporters don't share colours.
+const PIE_COLORS = [
+    '#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
+    '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6',
+];
+
+function chartTheme() {
+    const isDark = document.documentElement.classList.contains('dark');
+    return {
+        tickColor: isDark ? '#94a3b8' : '#475569',
+        gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
+        legendColor: isDark ? '#cbd5e1' : '#334155',
+    };
+}
+
+function parseSeries(canvas, attr) {
     try {
-        buckets = JSON.parse(canvas.dataset.buckets || '[]');
+        return JSON.parse(canvas.dataset[attr] || '[]');
     } catch (e) {
-        return;
+        return [];
     }
+}
+
+function renderReportsChart() {
+    const canvas = document.getElementById('reports-chart');
+    if (!canvas) return;
+    const buckets = parseSeries(canvas, 'buckets');
 
     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)';
+    const t = chartTheme();
 
     new Chart(canvas, {
         type: 'bar',
@@ -69,14 +114,114 @@ function renderReportsChart() {
             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 },
+                x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
+                y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
             },
         },
     });
 }
 
-document.addEventListener('DOMContentLoaded', renderReportsChart);
+function renderPieChart(canvasId) {
+    const canvas = document.getElementById(canvasId);
+    if (!canvas) return;
+    const series = parseSeries(canvas, 'series');
+    if (series.length === 0) return;
+
+    const labelKey = canvas.dataset.labelKey || 'name';
+    const labels = series.map((row) => String(row[labelKey] ?? ''));
+    const data = series.map((row) => row.count || 0);
+    const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
+    const t = chartTheme();
+
+    new Chart(canvas, {
+        type: 'pie',
+        data: {
+            labels,
+            datasets: [{
+                data,
+                backgroundColor: colors,
+                borderColor: 'rgba(255,255,255,0.1)',
+                borderWidth: 1,
+            }],
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            plugins: {
+                legend: {
+                    position: 'bottom',
+                    labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
+                },
+                tooltip: {
+                    callbacks: {
+                        label: (ctx) => {
+                            const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
+                            const pct = ((ctx.parsed / total) * 100).toFixed(1);
+                            return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
+                        },
+                    },
+                },
+            },
+        },
+    });
+}
+
+function renderBansTrendChart() {
+    const canvas = document.getElementById('bans-trend-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' });
+    });
+    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',
+            }],
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            plugins: {
+                legend: { display: false },
+                tooltip: {
+                    callbacks: {
+                        title: (items) => (items[0] && series[items[0].dataIndex]?.day) || '',
+                    },
+                },
+            },
+            scales: {
+                x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
+                y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
+            },
+        },
+    });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+    renderReportsChart();
+    renderPieChart('top-reporters-chart');
+    renderPieChart('top-categories-chart');
+    renderBansTrendChart();
+});
 
 // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
 // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO

+ 26 - 21
ui/resources/views/pages/dashboard.twig

@@ -46,20 +46,16 @@
             </div>
         </section>
 
-        <section class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
+        <section class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
             <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>
+                    <div class="mt-3 h-64">
+                        <canvas id="top-reporters-chart"
+                                data-series="{{ stats.topReporters|json_encode|e('html_attr') }}"
+                                data-label-key="name">
+                        </canvas>
+                    </div>
                 {% else %}
                     <p class="mt-2 text-sm text-slate-400">No reports in the last 24 hours.</p>
                 {% endif %}
@@ -68,20 +64,29 @@
             <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>
+                    <div class="mt-3 h-64">
+                        <canvas id="top-categories-chart"
+                                data-series="{{ stats.topCategories|json_encode|e('html_attr') }}"
+                                data-label-key="slug">
+                        </canvas>
+                    </div>
                 {% 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">Bans (7 days)</h2>
+                {% if stats.bansByDay|length > 0 %}
+                    <div class="mt-3 h-64">
+                        <canvas id="bans-trend-chart"
+                                data-series="{{ stats.bansByDay|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>
+                {% 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">

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

@@ -14,6 +14,7 @@ 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}
      */
     public function __construct(
@@ -24,6 +25,7 @@ final class DashboardStatsDto
         public readonly array $reportsByHour,
         public readonly array $topReporters,
         public readonly array $topCategories,
+        public readonly array $bansByDay,
         public readonly array $jobsStatus,
         public readonly string $referencePolicy,
     ) {
@@ -42,6 +44,7 @@ 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'),
             jobsStatus: self::extractList($payload, 'jobs_status'),
             referencePolicy: (string) ($payload['reference_policy'] ?? 'moderate'),
         );

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

@@ -30,6 +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],
+            ],
             'jobs_status' => [['name' => 'recompute-scores', 'last_finished_at' => '2026-04-29T10:55:00Z', 'status' => 'success', 'overdue' => false]],
             'reference_policy' => 'moderate',
         ]);
@@ -41,6 +50,9 @@ final class DashboardPageTest extends AppTestCase
         self::assertStringContainsString('Dashboard', $body);
         self::assertStringContainsString('reports', $body);
         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('web-prod-01', $body);
         self::assertStringContainsString('brute_force', $body);
         self::assertStringContainsString('recompute-scores', $body);