Bläddra i källkod

feat(ui): score distribution chart on policy editor

Adds a line chart above the preview table on the policy edit page
showing IPs grouped by max score in steps of 5, with the policy's
per-category thresholds drawn as vertical lines. Refreshed via the
section's own Refresh button, same pattern as the preview pane.

API: GET /api/v1/admin/policies/{id}/score-distribution returns
bucketed counts plus the policy's thresholds (Viewer-readable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 vecka sedan
förälder
incheckning
b2ac7e2d98

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

@@ -312,6 +312,8 @@ final class AppFactory
                 ->add(RbacMiddleware::require($rf, Role::Viewer));
             $admin->get('/policies/{id}/preview', [$policies, 'preview'])
                 ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->get('/policies/{id}/score-distribution', [$policies, 'scoreDistribution'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
             $admin->post('/policies', [$policies, 'create'])
                 ->add(RbacMiddleware::require($rf, Role::Admin));
             $admin->patch('/policies/{id}', [$policies, 'update'])

+ 38 - 0
api/src/Application/Admin/PoliciesController.php

@@ -15,6 +15,7 @@ use App\Infrastructure\Category\CategoryRepository;
 use App\Infrastructure\ManualBlock\ManualBlockRepository;
 use App\Infrastructure\Policy\PolicyRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
+use App\Infrastructure\Reputation\IpScoreRepository;
 use DateTimeImmutable;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -42,6 +43,7 @@ final class PoliciesController
         private readonly AuditEmitter $audit,
         private readonly ManualBlockRepository $manualBlocks,
         private readonly Clock $clock,
+        private readonly IpScoreRepository $ipScores,
     ) {
     }
 
@@ -361,6 +363,42 @@ final class PoliciesController
         ]);
     }
 
+    /**
+     * @param array{id: string} $args
+     */
+    public function scoreDistribution(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $policy = $this->policies->findById($id);
+        if ($policy === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $distribution = $this->ipScores->scoreDistribution(5.0);
+
+        $thresholds = [];
+        $slugByCategoryId = $this->slugByCategoryId();
+        foreach ($policy->thresholds as $catId => $threshold) {
+            $slug = $slugByCategoryId[$catId] ?? null;
+            $thresholds[] = [
+                'category_id' => $catId,
+                'category_slug' => $slug,
+                'threshold' => $threshold,
+            ];
+        }
+
+        return self::json($response, 200, [
+            'buckets' => $distribution['buckets'],
+            'max_score' => $distribution['max_score'],
+            'bucket_size' => $distribution['bucket_size'],
+            'thresholds' => $thresholds,
+            'policy' => $policy->name,
+        ]);
+    }
+
     private static function isoOrNull(?DateTimeImmutable $dt): ?string
     {
         return $dt?->format('Y-m-d\TH:i:s\Z');

+ 46 - 0
api/src/Infrastructure/Reputation/IpScoreRepository.php

@@ -306,6 +306,52 @@ class IpScoreRepository extends RepositoryBase
         return ['items' => $items, 'total' => $total];
     }
 
+    /**
+     * Histogram of IPs by their max score across all categories, bucketed in
+     * `$bucketSize`-wide bins (e.g. 5 → 0..5, 5..10, …). Only IPs with score
+     * > 0 are counted. Returns one row per bucket that has at least one IP,
+     * plus the overall max score so callers can size axes without a second
+     * query.
+     *
+     * @return array{
+     *     buckets: list<array{start: float, count: int}>,
+     *     max_score: float,
+     *     bucket_size: float,
+     * }
+     */
+    public function scoreDistribution(float $bucketSize): array
+    {
+        if ($bucketSize <= 0) {
+            $bucketSize = 5.0;
+        }
+        $sql = 'SELECT CAST(max_score / :bucket AS INTEGER) AS bucket_idx, COUNT(*) AS cnt '
+            . 'FROM (SELECT ip_bin, MAX(score) AS max_score FROM ip_scores '
+            . 'WHERE score > 0 GROUP BY ip_bin) t '
+            . 'GROUP BY bucket_idx ORDER BY bucket_idx';
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, [
+            'bucket' => number_format($bucketSize, 4, '.', ''),
+        ]);
+
+        $buckets = [];
+        foreach ($rows as $row) {
+            $buckets[] = [
+                'start' => (float) $row['bucket_idx'] * $bucketSize,
+                'count' => (int) $row['cnt'],
+            ];
+        }
+
+        $maxScore = (float) ($this->connection()->fetchOne(
+            'SELECT MAX(score) FROM ip_scores WHERE score > 0'
+        ) ?: 0);
+
+        return [
+            'buckets' => $buckets,
+            'max_score' => $maxScore,
+            'bucket_size' => $bucketSize,
+        ];
+    }
+
     /**
      * Build a parametrised `(:p0, :p1, …)` clause + matching params map for
      * a list of binary IPs. Used by the search's status filter.

+ 23 - 0
api/tests/Integration/Admin/PoliciesControllerTest.php

@@ -200,4 +200,27 @@ final class PoliciesControllerTest extends AppTestCase
         self::assertSame('paranoid', $body['policy']);
         self::assertIsArray($body['sample']);
     }
+
+    public function testScoreDistributionReturnsBucketsAndThresholds(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
+
+        $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}/score-distribution", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertArrayHasKey('buckets', $body);
+        self::assertIsArray($body['buckets']);
+        self::assertArrayHasKey('thresholds', $body);
+        self::assertIsArray($body['thresholds']);
+        self::assertArrayHasKey('bucket_size', $body);
+        self::assertEqualsWithDelta(5.0, $body['bucket_size'], 0.0001);
+        self::assertSame('moderate', $body['policy']);
+        foreach ($body['buckets'] as $bucket) {
+            self::assertArrayHasKey('start', $bucket);
+            self::assertArrayHasKey('count', $bucket);
+        }
+    }
 }

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

@@ -66,6 +66,10 @@ Chart.register(
     Filler,
 );
 
+// Expose Chart globally so per-page inline scripts (e.g. the policy
+// score-distribution component) can render charts without re-bundling.
+window.Chart = Chart;
+
 // Palette tuned for both light and dark backgrounds. Reused across the
 // pie charts so categories/reporters don't share colours.
 const PIE_COLORS = [

+ 167 - 0
ui/resources/views/pages/policies/edit.twig

@@ -93,6 +93,21 @@
         {% endif %}
     </form>
 
+    <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
+             x-data="policyScoreDistribution({{ policy.id }})" x-init="load()">
+        <div class="flex items-center justify-between">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Score distribution</h2>
+            <button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
+        </div>
+        <p class="mt-2 text-xs text-slate-400">
+            IPs grouped by max score across categories in steps of 5; vertical lines mark this policy's thresholds.
+        </p>
+        <div class="mt-3 h-64">
+            <canvas x-ref="canvas"></canvas>
+        </div>
+        <p class="mt-2 text-xs text-slate-400" x-show="empty">No scored IPs in the database yet.</p>
+    </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"
              x-data="policyPreview({{ policy.id }})" x-init="load()">
         <div class="flex items-center justify-between">
@@ -194,5 +209,157 @@ window.policyPreview = function (id) {
         },
     };
 };
+
+window.policyScoreDistribution = function (id) {
+    const THRESHOLD_COLORS = [
+        '#ef4444', '#f97316', '#eab308', '#22c55e',
+        '#0ea5e9', '#a855f7', '#ec4899', '#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',
+        };
+    }
+    return {
+        empty: false,
+        chart: null,
+        async load() {
+            try {
+                const res = await fetch('/app/policies/' + id + '/score-distribution-proxy', { credentials: 'same-origin' });
+                if (!res.ok) throw new Error('distribution ' + res.status);
+                const data = await res.json();
+                this.render(data);
+            } catch (e) {
+                this.empty = true;
+                if (this.chart) { this.chart.destroy(); this.chart = null; }
+            }
+        },
+        render(data) {
+            const buckets = Array.isArray(data.buckets) ? data.buckets : [];
+            const bucketSize = Number(data.bucket_size) || 5;
+            const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
+            const maxScore = Number(data.max_score) || 0;
+
+            // Pad the histogram with zero-count buckets through the highest
+            // observed bucket OR the highest threshold so the threshold
+            // lines are always visible on the X axis.
+            let upperX = maxScore;
+            for (const t of thresholds) {
+                if (typeof t.threshold === 'number' && t.threshold > upperX) {
+                    upperX = t.threshold;
+                }
+            }
+            if (buckets.length > 0) {
+                const lastStart = buckets[buckets.length - 1].start;
+                if (lastStart + bucketSize > upperX) {
+                    upperX = lastStart + bucketSize;
+                }
+            }
+
+            const points = buckets.map((b) => ({ x: Number(b.start) || 0, y: Number(b.count) || 0 }));
+            this.empty = points.length === 0;
+
+            const maxCount = points.reduce((m, p) => p.y > m ? p.y : m, 0);
+            const yMax = maxCount > 0 ? maxCount : 1;
+
+            const datasets = [{
+                label: 'IPs',
+                data: points,
+                borderColor: '#6366f1',
+                backgroundColor: 'rgba(99,102,241,0.18)',
+                tension: 0.25,
+                fill: true,
+                pointRadius: 3,
+                pointHoverRadius: 5,
+                pointBackgroundColor: '#6366f1',
+                stepped: false,
+                order: 2,
+            }];
+            thresholds.forEach((t, i) => {
+                if (typeof t.threshold !== 'number') return;
+                const color = THRESHOLD_COLORS[i % THRESHOLD_COLORS.length];
+                datasets.push({
+                    label: 'threshold ' + (t.category_slug || ('#' + t.category_id)),
+                    data: [
+                        { x: t.threshold, y: 0 },
+                        { x: t.threshold, y: yMax },
+                    ],
+                    borderColor: color,
+                    backgroundColor: color,
+                    borderWidth: 2,
+                    borderDash: [6, 4],
+                    pointRadius: 0,
+                    pointHoverRadius: 0,
+                    fill: false,
+                    tension: 0,
+                    order: 1,
+                });
+            });
+
+            const t = chartTheme();
+            const canvas = this.$refs.canvas;
+            if (!canvas) return;
+            if (this.chart) {
+                this.chart.destroy();
+            }
+            // Chart is registered globally by app.js; if it isn't yet
+            // (script ordering), bail silently.
+            if (typeof window.Chart === 'undefined' && typeof Chart === 'undefined') {
+                return;
+            }
+            const C = (typeof window.Chart !== 'undefined') ? window.Chart : Chart;
+            this.chart = new C(canvas, {
+                type: 'line',
+                data: { datasets },
+                options: {
+                    responsive: true,
+                    maintainAspectRatio: false,
+                    parsing: false,
+                    plugins: {
+                        legend: {
+                            position: 'bottom',
+                            labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
+                        },
+                        tooltip: {
+                            callbacks: {
+                                title: (items) => {
+                                    if (!items.length) return '';
+                                    const it = items[0];
+                                    if (it.dataset.label === 'IPs') {
+                                        const start = it.parsed.x;
+                                        return 'score ' + start + '–' + (start + bucketSize);
+                                    }
+                                    return it.dataset.label + ' = ' + it.parsed.x;
+                                },
+                                label: (it) => it.dataset.label === 'IPs'
+                                    ? (it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'))
+                                    : '',
+                            },
+                        },
+                    },
+                    scales: {
+                        x: {
+                            type: 'linear',
+                            min: 0,
+                            max: upperX > 0 ? upperX : bucketSize,
+                            title: { display: true, text: 'score', color: t.tickColor },
+                            ticks: { color: t.tickColor, stepSize: bucketSize },
+                            grid: { color: t.gridColor },
+                        },
+                        y: {
+                            beginAtZero: true,
+                            title: { display: true, text: 'IPs', color: t.tickColor },
+                            ticks: { color: t.tickColor, precision: 0 },
+                            grid: { color: t.gridColor },
+                        },
+                    },
+                },
+            });
+        },
+    };
+};
 </script>
 {% endblock %}

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

@@ -203,6 +203,14 @@ final class AdminClient
         return $this->api->request('GET', '/api/v1/admin/policies/' . $id . '/preview', [], $actingUserId);
     }
 
+    /**
+     * @return array<string, mixed>
+     */
+    public function policyScoreDistribution(int $actingUserId, int $id): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/policies/' . $id . '/score-distribution', [], $actingUserId);
+    }
+
     // ---- reporters (M10) ----
 
     /**

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

@@ -153,6 +153,7 @@ final class AppFactory
             $group->post('/policies/{id}/delete', [$policies, 'delete']);
             // GET-only XHR proxy used by the edit page's preview pane.
             $group->get('/policies/{id}/preview-proxy', [$policies, 'previewProxy']);
+            $group->get('/policies/{id}/score-distribution-proxy', [$policies, 'scoreDistributionProxy']);
 
             /** @var ReportersController $reporters */
             $reporters = $container->get(ReportersController::class);

+ 33 - 0
ui/src/Controllers/PoliciesController.php

@@ -84,6 +84,39 @@ final class PoliciesController
         return $response->withStatus(200)->withHeader('Content-Type', 'application/json');
     }
 
+    /**
+     * Browser-side distribution proxy: fetched by the policy edit page's
+     * Alpine component to render the score-histogram chart with the
+     * policy's thresholds overlaid as vertical lines.
+     *
+     * @param array{id: string} $args
+     */
+    public function scoreDistributionProxy(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $user = $this->sessionManager->getUser();
+        if ($user === null) {
+            $response->getBody()->write((string) json_encode(['error' => 'unauthenticated']));
+
+            return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
+        }
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            $response->getBody()->write((string) json_encode(['error' => 'not_found']));
+
+            return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
+        }
+        try {
+            $payload = $this->admin->policyScoreDistribution($user->userId, $id);
+        } catch (ApiException $e) {
+            $response->getBody()->write((string) json_encode(['error' => $e->getMessage()]));
+
+            return $response->withStatus(502)->withHeader('Content-Type', 'application/json');
+        }
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response->withStatus(200)->withHeader('Content-Type', 'application/json');
+    }
+
     public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
         $redirect = $this->requireUser($request, $response);