|
@@ -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>
|
|
<button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
|
|
|
</div>
|
|
</div>
|
|
|
<p class="mt-2 text-xs text-slate-400">
|
|
<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>
|
|
</p>
|
|
|
<div class="mt-3 h-64">
|
|
<div class="mt-3 h-64">
|
|
|
<canvas x-ref="canvas"></canvas>
|
|
<canvas x-ref="canvas"></canvas>
|
|
@@ -211,7 +211,7 @@ window.policyPreview = function (id) {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
window.policyScoreDistribution = function (id) {
|
|
window.policyScoreDistribution = function (id) {
|
|
|
- const THRESHOLD_COLORS = [
|
|
|
|
|
|
|
+ const CATEGORY_COLORS = [
|
|
|
'#ef4444', '#f97316', '#eab308', '#22c55e',
|
|
'#ef4444', '#f97316', '#eab308', '#22c55e',
|
|
|
'#0ea5e9', '#a855f7', '#ec4899', '#14b8a6',
|
|
'#0ea5e9', '#a855f7', '#ec4899', '#14b8a6',
|
|
|
];
|
|
];
|
|
@@ -223,6 +223,11 @@ window.policyScoreDistribution = function (id) {
|
|
|
legendColor: isDark ? '#cbd5e1' : '#334155',
|
|
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 {
|
|
return {
|
|
|
empty: false,
|
|
empty: false,
|
|
|
chart: null,
|
|
chart: null,
|
|
@@ -238,64 +243,104 @@ window.policyScoreDistribution = function (id) {
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
render(data) {
|
|
render(data) {
|
|
|
- const buckets = Array.isArray(data.buckets) ? data.buckets : [];
|
|
|
|
|
const bucketSize = Number(data.bucket_size) || 5;
|
|
const bucketSize = Number(data.bucket_size) || 5;
|
|
|
const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
|
|
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) {
|
|
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({
|
|
datasets.push({
|
|
|
- label: 'threshold ' + (t.category_slug || ('#' + t.category_id)),
|
|
|
|
|
|
|
+ label: oc.category_slug + ' ≥ ' + oc.threshold,
|
|
|
data: [
|
|
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,
|
|
borderColor: color,
|
|
|
backgroundColor: color,
|
|
backgroundColor: color,
|
|
|
borderWidth: 2,
|
|
borderWidth: 2,
|
|
|
- borderDash: [6, 4],
|
|
|
|
|
- pointRadius: 0,
|
|
|
|
|
- pointHoverRadius: 0,
|
|
|
|
|
|
|
+ tension: 0.25,
|
|
|
fill: false,
|
|
fill: false,
|
|
|
- tension: 0,
|
|
|
|
|
- order: 1,
|
|
|
|
|
|
|
+ pointRadius: 3,
|
|
|
|
|
+ pointHoverRadius: 5,
|
|
|
|
|
+ pointBackgroundColor: color,
|
|
|
|
|
+ order: i,
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -321,22 +366,29 @@ window.policyScoreDistribution = function (id) {
|
|
|
plugins: {
|
|
plugins: {
|
|
|
legend: {
|
|
legend: {
|
|
|
position: 'bottom',
|
|
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: {
|
|
tooltip: {
|
|
|
|
|
+ filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
|
|
|
callbacks: {
|
|
callbacks: {
|
|
|
title: (items) => {
|
|
title: (items) => {
|
|
|
if (!items.length) return '';
|
|
if (!items.length) return '';
|
|
|
const it = items[0];
|
|
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: {
|
|
x: {
|
|
|
type: 'linear',
|
|
type: 'linear',
|
|
|
min: 0,
|
|
min: 0,
|
|
|
- max: upperX > 0 ? upperX : bucketSize,
|
|
|
|
|
|
|
+ max: upperX,
|
|
|
title: { display: true, text: 'score', color: t.tickColor },
|
|
title: { display: true, text: 'score', color: t.tickColor },
|
|
|
ticks: { color: t.tickColor, stepSize: bucketSize },
|
|
ticks: { color: t.tickColor, stepSize: bucketSize },
|
|
|
grid: { color: t.gridColor },
|
|
grid: { color: t.gridColor },
|