| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007 |
- // 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 {
- Chart,
- BarController,
- BarElement,
- LineController,
- LineElement,
- PointElement,
- PieController,
- ArcElement,
- CategoryScale,
- LinearScale,
- Tooltip,
- Title,
- Legend,
- Filler,
- } from 'chart.js';
- function applyTheme(theme) {
- if (theme === 'dark') {
- document.documentElement.classList.add('dark');
- } else {
- document.documentElement.classList.remove('dark');
- }
- try {
- localStorage.setItem('irdb-theme', theme);
- } catch (e) {
- /* ignore */
- }
- }
- document.addEventListener('click', (e) => {
- const target = e.target.closest('[data-theme-toggle]');
- if (!target) return;
- const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
- applyTheme(next);
- });
- document.body.addEventListener('htmx:configRequest', (e) => {
- const meta = document.querySelector('meta[name="csrf-token"]');
- if (meta && meta.content) {
- e.detail.headers['X-CSRF-Token'] = meta.content;
- }
- });
- Chart.register(
- BarController,
- BarElement,
- LineController,
- LineElement,
- PointElement,
- PieController,
- ArcElement,
- CategoryScale,
- LinearScale,
- Tooltip,
- Title,
- Legend,
- Filler,
- );
- const PIE_COLORS = [
- '#34d399',
- '#22d3ee',
- '#818cf8',
- '#a78bfa',
- '#f472b6',
- '#fb7185',
- '#fbbf24',
- '#a3e635',
- '#2dd4bf',
- '#60a5fa',
- ];
- 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',
- };
- }
- 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})`;
- }
- function parseSeries(canvas, attr) {
- try {
- return JSON.parse(canvas.dataset[attr] || '[]');
- } catch (e) {
- return [];
- }
- }
- function renderReportsChart() {
- const canvas = document.getElementById('reports-chart');
- if (!canvas) return;
- const buckets = parseSeries(canvas, 'buckets');
- const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
- const data = buckets.map((b) => b.count || 0);
- const t = chartTheme();
- new Chart(canvas, {
- type: 'bar',
- data: {
- labels,
- datasets: [{
- label: 'reports',
- data,
- backgroundColor: 'rgba(52, 211, 153, 0.7)',
- borderColor: 'rgba(52, 211, 153, 0.9)',
- borderWidth: 1,
- borderRadius: 4,
- }],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: { legend: { display: false } },
- scales: {
- x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
- y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
- },
- },
- });
- }
- function renderPieChart(canvasId) {
- const canvas = document.getElementById(canvasId);
- if (!canvas) return;
- const series = parseSeries(canvas, 'series');
- if (series.length === 0) return;
- const labelKey = canvas.dataset.labelKey || 'name';
- const labels = series.map((row) => String(row[labelKey] ?? ''));
- const data = series.map((row) => row.count || 0);
- const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
- const t = chartTheme();
- new Chart(canvas, {
- type: 'pie',
- data: {
- labels,
- datasets: [{
- data,
- backgroundColor: colors,
- borderColor: 'rgba(255,255,255,0.25)',
- borderWidth: 1.5,
- }],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- position: 'bottom',
- labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
- },
- tooltip: {
- callbacks: {
- label: (ctx) => {
- const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
- const pct = ((ctx.parsed / total) * 100).toFixed(1);
- return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
- },
- },
- },
- },
- },
- });
- }
- function renderBlockedIpsChart() {
- const canvas = document.getElementById('blocked-ips-chart');
- if (!canvas) return;
- let payload;
- try {
- payload = JSON.parse(canvas.dataset.blocked || '{}');
- } catch (e) {
- return;
- }
- const days = Array.isArray(payload.days) ? payload.days : [];
- const series = Array.isArray(payload.series) ? payload.series : [];
- if (days.length === 0 || series.length === 0) return;
- const labels = days.map((iso) => {
- const d = new Date(iso + 'T00:00:00Z');
- return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
- });
- const datasets = series.map((row, i) => {
- const colour = PIE_COLORS[i % PIE_COLORS.length];
- return {
- label: row.category || '—',
- data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
- backgroundColor: hexToRgba(colour, 0.7),
- borderColor: colour,
- borderWidth: 1,
- };
- });
- const t = chartTheme();
- new Chart(canvas, {
- type: 'bar',
- data: { labels, datasets },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- position: 'bottom',
- labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
- },
- tooltip: {
- callbacks: {
- title: (items) => (items[0] && days[items[0].dataIndex]) || '',
- },
- },
- },
- scales: {
- x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
- y: {
- stacked: true,
- ticks: { color: t.tickColor, precision: 0 },
- grid: { color: t.gridColor },
- beginAtZero: true,
- },
- },
- },
- });
- }
- document.addEventListener('DOMContentLoaded', () => {
- renderReportsChart();
- renderPieChart('top-reporters-chart');
- renderPieChart('top-categories-chart');
- renderBlockedIpsChart();
- });
- function getDateLocales() {
- const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
- const locales = [];
- if (typeof navigator !== 'undefined' && navigator.language) {
- locales.push(navigator.language);
- }
- if (fallback && fallback.trim()) {
- locales.push(fallback.trim());
- }
- return locales.length > 0 ? locales : undefined;
- }
- function buildDateFormatters() {
- const locales = getDateLocales();
- return {
- datetime: new Intl.DateTimeFormat(locales, {
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- }),
- date: new Intl.DateTimeFormat(locales, {
- year: 'numeric', month: '2-digit', day: '2-digit',
- }),
- };
- }
- function formatTimes(root) {
- const scope = root && root.querySelectorAll ? root : document;
- const formatters = buildDateFormatters();
- const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
- elements.forEach((el) => {
- const iso = el.getAttribute('datetime');
- if (!iso) return;
- const d = new Date(iso);
- if (isNaN(d.getTime())) return;
- const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
- try {
- el.textContent = fmt.format(d);
- if (!el.hasAttribute('title')) {
- el.setAttribute('title', iso);
- }
- } catch (e) {
- /* leave the ISO fallback in place */
- }
- });
- }
- document.addEventListener('DOMContentLoaded', () => formatTimes(document));
- document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
- // 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:';
- function readSortState(tableId) {
- try {
- const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
- if (!raw) return null;
- const parsed = JSON.parse(raw);
- if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
- return parsed;
- }
- } catch (e) { /* ignore */ }
- return null;
- }
- function writeSortState(tableId, state) {
- try {
- if (state) {
- sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
- } else {
- sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
- }
- } catch (e) { /* ignore */ }
- }
- function clearAllSortState() {
- try {
- const keys = [];
- for (let i = 0; i < sessionStorage.length; i++) {
- const k = sessionStorage.key(i);
- if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
- }
- keys.forEach((k) => sessionStorage.removeItem(k));
- } catch (e) { /* ignore */ }
- }
- function cellSortValue(cell, type) {
- if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
- const explicit = cell.getAttribute('data-sort-value');
- if (explicit !== null) return explicit;
- const timeEl = cell.querySelector('time[datetime]');
- if (timeEl) return timeEl.getAttribute('datetime') || '';
- return (cell.textContent || '').trim();
- }
- function compareValues(a, b, type) {
- if (type === 'number') {
- const na = parseFloat(a);
- const nb = parseFloat(b);
- const aNaN = Number.isNaN(na);
- const bNaN = Number.isNaN(nb);
- if (aNaN && bNaN) return 0;
- if (aNaN) return 1;
- if (bNaN) return -1;
- return na - nb;
- }
- if (type === 'date') {
- const ta = a ? Date.parse(a) : NaN;
- const tb = b ? Date.parse(b) : NaN;
- const aNaN = Number.isNaN(ta);
- const bNaN = Number.isNaN(tb);
- if (aNaN && bNaN) return 0;
- if (aNaN) return 1;
- if (bNaN) return -1;
- return ta - tb;
- }
- return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
- }
- function findSortableThs(table) {
- const head = table.tHead;
- if (!head || !head.rows.length) return [];
- return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
- }
- function applySort(table, key, dir) {
- const ths = findSortableThs(table);
- const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
- if (!th) return;
- const colIndex = th.cellIndex;
- const type = th.getAttribute('data-sort-type') || 'string';
- const tbody = table.tBodies[0];
- if (!tbody) return;
- const allRows = Array.from(tbody.rows);
- const groups = [];
- let current = null;
- for (const row of allRows) {
- if (row.hasAttribute('data-sort-row-detail') && current) {
- current.push(row);
- continue;
- }
- current = [row];
- groups.push(current);
- }
- groups.sort((ga, gb) => {
- const va = cellSortValue(ga[0].cells[colIndex], type);
- const vb = cellSortValue(gb[0].cells[colIndex], type);
- const cmp = compareValues(va, vb, type);
- return dir === 'desc' ? -cmp : cmp;
- });
- const frag = document.createDocumentFragment();
- groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
- tbody.appendChild(frag);
- ths.forEach((t) => {
- const ind = t.querySelector('.sort-indicator');
- const isActive = t === th;
- if (ind) {
- ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
- ind.classList.toggle('text-slate-300', !isActive);
- ind.classList.toggle('dark:text-slate-600', !isActive);
- ind.classList.toggle('text-indigo-600', isActive);
- ind.classList.toggle('dark:text-indigo-400', isActive);
- }
- t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
- });
- }
- function initSortableTable(table) {
- if (!table || table.dataset.sortableInit === '1') return;
- table.dataset.sortableInit = '1';
- const tableId = table.getAttribute('data-sortable-table');
- if (!tableId) return;
- const ths = findSortableThs(table);
- if (ths.length === 0) return;
- ths.forEach((th) => {
- th.classList.add('cursor-pointer', 'select-none');
- th.addEventListener('click', () => {
- const key = th.getAttribute('data-sort-key');
- const current = readSortState(tableId);
- const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
- writeSortState(tableId, { key, dir });
- applySort(table, key, dir);
- });
- });
- const initial = readSortState(tableId);
- if (initial) {
- applySort(table, initial.key, initial.dir);
- }
- }
- function initSortableTables(root) {
- const scope = root && root.querySelectorAll ? root : document;
- scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
- }
- document.addEventListener('DOMContentLoaded', () => {
- if (window.location && window.location.pathname === '/login') {
- clearAllSortState();
- }
- initSortableTables(document);
- });
- document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
- // ────────────────────────────────────────────────────────────────────
- // 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();
|