|
@@ -93,6 +93,21 @@
|
|
|
{% endif %}
|
|
{% endif %}
|
|
|
</form>
|
|
</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"
|
|
<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()">
|
|
x-data="policyPreview({{ policy.id }})" x-init="load()">
|
|
|
<div class="flex items-center justify-between">
|
|
<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>
|
|
</script>
|
|
|
{% endblock %}
|
|
{% endblock %}
|