Sfoglia il codice sorgente

feat(ui): per-category lines + threshold-region shading on policy chart

The policy editor's score-distribution chart used a single "max-score
across categories" line plus dashed vertical threshold lines. Replaces
that with one histogram line per thresholded category and a translucent
region to the right of each threshold marking the score range that lands
on the blocklist for that category.

Categories without a threshold on this policy are hidden — keeps the
chart readable when the matrix touches only a couple of categories.
Adds `IpScoreRepository::scoreDistributionByCategory()` and exposes the
per-category buckets through the existing `/score-distribution` endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 settimana fa
parent
commit
3faebd216d

+ 14 - 1
api/src/Application/Admin/PoliciesController.php

@@ -395,9 +395,11 @@ final class PoliciesController
         }
 
         $distribution = $this->ipScores->scoreDistribution(5.0);
+        $byCategory = $this->ipScores->scoreDistributionByCategory(5.0);
 
-        $thresholds = [];
         $slugByCategoryId = $this->slugByCategoryId();
+
+        $thresholds = [];
         foreach ($policy->thresholds as $catId => $threshold) {
             $slug = $slugByCategoryId[$catId] ?? null;
             $thresholds[] = [
@@ -407,11 +409,22 @@ final class PoliciesController
             ];
         }
 
+        $categories = [];
+        foreach ($byCategory['categories'] as $catId => $catData) {
+            $categories[] = [
+                'category_id' => $catId,
+                'category_slug' => $slugByCategoryId[$catId] ?? null,
+                'buckets' => $catData['buckets'],
+                'max_score' => $catData['max_score'],
+            ];
+        }
+
         return self::json($response, 200, [
             'buckets' => $distribution['buckets'],
             'max_score' => $distribution['max_score'],
             'bucket_size' => $distribution['bucket_size'],
             'thresholds' => $thresholds,
+            'categories' => $categories,
             'policy' => $policy->name,
         ]);
     }

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

@@ -352,6 +352,60 @@ class IpScoreRepository extends RepositoryBase
         ];
     }
 
+    /**
+     * Per-category histogram of (ip, category) score pairs, bucketed in
+     * `$bucketSize`-wide bins. One series per `category_id` that has any
+     * scored IP — empty categories are simply absent. Used by the policy
+     * editor to draw one line per category instead of an aggregate.
+     *
+     * @return array{
+     *     categories: array<int, array{buckets: list<array{start: float, count: int}>, max_score: float}>,
+     *     bucket_size: float,
+     *     overall_max_score: float,
+     * }
+     */
+    public function scoreDistributionByCategory(float $bucketSize): array
+    {
+        if ($bucketSize <= 0) {
+            $bucketSize = 5.0;
+        }
+        $sql = 'SELECT category_id, CAST(score / :bucket AS INTEGER) AS bucket_idx, '
+            . 'COUNT(*) AS cnt, MAX(score) AS max_score '
+            . 'FROM ip_scores WHERE score > 0 '
+            . 'GROUP BY category_id, bucket_idx ORDER BY category_id, bucket_idx';
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection()->fetchAllAssociative($sql, [
+            'bucket' => number_format($bucketSize, 4, '.', ''),
+        ]);
+
+        /** @var array<int, array{buckets: list<array{start: float, count: int}>, max_score: float}> $byCategory */
+        $byCategory = [];
+        $overallMax = 0.0;
+        foreach ($rows as $row) {
+            $catId = (int) $row['category_id'];
+            $start = (float) $row['bucket_idx'] * $bucketSize;
+            $count = (int) $row['cnt'];
+            $max = (float) $row['max_score'];
+
+            if (!isset($byCategory[$catId])) {
+                $byCategory[$catId] = ['buckets' => [], 'max_score' => 0.0];
+            }
+            $byCategory[$catId]['buckets'][] = ['start' => $start, 'count' => $count];
+            if ($max > $byCategory[$catId]['max_score']) {
+                $byCategory[$catId]['max_score'] = $max;
+            }
+            if ($max > $overallMax) {
+                $overallMax = $max;
+            }
+        }
+
+        return [
+            'categories' => $byCategory,
+            'bucket_size' => $bucketSize,
+            'overall_max_score' => $overallMax,
+        ];
+    }
+
     /**
      * Build a parametrised `(:p0, :p1, …)` clause + matching params map for
      * a list of binary IPs. Used by the search's status filter.

+ 106 - 54
ui/resources/views/pages/policies/edit.twig

@@ -100,7 +100,7 @@
             <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.
+            One line per thresholded category, IPs grouped by score in steps of 5; the shaded area to the right of each threshold marks scores high enough to land on this policy's blocklist.
         </p>
         <div class="mt-3 h-64">
             <canvas x-ref="canvas"></canvas>
@@ -211,7 +211,7 @@ window.policyPreview = function (id) {
 };
 
 window.policyScoreDistribution = function (id) {
-    const THRESHOLD_COLORS = [
+    const CATEGORY_COLORS = [
         '#ef4444', '#f97316', '#eab308', '#22c55e',
         '#0ea5e9', '#a855f7', '#ec4899', '#14b8a6',
     ];
@@ -223,6 +223,11 @@ window.policyScoreDistribution = function (id) {
             legendColor: isDark ? '#cbd5e1' : '#334155',
         };
     }
+    function hexToRgba(hex, alpha) {
+        const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
+        if (!m) return hex;
+        return 'rgba(' + parseInt(m[1], 16) + ',' + parseInt(m[2], 16) + ',' + parseInt(m[3], 16) + ',' + alpha + ')';
+    }
     return {
         empty: false,
         chart: null,
@@ -238,64 +243,104 @@ window.policyScoreDistribution = function (id) {
             }
         },
         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;
+            const categories = Array.isArray(data.categories) ? data.categories : [];
+            const overallMaxScore = Number(data.overall_max_score) || 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;
+            // Index thresholds by category so we can look them up while
+            // iterating the per-category histograms. Only categories with
+            // a numeric threshold appear on the chart.
+            const thresholdByCat = {};
+            const thresholdBySlug = {};
             for (const t of thresholds) {
-                if (typeof t.threshold === 'number' && t.threshold > upperX) {
-                    upperX = t.threshold;
-                }
+                if (typeof t.threshold !== 'number') continue;
+                if (t.category_id != null) thresholdByCat[t.category_id] = t.threshold;
+                if (t.category_slug) thresholdBySlug[t.category_slug] = t.threshold;
             }
-            if (buckets.length > 0) {
-                const lastStart = buckets[buckets.length - 1].start;
-                if (lastStart + bucketSize > upperX) {
-                    upperX = lastStart + bucketSize;
-                }
+
+            const categoriesById = {};
+            for (const c of categories) {
+                if (c.category_id != null) categoriesById[c.category_id] = c;
             }
 
-            const points = buckets.map((b) => ({ x: Number(b.start) || 0, y: Number(b.count) || 0 }));
-            this.empty = points.length === 0;
+            // Build the on-chart category list — only those with a threshold.
+            // Iterate over thresholds first so the legend ordering matches the
+            // policy's threshold list rather than database insertion order.
+            const onChart = [];
+            for (const t of thresholds) {
+                if (typeof t.threshold !== 'number') continue;
+                const cat = categoriesById[t.category_id];
+                onChart.push({
+                    category_id: t.category_id,
+                    category_slug: t.category_slug || ('#' + t.category_id),
+                    threshold: t.threshold,
+                    buckets: cat && Array.isArray(cat.buckets) ? cat.buckets : [],
+                });
+            }
+            this.empty = onChart.length === 0;
 
-            const maxCount = points.reduce((m, p) => p.y > m ? p.y : m, 0);
-            const yMax = maxCount > 0 ? maxCount : 1;
+            // Compute axis bounds: extend X to the largest threshold or score
+            // observed across the included categories. Y to the largest count.
+            let upperX = overallMaxScore;
+            let yMax = 0;
+            for (const oc of onChart) {
+                if (oc.threshold > upperX) upperX = oc.threshold;
+                for (const b of oc.buckets) {
+                    const start = Number(b.start) || 0;
+                    const cnt = Number(b.count) || 0;
+                    if (start + bucketSize > upperX) upperX = start + bucketSize;
+                    if (cnt > yMax) yMax = cnt;
+                }
+            }
+            if (yMax <= 0) yMax = 1;
+            if (upperX <= 0) upperX = bucketSize;
+            // Pad by one bucket so the rightmost shaded area has somewhere to
+            // extend into when a threshold sits exactly at the data maximum.
+            upperX = upperX + bucketSize;
 
-            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];
+            const datasets = [];
+            onChart.forEach((oc, i) => {
+                const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
+
+                // Shaded "blocked by this category" region. Drawn first
+                // (higher `order`) so the histogram lines render on top.
                 datasets.push({
-                    label: 'threshold ' + (t.category_slug || ('#' + t.category_id)),
+                    label: oc.category_slug + ' ≥ ' + oc.threshold,
                     data: [
-                        { x: t.threshold, y: 0 },
-                        { x: t.threshold, y: yMax },
+                        { x: oc.threshold, y: yMax },
+                        { x: upperX, y: yMax },
                     ],
+                    borderColor: hexToRgba(color, 0.45),
+                    backgroundColor: hexToRgba(color, 0.10),
+                    borderWidth: 1,
+                    pointRadius: 0,
+                    pointHoverRadius: 0,
+                    fill: 'origin',
+                    tension: 0,
+                    stepped: false,
+                    spanGaps: false,
+                    order: 5 + i,
+                    _isThresholdRegion: true,
+                });
+
+                // Per-category histogram line.
+                const points = oc.buckets.map((b) => ({
+                    x: Number(b.start) || 0,
+                    y: Number(b.count) || 0,
+                }));
+                datasets.push({
+                    label: oc.category_slug,
+                    data: points,
                     borderColor: color,
                     backgroundColor: color,
                     borderWidth: 2,
-                    borderDash: [6, 4],
-                    pointRadius: 0,
-                    pointHoverRadius: 0,
+                    tension: 0.25,
                     fill: false,
-                    tension: 0,
-                    order: 1,
+                    pointRadius: 3,
+                    pointHoverRadius: 5,
+                    pointBackgroundColor: color,
+                    order: i,
                 });
             });
 
@@ -321,22 +366,29 @@ window.policyScoreDistribution = function (id) {
                     plugins: {
                         legend: {
                             position: 'bottom',
-                            labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
+                            labels: {
+                                color: t.legendColor,
+                                boxWidth: 12,
+                                font: { size: 11 },
+                                // Hide the synthetic threshold-region datasets;
+                                // their meaning is conveyed by the shading next
+                                // to the matching category line.
+                                filter: (item, chartData) => {
+                                    const ds = chartData.datasets[item.datasetIndex];
+                                    return !ds || !ds._isThresholdRegion;
+                                },
+                            },
                         },
                         tooltip: {
+                            filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
                             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;
+                                    const start = it.parsed.x;
+                                    return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
                                 },
-                                label: (it) => it.dataset.label === 'IPs'
-                                    ? (it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'))
-                                    : '',
+                                label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
                             },
                         },
                     },
@@ -344,7 +396,7 @@ window.policyScoreDistribution = function (id) {
                         x: {
                             type: 'linear',
                             min: 0,
-                            max: upperX > 0 ? upperX : bucketSize,
+                            max: upperX,
                             title: { display: true, text: 'score', color: t.tickColor },
                             ticks: { color: t.tickColor, stepSize: bucketSize },
                             grid: { color: t.gridColor },