|
@@ -1,4 +1,8 @@
|
|
|
-import Alpine from 'alpinejs';
|
|
|
|
|
|
|
+// CSP-friendly Alpine build: forbids `Function()` / arbitrary inline
|
|
|
|
|
+// expressions, so every component lives here as `Alpine.data(...)` and
|
|
|
|
|
+// templates reference component/method names only. Lets the UI ship a
|
|
|
|
|
+// CSP without `'unsafe-eval'` or `'unsafe-inline'` (SEC_REVIEW F24).
|
|
|
|
|
+import Alpine from '@alpinejs/csp';
|
|
|
import 'htmx.org';
|
|
import 'htmx.org';
|
|
|
import {
|
|
import {
|
|
|
Chart,
|
|
Chart,
|
|
@@ -17,8 +21,6 @@ import {
|
|
|
Filler,
|
|
Filler,
|
|
|
} from 'chart.js';
|
|
} from 'chart.js';
|
|
|
|
|
|
|
|
-// Dark mode toggle. Layout's inline <head> script handles the FOUC-free
|
|
|
|
|
-// initial paint; this just wires the toggle button.
|
|
|
|
|
function applyTheme(theme) {
|
|
function applyTheme(theme) {
|
|
|
if (theme === 'dark') {
|
|
if (theme === 'dark') {
|
|
|
document.documentElement.classList.add('dark');
|
|
document.documentElement.classList.add('dark');
|
|
@@ -39,7 +41,6 @@ document.addEventListener('click', (e) => {
|
|
|
applyTheme(next);
|
|
applyTheme(next);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-// htmx: send the per-session CSRF token on every state-changing request.
|
|
|
|
|
document.body.addEventListener('htmx:configRequest', (e) => {
|
|
document.body.addEventListener('htmx:configRequest', (e) => {
|
|
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
|
if (meta && meta.content) {
|
|
if (meta && meta.content) {
|
|
@@ -47,9 +48,6 @@ document.body.addEventListener('htmx:configRequest', (e) => {
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-// 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(
|
|
Chart.register(
|
|
|
BarController,
|
|
BarController,
|
|
|
BarElement,
|
|
BarElement,
|
|
@@ -66,23 +64,17 @@ Chart.register(
|
|
|
Filler,
|
|
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;
|
|
|
|
|
-
|
|
|
|
|
-// Faded "glass" palette: Tailwind -400 tones for a powdery, translucent
|
|
|
|
|
-// feel. Emerald-400 (#34d399) matches the mid-stop of the logo's gradient.
|
|
|
|
|
const PIE_COLORS = [
|
|
const PIE_COLORS = [
|
|
|
- '#34d399', // emerald-400 (matches logo's mid-stop)
|
|
|
|
|
- '#22d3ee', // cyan-400
|
|
|
|
|
- '#818cf8', // indigo-400
|
|
|
|
|
- '#a78bfa', // violet-400
|
|
|
|
|
- '#f472b6', // pink-400
|
|
|
|
|
- '#fb7185', // rose-400
|
|
|
|
|
- '#fbbf24', // amber-400
|
|
|
|
|
- '#a3e635', // lime-400
|
|
|
|
|
- '#2dd4bf', // teal-400
|
|
|
|
|
- '#60a5fa', // blue-400
|
|
|
|
|
|
|
+ '#34d399',
|
|
|
|
|
+ '#22d3ee',
|
|
|
|
|
+ '#818cf8',
|
|
|
|
|
+ '#a78bfa',
|
|
|
|
|
+ '#f472b6',
|
|
|
|
|
+ '#fb7185',
|
|
|
|
|
+ '#fbbf24',
|
|
|
|
|
+ '#a3e635',
|
|
|
|
|
+ '#2dd4bf',
|
|
|
|
|
+ '#60a5fa',
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
function chartTheme() {
|
|
function chartTheme() {
|
|
@@ -124,7 +116,7 @@ function renderReportsChart() {
|
|
|
datasets: [{
|
|
datasets: [{
|
|
|
label: 'reports',
|
|
label: 'reports',
|
|
|
data,
|
|
data,
|
|
|
- backgroundColor: 'rgba(52, 211, 153, 0.7)', // emerald-400 @ 70% — frosted bar
|
|
|
|
|
|
|
+ backgroundColor: 'rgba(52, 211, 153, 0.7)',
|
|
|
borderColor: 'rgba(52, 211, 153, 0.9)',
|
|
borderColor: 'rgba(52, 211, 153, 0.9)',
|
|
|
borderWidth: 1,
|
|
borderWidth: 1,
|
|
|
borderRadius: 4,
|
|
borderRadius: 4,
|
|
@@ -161,7 +153,7 @@ function renderPieChart(canvasId) {
|
|
|
datasets: [{
|
|
datasets: [{
|
|
|
data,
|
|
data,
|
|
|
backgroundColor: colors,
|
|
backgroundColor: colors,
|
|
|
- borderColor: 'rgba(255,255,255,0.25)', // crisper glass-segment seam
|
|
|
|
|
|
|
+ borderColor: 'rgba(255,255,255,0.25)',
|
|
|
borderWidth: 1.5,
|
|
borderWidth: 1.5,
|
|
|
}],
|
|
}],
|
|
|
},
|
|
},
|
|
@@ -200,8 +192,6 @@ function renderBlockedIpsChart() {
|
|
|
const series = Array.isArray(payload.series) ? payload.series : [];
|
|
const series = Array.isArray(payload.series) ? payload.series : [];
|
|
|
if (days.length === 0 || series.length === 0) return;
|
|
if (days.length === 0 || series.length === 0) return;
|
|
|
|
|
|
|
|
- // Day-of-month for axis ticks; the ISO date is preserved in the
|
|
|
|
|
- // tooltip title via the closure on `days`.
|
|
|
|
|
const labels = days.map((iso) => {
|
|
const labels = days.map((iso) => {
|
|
|
const d = new Date(iso + 'T00:00:00Z');
|
|
const d = new Date(iso + 'T00:00:00Z');
|
|
|
return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
@@ -211,8 +201,8 @@ function renderBlockedIpsChart() {
|
|
|
return {
|
|
return {
|
|
|
label: row.category || '—',
|
|
label: row.category || '—',
|
|
|
data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
|
|
data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
|
|
|
- backgroundColor: hexToRgba(colour, 0.7), // frosted fill
|
|
|
|
|
- borderColor: colour, // crisp glass edge
|
|
|
|
|
|
|
+ backgroundColor: hexToRgba(colour, 0.7),
|
|
|
|
|
+ borderColor: colour,
|
|
|
borderWidth: 1,
|
|
borderWidth: 1,
|
|
|
};
|
|
};
|
|
|
});
|
|
});
|
|
@@ -255,14 +245,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
renderBlockedIpsChart();
|
|
renderBlockedIpsChart();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-// Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
|
|
|
|
|
-// datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
|
|
|
|
|
-// string as a no-JS fallback. This pass replaces it with the user's
|
|
|
|
|
-// browser locale formatting, with an optional configured fallback (set
|
|
|
|
|
-// via UI_LOCALE on the html data attribute) appended so browser locale
|
|
|
|
|
-// wins but a deployment can still ensure something sensible if the
|
|
|
|
|
-// browser's preference isn't supported. `data-irdb-dt-format="date"`
|
|
|
|
|
-// switches to date-only output.
|
|
|
|
|
function getDateLocales() {
|
|
function getDateLocales() {
|
|
|
const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
|
|
const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
|
|
|
const locales = [];
|
|
const locales = [];
|
|
@@ -312,12 +294,40 @@ function formatTimes(root) {
|
|
|
document.addEventListener('DOMContentLoaded', () => formatTimes(document));
|
|
document.addEventListener('DOMContentLoaded', () => formatTimes(document));
|
|
|
document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
|
|
document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
|
|
|
|
|
|
|
|
-// Sortable tables. Tables marked with `data-sortable-table="<id>"` get
|
|
|
|
|
-// click-to-sort headers. Sort state lives in sessionStorage under
|
|
|
|
|
-// `irdb-sort:<id>` and is wiped on the login page so logging out
|
|
|
|
|
-// forgets it. The comparator is selected by the th's `data-sort-type`
|
|
|
|
|
-// (`string` | `number` | `date`); cells may override the displayed
|
|
|
|
|
-// text via `data-sort-value`.
|
|
|
|
|
|
|
+// Audit page filter form: ISO ⇄ datetime-local round-tripping. Used to
|
|
|
|
|
+// live as an inline <script> in audit/index.twig; moved here so the page
|
|
|
|
|
+// has zero inline JS.
|
|
|
|
|
+function initAuditFilterForm() {
|
|
|
|
|
+ const form = document.getElementById('audit-filter-form');
|
|
|
|
|
+ if (!form) return;
|
|
|
|
|
+ function pad(n) { return String(n).padStart(2, '0'); }
|
|
|
|
|
+ function isoToLocalInput(iso) {
|
|
|
|
|
+ if (!iso) return '';
|
|
|
|
|
+ const d = new Date(iso);
|
|
|
|
|
+ if (isNaN(d.getTime())) return '';
|
|
|
|
|
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
|
|
|
+ + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
|
|
|
|
+ }
|
|
|
|
|
+ function localInputToIso(value) {
|
|
|
|
|
+ if (!value) return '';
|
|
|
|
|
+ const d = new Date(value);
|
|
|
|
|
+ if (isNaN(d.getTime())) return '';
|
|
|
|
|
+ return d.toISOString();
|
|
|
|
|
+ }
|
|
|
|
|
+ form.querySelectorAll('input[data-irdb-iso-filter]').forEach((el) => {
|
|
|
|
|
+ const iso = el.getAttribute('data-irdb-iso-filter');
|
|
|
|
|
+ if (iso) el.value = isoToLocalInput(iso);
|
|
|
|
|
+ });
|
|
|
|
|
+ form.addEventListener('submit', () => {
|
|
|
|
|
+ form.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
|
|
|
|
|
+ if (el.value) el.value = localInputToIso(el.value);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+document.addEventListener('DOMContentLoaded', initAuditFilterForm);
|
|
|
|
|
+
|
|
|
|
|
+// Sortable tables. Templates mark a table with `data-sortable-table="<id>"`
|
|
|
|
|
+// and headers with `data-sort-key`; sort state lives in sessionStorage.
|
|
|
const SORT_KEY_PREFIX = 'irdb-sort:';
|
|
const SORT_KEY_PREFIX = 'irdb-sort:';
|
|
|
|
|
|
|
|
function readSortState(tableId) {
|
|
function readSortState(tableId) {
|
|
@@ -357,7 +367,6 @@ function cellSortValue(cell, type) {
|
|
|
if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
|
|
if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
|
|
|
const explicit = cell.getAttribute('data-sort-value');
|
|
const explicit = cell.getAttribute('data-sort-value');
|
|
|
if (explicit !== null) return explicit;
|
|
if (explicit !== null) return explicit;
|
|
|
- // <time datetime="..."> is the canonical date carrier.
|
|
|
|
|
const timeEl = cell.querySelector('time[datetime]');
|
|
const timeEl = cell.querySelector('time[datetime]');
|
|
|
if (timeEl) return timeEl.getAttribute('datetime') || '';
|
|
if (timeEl) return timeEl.getAttribute('datetime') || '';
|
|
|
return (cell.textContent || '').trim();
|
|
return (cell.textContent || '').trim();
|
|
@@ -370,7 +379,7 @@ function compareValues(a, b, type) {
|
|
|
const aNaN = Number.isNaN(na);
|
|
const aNaN = Number.isNaN(na);
|
|
|
const bNaN = Number.isNaN(nb);
|
|
const bNaN = Number.isNaN(nb);
|
|
|
if (aNaN && bNaN) return 0;
|
|
if (aNaN && bNaN) return 0;
|
|
|
- if (aNaN) return 1; // empty/— goes last on asc
|
|
|
|
|
|
|
+ if (aNaN) return 1;
|
|
|
if (bNaN) return -1;
|
|
if (bNaN) return -1;
|
|
|
return na - nb;
|
|
return na - nb;
|
|
|
}
|
|
}
|
|
@@ -384,13 +393,10 @@ function compareValues(a, b, type) {
|
|
|
if (bNaN) return -1;
|
|
if (bNaN) return -1;
|
|
|
return ta - tb;
|
|
return ta - tb;
|
|
|
}
|
|
}
|
|
|
- // string
|
|
|
|
|
return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
|
|
return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function findSortableThs(table) {
|
|
function findSortableThs(table) {
|
|
|
- // Only <th> in the *first* thead row (avoid scanning nested tables
|
|
|
|
|
- // — the score-chart has its own SVG layout, not a sortable table).
|
|
|
|
|
const head = table.tHead;
|
|
const head = table.tHead;
|
|
|
if (!head || !head.rows.length) return [];
|
|
if (!head || !head.rows.length) return [];
|
|
|
return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
|
|
return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
|
|
@@ -406,10 +412,6 @@ function applySort(table, key, dir) {
|
|
|
const tbody = table.tBodies[0];
|
|
const tbody = table.tBodies[0];
|
|
|
if (!tbody) return;
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
- // Treat each contiguous run of rows with the same `data-sort-row-group`
|
|
|
|
|
- // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
|
|
|
|
|
- // expandable rows (e.g. the audit log payload toggle) stay glued to
|
|
|
|
|
- // their parent. By default, every row sorts independently.
|
|
|
|
|
const allRows = Array.from(tbody.rows);
|
|
const allRows = Array.from(tbody.rows);
|
|
|
const groups = [];
|
|
const groups = [];
|
|
|
let current = null;
|
|
let current = null;
|
|
@@ -433,7 +435,6 @@ function applySort(table, key, dir) {
|
|
|
groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
|
|
groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
|
|
|
tbody.appendChild(frag);
|
|
tbody.appendChild(frag);
|
|
|
|
|
|
|
|
- // Update indicators.
|
|
|
|
|
ths.forEach((t) => {
|
|
ths.forEach((t) => {
|
|
|
const ind = t.querySelector('.sort-indicator');
|
|
const ind = t.querySelector('.sort-indicator');
|
|
|
const isActive = t === th;
|
|
const isActive = t === th;
|
|
@@ -479,8 +480,6 @@ function initSortableTables(root) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
- // The login page is the canonical "logged out" landing spot —
|
|
|
|
|
- // wipe any sort state left over from the previous session.
|
|
|
|
|
if (window.location && window.location.pathname === '/login') {
|
|
if (window.location && window.location.pathname === '/login') {
|
|
|
clearAllSortState();
|
|
clearAllSortState();
|
|
|
}
|
|
}
|
|
@@ -488,5 +487,521 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
});
|
|
});
|
|
|
document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
|
|
document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
|
|
|
|
|
|
|
|
-window.Alpine = Alpine;
|
|
|
|
|
|
|
+// ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+// Alpine.js components — registered up-front because the CSP build only
|
|
|
|
|
+// resolves `x-data="name"` against names registered via Alpine.data().
|
|
|
|
|
+// ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+// Generic open/close toggle (popovers, modals, side-panes). Optional
|
|
|
|
|
+// `data-initial-open="1"` on the root element starts in the open state.
|
|
|
|
|
+Alpine.data('toggle', () => ({
|
|
|
|
|
+ open: false,
|
|
|
|
|
+ init() {
|
|
|
|
|
+ if (this.$el.dataset.initialOpen === '1') {
|
|
|
|
|
+ this.open = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ show() { this.open = true; },
|
|
|
|
|
+ hide() { this.open = false; },
|
|
|
|
|
+ flip() { this.open = !this.open; },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Single-row expander — multiple rows share one component, only one row
|
|
|
|
|
+// expanded at a time. Each expander button passes its own row id.
|
|
|
|
|
+Alpine.data('rowExpander', () => ({
|
|
|
|
|
+ open: null,
|
|
|
|
|
+ toggle(id) {
|
|
|
|
|
+ this.open = (this.open === id ? null : id);
|
|
|
|
|
+ },
|
|
|
|
|
+ isOpen(id) {
|
|
|
|
|
+ return this.open === id;
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Form section switcher driven by a <select> bound via `x-model="kind"`.
|
|
|
|
|
+// `data-initial-kind="ip"` chooses the starting branch.
|
|
|
|
|
+Alpine.data('kindSwitcher', () => ({
|
|
|
|
|
+ kind: '',
|
|
|
|
|
+ init() {
|
|
|
|
|
+ this.kind = this.$el.dataset.initialKind || '';
|
|
|
|
|
+ },
|
|
|
|
|
+ isKind(value) {
|
|
|
|
|
+ return this.kind === value;
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Submit-once guard: disables the submit button on form submission.
|
|
|
|
|
+Alpine.data('submitGuard', () => ({
|
|
|
|
|
+ submitting: false,
|
|
|
|
|
+ onSubmit() { this.submitting = true; },
|
|
|
|
|
+ get notSubmitting() { return !this.submitting; },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// "Type the magic word to confirm" dangerous-action component used by
|
|
|
|
|
+// the seed-demo and purge buttons in the settings page.
|
|
|
|
|
+Alpine.data('dangerousAction', () => ({
|
|
|
|
|
+ open: false,
|
|
|
|
|
+ confirm: '',
|
|
|
|
|
+ submitting: false,
|
|
|
|
|
+ expectedConfirm: '',
|
|
|
|
|
+ init() {
|
|
|
|
|
+ this.expectedConfirm = this.$el.dataset.expectedConfirm || '';
|
|
|
|
|
+ },
|
|
|
|
|
+ show() { this.open = true; },
|
|
|
|
|
+ hide() {
|
|
|
|
|
+ this.open = false;
|
|
|
|
|
+ this.confirm = '';
|
|
|
|
|
+ },
|
|
|
|
|
+ onSubmit() { this.submitting = true; },
|
|
|
|
|
+ get blocked() {
|
|
|
|
|
+ return this.confirm !== this.expectedConfirm || this.submitting;
|
|
|
|
|
+ },
|
|
|
|
|
+ get notSubmitting() { return !this.submitting; },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Login page: initially-open if OIDC is disabled, hidden behind a
|
|
|
|
|
+// "Use local sign-in" toggle otherwise. The PHP page sets
|
|
|
|
|
+// `data-initial-open="1"` when there is no OIDC.
|
|
|
|
|
+Alpine.data('loginForm', () => ({
|
|
|
|
|
+ open: false,
|
|
|
|
|
+ init() {
|
|
|
|
|
+ if (this.$el.dataset.initialOpen === '1') {
|
|
|
|
|
+ this.open = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.$watch('open', (v) => {
|
|
|
|
|
+ if (v) {
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ if (this.$refs.usernameInput) {
|
|
|
|
|
+ this.$refs.usernameInput.focus();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ flip() { this.open = !this.open; },
|
|
|
|
|
+ get toggleLabel() {
|
|
|
|
|
+ return this.open ? 'Hide local sign-in' : 'Use local sign-in';
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Categories edit page: live preview of the decay function.
|
|
|
|
|
+Alpine.data('decayPreview', () => ({
|
|
|
|
|
+ fn: 'exponential',
|
|
|
|
|
+ param: 14,
|
|
|
|
|
+ init() {
|
|
|
|
|
+ const ds = this.$el.dataset;
|
|
|
|
|
+ this.fn = ds.decayFn === 'linear' ? 'linear' : 'exponential';
|
|
|
|
|
+ this.param = parseFloat(ds.decayParam) || 14;
|
|
|
|
|
+ },
|
|
|
|
|
+ decay(ageDays) {
|
|
|
|
|
+ const p = Math.max(0.1, Number(this.param) || 0.1);
|
|
|
|
|
+ if (this.fn === 'linear') {
|
|
|
|
|
+ return Math.max(0, 1 - ageDays / p);
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.pow(0.5, ageDays / p);
|
|
|
|
|
+ },
|
|
|
|
|
+ path() {
|
|
|
|
|
+ const x0 = 40, y0 = 10, w = 600, h = 200;
|
|
|
|
|
+ const points = [];
|
|
|
|
|
+ for (let i = 0; i <= 60; i++) {
|
|
|
|
|
+ const x = x0 + (i / 60) * w;
|
|
|
|
|
+ const y = y0 + (1 - this.decay(i)) * h;
|
|
|
|
|
+ points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return points.join(' ');
|
|
|
|
|
+ },
|
|
|
|
|
+ get paramLabel() {
|
|
|
|
|
+ return this.fn === 'linear' ? 'Days to zero' : 'Half-life days';
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Policies edit page: preview of the rendered blocklist for a policy.
|
|
|
|
|
+Alpine.data('policyPreview', () => ({
|
|
|
|
|
+ loading: true,
|
|
|
|
|
+ count: 0,
|
|
|
|
|
+ sample: [],
|
|
|
|
|
+ policyId: 0,
|
|
|
|
|
+ init() {
|
|
|
|
|
+ this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
|
|
|
|
|
+ this.load();
|
|
|
|
|
+ },
|
|
|
|
|
+ async load() {
|
|
|
|
|
+ this.loading = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/app/policies/' + this.policyId + '/preview-proxy', { credentials: 'same-origin' });
|
|
|
|
|
+ if (!res.ok) throw new Error('preview ' + res.status);
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ this.count = data.count || 0;
|
|
|
|
|
+ const items = Array.isArray(data.sample) ? data.sample : [];
|
|
|
|
|
+ this.sample = items.map((raw, idx) => this.shapeEntry(raw, idx));
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.count = 0;
|
|
|
|
|
+ this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.loading = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ get notLoading() { return !this.loading; },
|
|
|
|
|
+ shapeEntry(raw, idx) {
|
|
|
|
|
+ if (typeof raw === 'string') {
|
|
|
|
|
+ return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
|
|
|
|
|
+ }
|
|
|
|
|
+ const ip = raw.ip_or_cidr || '';
|
|
|
|
|
+ let expiry = '';
|
|
|
|
|
+ let tooltip = '';
|
|
|
|
|
+ if (raw.expires_at) {
|
|
|
|
|
+ const formatted = this.formatExpiry(raw.expires_at);
|
|
|
|
|
+ expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
|
|
|
|
|
+ tooltip = (raw.expires_estimated
|
|
|
|
|
+ ? 'Estimated falls-off date (assumes no further reports). ISO: '
|
|
|
|
|
+ : 'Configured manual block expiry. ISO: ') + raw.expires_at;
|
|
|
|
|
+ } else if (raw.reason === 'manual') {
|
|
|
|
|
+ expiry = 'never';
|
|
|
|
|
+ tooltip = 'Manual block has no configured expiry';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ expiry = '—';
|
|
|
|
|
+ tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
|
|
|
|
|
+ }
|
|
|
|
|
+ return { key: 'r' + idx + ':' + ip, label: ip, expiry, tooltip };
|
|
|
|
|
+ },
|
|
|
|
|
+ formatExpiry(iso) {
|
|
|
|
|
+ if (!iso) return '';
|
|
|
|
|
+ const d = new Date(iso);
|
|
|
|
|
+ if (isNaN(d.getTime())) return iso;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new Intl.DateTimeFormat(getDateLocales(), {
|
|
|
|
|
+ year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
|
|
+ hour: '2-digit', minute: '2-digit',
|
|
|
|
|
+ }).format(d);
|
|
|
|
|
+ } catch (_) { return iso; }
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Policies edit page: per-category histogram with shaded "blocked"
|
|
|
|
|
+// regions to the right of each threshold.
|
|
|
|
|
+Alpine.data('policyScoreDistribution', () => ({
|
|
|
|
|
+ empty: false,
|
|
|
|
|
+ chart: null,
|
|
|
|
|
+ policyId: 0,
|
|
|
|
|
+ init() {
|
|
|
|
|
+ this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
|
|
|
|
|
+ this.load();
|
|
|
|
|
+ },
|
|
|
|
|
+ async load() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/app/policies/' + this.policyId + '/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 CATEGORY_COLORS = [
|
|
|
|
|
+ '#f87171', '#fbbf24', '#facc15', '#4ade80',
|
|
|
|
|
+ '#38bdf8', '#a78bfa', '#f472b6', '#2dd4bf',
|
|
|
|
|
+ ];
|
|
|
|
|
+ const bucketSize = Number(data.bucket_size) || 5;
|
|
|
|
|
+ const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
|
|
|
|
|
+ const categories = Array.isArray(data.categories) ? data.categories : [];
|
|
|
|
|
+ const overallMaxScore = Number(data.overall_max_score) || Number(data.max_score) || 0;
|
|
|
|
|
+
|
|
|
|
|
+ const thresholdByCat = {};
|
|
|
|
|
+ const thresholdBySlug = {};
|
|
|
|
|
+ for (const t of thresholds) {
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const categoriesById = {};
|
|
|
|
|
+ for (const c of categories) {
|
|
|
|
|
+ if (c.category_id != null) categoriesById[c.category_id] = c;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ upperX = upperX + bucketSize;
|
|
|
|
|
+
|
|
|
|
|
+ const datasets = [];
|
|
|
|
|
+ onChart.forEach((oc, i) => {
|
|
|
|
|
+ const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
|
|
|
|
|
+ datasets.push({
|
|
|
|
|
+ label: oc.category_slug + ' ≥ ' + oc.threshold,
|
|
|
|
|
+ data: [
|
|
|
|
|
+ { 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,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ 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,
|
|
|
|
|
+ tension: 0.25,
|
|
|
|
|
+ fill: false,
|
|
|
|
|
+ pointRadius: 3,
|
|
|
|
|
+ pointHoverRadius: 5,
|
|
|
|
|
+ pointBackgroundColor: color,
|
|
|
|
|
+ order: i,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const t = chartTheme();
|
|
|
|
|
+ const canvas = this.$refs.canvas;
|
|
|
|
|
+ if (!canvas) return;
|
|
|
|
|
+ if (this.chart) {
|
|
|
|
|
+ this.chart.destroy();
|
|
|
|
|
+ }
|
|
|
|
|
+ this.chart = new Chart(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 },
|
|
|
|
|
+ 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];
|
|
|
|
|
+ const start = it.parsed.x;
|
|
|
|
|
+ return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
|
|
|
|
|
+ },
|
|
|
|
|
+ label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ scales: {
|
|
|
|
|
+ x: {
|
|
|
|
|
+ type: 'linear',
|
|
|
|
|
+ min: 0,
|
|
|
|
|
+ max: upperX,
|
|
|
|
|
+ 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 },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// IP detail page: rolling decay-aware score plotted across week / month
|
|
|
|
|
+// / all / future ranges. Reads its inputs from data attributes set by
|
|
|
|
|
+// the template.
|
|
|
|
|
+Alpine.data('scoreOverTime', () => ({
|
|
|
|
|
+ range: 'month',
|
|
|
|
|
+ ranges: [
|
|
|
|
|
+ { id: 'week', label: 'Week' },
|
|
|
|
|
+ { id: 'month', label: 'Month' },
|
|
|
|
|
+ { id: 'all', label: 'All' },
|
|
|
|
|
+ { id: 'future', label: '+30d' },
|
|
|
|
|
+ ],
|
|
|
|
|
+ _now: 0,
|
|
|
|
|
+ _reports: [],
|
|
|
|
|
+ _categories: {},
|
|
|
|
|
+ init() {
|
|
|
|
|
+ const ds = this.$el.dataset;
|
|
|
|
|
+ let payload = { reports: [], categories: [], now: '' };
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (ds.scoreChart) {
|
|
|
|
|
+ payload = JSON.parse(ds.scoreChart);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) { /* fall through with defaults */ }
|
|
|
|
|
+ const DAY_MS = 24 * 3600 * 1000;
|
|
|
|
|
+ this._now = new Date(payload.now || Date.now()).getTime();
|
|
|
|
|
+ this._reports = (payload.reports || [])
|
|
|
|
|
+ .map(r => ({
|
|
|
|
|
+ t: new Date(r.at).getTime(),
|
|
|
|
|
+ category: r.category,
|
|
|
|
|
+ weight: Number(r.weight) || 1,
|
|
|
|
|
+ }))
|
|
|
|
|
+ .filter(r => Number.isFinite(r.t));
|
|
|
|
|
+ this._categories = (payload.categories || []).reduce((acc, c) => {
|
|
|
|
|
+ if (c && c.slug) {
|
|
|
|
|
+ acc[c.slug] = {
|
|
|
|
|
+ fn: c.decay_function === 'linear' ? 'linear' : 'exponential',
|
|
|
|
|
+ param: Math.max(0.1, Number(c.decay_param) || 14),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return acc;
|
|
|
|
|
+ }, {});
|
|
|
|
|
+ this.DAY_MS = DAY_MS;
|
|
|
|
|
+ },
|
|
|
|
|
+ setRange(id) { this.range = id; },
|
|
|
|
|
+ isRange(id) { return this.range === id; },
|
|
|
|
|
+ isFuture() { return this.range === 'future'; },
|
|
|
|
|
+ classForRange(id) {
|
|
|
|
|
+ return this.range === id
|
|
|
|
|
+ ? 'bg-indigo-600 text-white border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700'
|
|
|
|
|
+ : 'bg-white text-slate-600 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700';
|
|
|
|
|
+ },
|
|
|
|
|
+ decayFor(report, t) {
|
|
|
|
|
+ const DEFAULT = { fn: 'exponential', param: 14 };
|
|
|
|
|
+ const cat = this._categories[report.category] || DEFAULT;
|
|
|
|
|
+ const ageDays = (t - report.t) / this.DAY_MS;
|
|
|
|
|
+ if (ageDays < 0) return 0;
|
|
|
|
|
+ if (cat.fn === 'linear') return Math.max(0, 1 - ageDays / cat.param);
|
|
|
|
|
+ return Math.pow(0.5, ageDays / cat.param);
|
|
|
|
|
+ },
|
|
|
|
|
+ totalScoreAt(t) {
|
|
|
|
|
+ let total = 0;
|
|
|
|
|
+ for (const r of this._reports) total += r.weight * this.decayFor(r, t);
|
|
|
|
|
+ return total;
|
|
|
|
|
+ },
|
|
|
|
|
+ fmtDate(ts) {
|
|
|
|
|
+ const d = new Date(ts);
|
|
|
|
|
+ if (isNaN(d.getTime())) return '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return d.toISOString().slice(0, 10);
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ get hasReports() { return this._reports.length > 0; },
|
|
|
|
|
+ get hasNoReports() { return this._reports.length === 0; },
|
|
|
|
|
+ get bounds() {
|
|
|
|
|
+ const NOW = this._now;
|
|
|
|
|
+ const DAY = this.DAY_MS;
|
|
|
|
|
+ switch (this.range) {
|
|
|
|
|
+ case 'week': return { start: NOW - 7 * DAY, end: NOW };
|
|
|
|
|
+ case 'future': return { start: NOW, end: NOW + 30 * DAY };
|
|
|
|
|
+ case 'all': {
|
|
|
|
|
+ const earliest = this._reports.length
|
|
|
|
|
+ ? Math.min.apply(null, this._reports.map(r => r.t))
|
|
|
|
|
+ : NOW - 30 * DAY;
|
|
|
|
|
+ return { start: Math.min(earliest, NOW - DAY), end: NOW };
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'month':
|
|
|
|
|
+ default: return { start: NOW - 30 * DAY, end: NOW };
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ get points() {
|
|
|
|
|
+ const { start, end } = this.bounds;
|
|
|
|
|
+ const N = 120;
|
|
|
|
|
+ const span = end - start;
|
|
|
|
|
+ const out = [];
|
|
|
|
|
+ for (let i = 0; i <= N; i++) {
|
|
|
|
|
+ const t = start + (span * i) / N;
|
|
|
|
|
+ out.push({ t, v: this.totalScoreAt(t) });
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ },
|
|
|
|
|
+ get maxScoreDisplay() {
|
|
|
|
|
+ let max = 0;
|
|
|
|
|
+ for (const p of this.points) if (p.v > max) max = p.v;
|
|
|
|
|
+ return max;
|
|
|
|
|
+ },
|
|
|
|
|
+ get maxScoreLabel() {
|
|
|
|
|
+ return this.maxScoreDisplay.toFixed(2);
|
|
|
|
|
+ },
|
|
|
|
|
+ path() {
|
|
|
|
|
+ const pts = this.points;
|
|
|
|
|
+ if (pts.length === 0) return '';
|
|
|
|
|
+ const x0 = 50, y0 = 20, w = 590, h = 180;
|
|
|
|
|
+ const max = Math.max(this.maxScoreDisplay, 1e-6);
|
|
|
|
|
+ const out = [];
|
|
|
|
|
+ for (let i = 0; i < pts.length; i++) {
|
|
|
|
|
+ const x = x0 + (i / (pts.length - 1)) * w;
|
|
|
|
|
+ const y = y0 + (1 - pts[i].v / max) * h;
|
|
|
|
|
+ out.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return out.join(' ');
|
|
|
|
|
+ },
|
|
|
|
|
+ yLabel(frac) {
|
|
|
|
|
+ return (this.maxScoreDisplay * frac).toFixed(2);
|
|
|
|
|
+ },
|
|
|
|
|
+ xLabel(frac) {
|
|
|
|
|
+ const { start, end } = this.bounds;
|
|
|
|
|
+ return this.fmtDate(start + (end - start) * frac);
|
|
|
|
|
+ },
|
|
|
|
|
+ xAxisCaption() {
|
|
|
|
|
+ switch (this.range) {
|
|
|
|
|
+ case 'week': return 'last 7 days';
|
|
|
|
|
+ case 'month': return 'last 30 days';
|
|
|
|
|
+ case 'all': return 'all reported activity';
|
|
|
|
|
+ case 'future': return 'next 30 days (forecast)';
|
|
|
|
|
+ default: return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ rangeLabel() {
|
|
|
|
|
+ const opt = this.ranges.find(r => r.id === this.range);
|
|
|
|
|
+ return opt ? opt.label : '';
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+// Tokens index page: copy-button for the just-issued raw token.
|
|
|
|
|
+Alpine.data('rawTokenCopy', () => ({
|
|
|
|
|
+ open: true,
|
|
|
|
|
+ hide() { this.open = false; },
|
|
|
|
|
+ copy() {
|
|
|
|
|
+ const el = document.getElementById('raw-token');
|
|
|
|
|
+ if (el) navigator.clipboard.writeText(el.value);
|
|
|
|
|
+ },
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
Alpine.start();
|
|
Alpine.start();
|