1
0

app.js 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. // CSP-friendly Alpine build: forbids `Function()` / arbitrary inline
  2. // expressions, so every component lives here as `Alpine.data(...)` and
  3. // templates reference component/method names only. Lets the UI ship a
  4. // CSP without `'unsafe-eval'` or `'unsafe-inline'` (SEC_REVIEW F24).
  5. import Alpine from '@alpinejs/csp';
  6. import 'htmx.org';
  7. import {
  8. Chart,
  9. BarController,
  10. BarElement,
  11. LineController,
  12. LineElement,
  13. PointElement,
  14. PieController,
  15. ArcElement,
  16. CategoryScale,
  17. LinearScale,
  18. Tooltip,
  19. Title,
  20. Legend,
  21. Filler,
  22. } from 'chart.js';
  23. function applyTheme(theme) {
  24. if (theme === 'dark') {
  25. document.documentElement.classList.add('dark');
  26. } else {
  27. document.documentElement.classList.remove('dark');
  28. }
  29. try {
  30. localStorage.setItem('irdb-theme', theme);
  31. } catch (e) {
  32. /* ignore */
  33. }
  34. }
  35. document.addEventListener('click', (e) => {
  36. const target = e.target.closest('[data-theme-toggle]');
  37. if (!target) return;
  38. const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
  39. applyTheme(next);
  40. });
  41. document.body.addEventListener('htmx:configRequest', (e) => {
  42. const meta = document.querySelector('meta[name="csrf-token"]');
  43. if (meta && meta.content) {
  44. e.detail.headers['X-CSRF-Token'] = meta.content;
  45. }
  46. });
  47. Chart.register(
  48. BarController,
  49. BarElement,
  50. LineController,
  51. LineElement,
  52. PointElement,
  53. PieController,
  54. ArcElement,
  55. CategoryScale,
  56. LinearScale,
  57. Tooltip,
  58. Title,
  59. Legend,
  60. Filler,
  61. );
  62. const PIE_COLORS = [
  63. '#34d399',
  64. '#22d3ee',
  65. '#818cf8',
  66. '#a78bfa',
  67. '#f472b6',
  68. '#fb7185',
  69. '#fbbf24',
  70. '#a3e635',
  71. '#2dd4bf',
  72. '#60a5fa',
  73. ];
  74. function chartTheme() {
  75. const isDark = document.documentElement.classList.contains('dark');
  76. return {
  77. tickColor: isDark ? '#94a3b8' : '#475569',
  78. gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
  79. legendColor: isDark ? '#cbd5e1' : '#334155',
  80. };
  81. }
  82. function hexToRgba(hex, alpha) {
  83. const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
  84. if (!m) return hex;
  85. return `rgba(${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)},${alpha})`;
  86. }
  87. function parseSeries(canvas, attr) {
  88. try {
  89. return JSON.parse(canvas.dataset[attr] || '[]');
  90. } catch (e) {
  91. return [];
  92. }
  93. }
  94. function renderReportsChart() {
  95. const canvas = document.getElementById('reports-chart');
  96. if (!canvas) return;
  97. const buckets = parseSeries(canvas, 'buckets');
  98. const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
  99. const data = buckets.map((b) => b.count || 0);
  100. const t = chartTheme();
  101. new Chart(canvas, {
  102. type: 'bar',
  103. data: {
  104. labels,
  105. datasets: [{
  106. label: 'reports',
  107. data,
  108. backgroundColor: 'rgba(52, 211, 153, 0.7)',
  109. borderColor: 'rgba(52, 211, 153, 0.9)',
  110. borderWidth: 1,
  111. borderRadius: 4,
  112. }],
  113. },
  114. options: {
  115. responsive: true,
  116. maintainAspectRatio: false,
  117. plugins: { legend: { display: false } },
  118. scales: {
  119. x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  120. y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
  121. },
  122. },
  123. });
  124. }
  125. function renderPieChart(canvasId) {
  126. const canvas = document.getElementById(canvasId);
  127. if (!canvas) return;
  128. const series = parseSeries(canvas, 'series');
  129. if (series.length === 0) return;
  130. const labelKey = canvas.dataset.labelKey || 'name';
  131. const labels = series.map((row) => String(row[labelKey] ?? ''));
  132. const data = series.map((row) => row.count || 0);
  133. const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
  134. const t = chartTheme();
  135. new Chart(canvas, {
  136. type: 'pie',
  137. data: {
  138. labels,
  139. datasets: [{
  140. data,
  141. backgroundColor: colors,
  142. borderColor: 'rgba(255,255,255,0.25)',
  143. borderWidth: 1.5,
  144. }],
  145. },
  146. options: {
  147. responsive: true,
  148. maintainAspectRatio: false,
  149. plugins: {
  150. legend: {
  151. position: 'bottom',
  152. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  153. },
  154. tooltip: {
  155. callbacks: {
  156. label: (ctx) => {
  157. const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
  158. const pct = ((ctx.parsed / total) * 100).toFixed(1);
  159. return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
  160. },
  161. },
  162. },
  163. },
  164. },
  165. });
  166. }
  167. function renderBlockedIpsChart() {
  168. const canvas = document.getElementById('blocked-ips-chart');
  169. if (!canvas) return;
  170. let payload;
  171. try {
  172. payload = JSON.parse(canvas.dataset.blocked || '{}');
  173. } catch (e) {
  174. return;
  175. }
  176. const days = Array.isArray(payload.days) ? payload.days : [];
  177. const series = Array.isArray(payload.series) ? payload.series : [];
  178. if (days.length === 0 || series.length === 0) return;
  179. const labels = days.map((iso) => {
  180. const d = new Date(iso + 'T00:00:00Z');
  181. return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  182. });
  183. const datasets = series.map((row, i) => {
  184. const colour = PIE_COLORS[i % PIE_COLORS.length];
  185. return {
  186. label: row.category || '—',
  187. data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
  188. backgroundColor: hexToRgba(colour, 0.7),
  189. borderColor: colour,
  190. borderWidth: 1,
  191. };
  192. });
  193. const t = chartTheme();
  194. new Chart(canvas, {
  195. type: 'bar',
  196. data: { labels, datasets },
  197. options: {
  198. responsive: true,
  199. maintainAspectRatio: false,
  200. plugins: {
  201. legend: {
  202. position: 'bottom',
  203. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  204. },
  205. tooltip: {
  206. callbacks: {
  207. title: (items) => (items[0] && days[items[0].dataIndex]) || '',
  208. },
  209. },
  210. },
  211. scales: {
  212. x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  213. y: {
  214. stacked: true,
  215. ticks: { color: t.tickColor, precision: 0 },
  216. grid: { color: t.gridColor },
  217. beginAtZero: true,
  218. },
  219. },
  220. },
  221. });
  222. }
  223. document.addEventListener('DOMContentLoaded', () => {
  224. renderReportsChart();
  225. renderPieChart('top-reporters-chart');
  226. renderPieChart('top-categories-chart');
  227. renderBlockedIpsChart();
  228. });
  229. function getDateLocales() {
  230. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  231. const locales = [];
  232. if (typeof navigator !== 'undefined' && navigator.language) {
  233. locales.push(navigator.language);
  234. }
  235. if (fallback && fallback.trim()) {
  236. locales.push(fallback.trim());
  237. }
  238. return locales.length > 0 ? locales : undefined;
  239. }
  240. function buildDateFormatters() {
  241. const locales = getDateLocales();
  242. return {
  243. datetime: new Intl.DateTimeFormat(locales, {
  244. year: 'numeric', month: '2-digit', day: '2-digit',
  245. hour: '2-digit', minute: '2-digit', second: '2-digit',
  246. }),
  247. date: new Intl.DateTimeFormat(locales, {
  248. year: 'numeric', month: '2-digit', day: '2-digit',
  249. }),
  250. };
  251. }
  252. function formatTimes(root) {
  253. const scope = root && root.querySelectorAll ? root : document;
  254. const formatters = buildDateFormatters();
  255. const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
  256. elements.forEach((el) => {
  257. const iso = el.getAttribute('datetime');
  258. if (!iso) return;
  259. const d = new Date(iso);
  260. if (isNaN(d.getTime())) return;
  261. const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
  262. try {
  263. el.textContent = fmt.format(d);
  264. if (!el.hasAttribute('title')) {
  265. el.setAttribute('title', iso);
  266. }
  267. } catch (e) {
  268. /* leave the ISO fallback in place */
  269. }
  270. });
  271. }
  272. document.addEventListener('DOMContentLoaded', () => formatTimes(document));
  273. document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
  274. // Audit page filter form: ISO ⇄ datetime-local round-tripping. Used to
  275. // live as an inline <script> in audit/index.twig; moved here so the page
  276. // has zero inline JS.
  277. function initAuditFilterForm() {
  278. const form = document.getElementById('audit-filter-form');
  279. if (!form) return;
  280. function pad(n) { return String(n).padStart(2, '0'); }
  281. function isoToLocalInput(iso) {
  282. if (!iso) return '';
  283. const d = new Date(iso);
  284. if (isNaN(d.getTime())) return '';
  285. return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
  286. + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
  287. }
  288. function localInputToIso(value) {
  289. if (!value) return '';
  290. const d = new Date(value);
  291. if (isNaN(d.getTime())) return '';
  292. return d.toISOString();
  293. }
  294. form.querySelectorAll('input[data-irdb-iso-filter]').forEach((el) => {
  295. const iso = el.getAttribute('data-irdb-iso-filter');
  296. if (iso) el.value = isoToLocalInput(iso);
  297. });
  298. form.addEventListener('submit', () => {
  299. form.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
  300. if (el.value) el.value = localInputToIso(el.value);
  301. });
  302. });
  303. }
  304. document.addEventListener('DOMContentLoaded', initAuditFilterForm);
  305. // Sortable tables. Templates mark a table with `data-sortable-table="<id>"`
  306. // and headers with `data-sort-key`; sort state lives in sessionStorage.
  307. const SORT_KEY_PREFIX = 'irdb-sort:';
  308. function readSortState(tableId) {
  309. try {
  310. const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
  311. if (!raw) return null;
  312. const parsed = JSON.parse(raw);
  313. if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
  314. return parsed;
  315. }
  316. } catch (e) { /* ignore */ }
  317. return null;
  318. }
  319. function writeSortState(tableId, state) {
  320. try {
  321. if (state) {
  322. sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
  323. } else {
  324. sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
  325. }
  326. } catch (e) { /* ignore */ }
  327. }
  328. function clearAllSortState() {
  329. try {
  330. const keys = [];
  331. for (let i = 0; i < sessionStorage.length; i++) {
  332. const k = sessionStorage.key(i);
  333. if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
  334. }
  335. keys.forEach((k) => sessionStorage.removeItem(k));
  336. } catch (e) { /* ignore */ }
  337. }
  338. function cellSortValue(cell, type) {
  339. if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
  340. const explicit = cell.getAttribute('data-sort-value');
  341. if (explicit !== null) return explicit;
  342. const timeEl = cell.querySelector('time[datetime]');
  343. if (timeEl) return timeEl.getAttribute('datetime') || '';
  344. return (cell.textContent || '').trim();
  345. }
  346. function compareValues(a, b, type) {
  347. if (type === 'number') {
  348. const na = parseFloat(a);
  349. const nb = parseFloat(b);
  350. const aNaN = Number.isNaN(na);
  351. const bNaN = Number.isNaN(nb);
  352. if (aNaN && bNaN) return 0;
  353. if (aNaN) return 1;
  354. if (bNaN) return -1;
  355. return na - nb;
  356. }
  357. if (type === 'date') {
  358. const ta = a ? Date.parse(a) : NaN;
  359. const tb = b ? Date.parse(b) : NaN;
  360. const aNaN = Number.isNaN(ta);
  361. const bNaN = Number.isNaN(tb);
  362. if (aNaN && bNaN) return 0;
  363. if (aNaN) return 1;
  364. if (bNaN) return -1;
  365. return ta - tb;
  366. }
  367. return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
  368. }
  369. function findSortableThs(table) {
  370. const head = table.tHead;
  371. if (!head || !head.rows.length) return [];
  372. return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
  373. }
  374. function applySort(table, key, dir) {
  375. const ths = findSortableThs(table);
  376. const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
  377. if (!th) return;
  378. const colIndex = th.cellIndex;
  379. const type = th.getAttribute('data-sort-type') || 'string';
  380. const tbody = table.tBodies[0];
  381. if (!tbody) return;
  382. const allRows = Array.from(tbody.rows);
  383. const groups = [];
  384. let current = null;
  385. for (const row of allRows) {
  386. if (row.hasAttribute('data-sort-row-detail') && current) {
  387. current.push(row);
  388. continue;
  389. }
  390. current = [row];
  391. groups.push(current);
  392. }
  393. groups.sort((ga, gb) => {
  394. const va = cellSortValue(ga[0].cells[colIndex], type);
  395. const vb = cellSortValue(gb[0].cells[colIndex], type);
  396. const cmp = compareValues(va, vb, type);
  397. return dir === 'desc' ? -cmp : cmp;
  398. });
  399. const frag = document.createDocumentFragment();
  400. groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
  401. tbody.appendChild(frag);
  402. ths.forEach((t) => {
  403. const ind = t.querySelector('.sort-indicator');
  404. const isActive = t === th;
  405. if (ind) {
  406. ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
  407. ind.classList.toggle('text-slate-300', !isActive);
  408. ind.classList.toggle('dark:text-slate-600', !isActive);
  409. ind.classList.toggle('text-indigo-600', isActive);
  410. ind.classList.toggle('dark:text-indigo-400', isActive);
  411. }
  412. t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
  413. });
  414. }
  415. function initSortableTable(table) {
  416. if (!table || table.dataset.sortableInit === '1') return;
  417. table.dataset.sortableInit = '1';
  418. const tableId = table.getAttribute('data-sortable-table');
  419. if (!tableId) return;
  420. const ths = findSortableThs(table);
  421. if (ths.length === 0) return;
  422. ths.forEach((th) => {
  423. th.classList.add('cursor-pointer', 'select-none');
  424. th.addEventListener('click', () => {
  425. const key = th.getAttribute('data-sort-key');
  426. const current = readSortState(tableId);
  427. const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
  428. writeSortState(tableId, { key, dir });
  429. applySort(table, key, dir);
  430. });
  431. });
  432. const initial = readSortState(tableId);
  433. if (initial) {
  434. applySort(table, initial.key, initial.dir);
  435. }
  436. }
  437. function initSortableTables(root) {
  438. const scope = root && root.querySelectorAll ? root : document;
  439. scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
  440. }
  441. document.addEventListener('DOMContentLoaded', () => {
  442. if (window.location && window.location.pathname === '/login') {
  443. clearAllSortState();
  444. }
  445. initSortableTables(document);
  446. });
  447. document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
  448. // ────────────────────────────────────────────────────────────────────
  449. // Alpine.js components — registered up-front because the CSP build only
  450. // resolves `x-data="name"` against names registered via Alpine.data().
  451. // ────────────────────────────────────────────────────────────────────
  452. // Generic open/close toggle (popovers, modals, side-panes). Optional
  453. // `data-initial-open="1"` on the root element starts in the open state.
  454. Alpine.data('toggle', () => ({
  455. open: false,
  456. init() {
  457. if (this.$el.dataset.initialOpen === '1') {
  458. this.open = true;
  459. }
  460. },
  461. show() { this.open = true; },
  462. hide() { this.open = false; },
  463. flip() { this.open = !this.open; },
  464. }));
  465. // Single-row expander — multiple rows share one component, only one row
  466. // expanded at a time. Each expander button passes its own row id.
  467. Alpine.data('rowExpander', () => ({
  468. open: null,
  469. toggle(id) {
  470. this.open = (this.open === id ? null : id);
  471. },
  472. isOpen(id) {
  473. return this.open === id;
  474. },
  475. }));
  476. // Form section switcher driven by a <select> bound via `x-model="kind"`.
  477. // `data-initial-kind="ip"` chooses the starting branch.
  478. Alpine.data('kindSwitcher', () => ({
  479. kind: '',
  480. init() {
  481. this.kind = this.$el.dataset.initialKind || '';
  482. },
  483. isKind(value) {
  484. return this.kind === value;
  485. },
  486. }));
  487. // Submit-once guard: disables the submit button on form submission.
  488. Alpine.data('submitGuard', () => ({
  489. submitting: false,
  490. onSubmit() { this.submitting = true; },
  491. get notSubmitting() { return !this.submitting; },
  492. }));
  493. // "Type the magic word to confirm" dangerous-action component used by
  494. // the seed-demo and purge buttons in the settings page.
  495. Alpine.data('dangerousAction', () => ({
  496. open: false,
  497. confirm: '',
  498. submitting: false,
  499. expectedConfirm: '',
  500. init() {
  501. this.expectedConfirm = this.$el.dataset.expectedConfirm || '';
  502. },
  503. show() { this.open = true; },
  504. hide() {
  505. this.open = false;
  506. this.confirm = '';
  507. },
  508. onSubmit() { this.submitting = true; },
  509. get blocked() {
  510. return this.confirm !== this.expectedConfirm || this.submitting;
  511. },
  512. get notSubmitting() { return !this.submitting; },
  513. }));
  514. // Login page: initially-open if OIDC is disabled, hidden behind a
  515. // "Use local sign-in" toggle otherwise. The PHP page sets
  516. // `data-initial-open="1"` when there is no OIDC.
  517. Alpine.data('loginForm', () => ({
  518. open: false,
  519. init() {
  520. if (this.$el.dataset.initialOpen === '1') {
  521. this.open = true;
  522. }
  523. this.$watch('open', (v) => {
  524. if (v) {
  525. this.$nextTick(() => {
  526. if (this.$refs.usernameInput) {
  527. this.$refs.usernameInput.focus();
  528. }
  529. });
  530. }
  531. });
  532. },
  533. flip() { this.open = !this.open; },
  534. get toggleLabel() {
  535. return this.open ? 'Hide local sign-in' : 'Use local sign-in';
  536. },
  537. }));
  538. // Categories edit page: live preview of the decay function.
  539. Alpine.data('decayPreview', () => ({
  540. fn: 'exponential',
  541. param: 14,
  542. init() {
  543. const ds = this.$el.dataset;
  544. this.fn = ds.decayFn === 'linear' ? 'linear' : 'exponential';
  545. this.param = parseFloat(ds.decayParam) || 14;
  546. },
  547. decay(ageDays) {
  548. const p = Math.max(0.1, Number(this.param) || 0.1);
  549. if (this.fn === 'linear') {
  550. return Math.max(0, 1 - ageDays / p);
  551. }
  552. return Math.pow(0.5, ageDays / p);
  553. },
  554. path() {
  555. const x0 = 40, y0 = 10, w = 600, h = 200;
  556. const points = [];
  557. for (let i = 0; i <= 60; i++) {
  558. const x = x0 + (i / 60) * w;
  559. const y = y0 + (1 - this.decay(i)) * h;
  560. points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
  561. }
  562. return points.join(' ');
  563. },
  564. get paramLabel() {
  565. return this.fn === 'linear' ? 'Days to zero' : 'Half-life days';
  566. },
  567. }));
  568. // Policies edit page: preview of the rendered blocklist for a policy.
  569. Alpine.data('policyPreview', () => ({
  570. loading: true,
  571. count: 0,
  572. sample: [],
  573. policyId: 0,
  574. init() {
  575. this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
  576. this.load();
  577. },
  578. async load() {
  579. this.loading = true;
  580. try {
  581. const res = await fetch('/app/policies/' + this.policyId + '/preview-proxy', { credentials: 'same-origin' });
  582. if (!res.ok) throw new Error('preview ' + res.status);
  583. const data = await res.json();
  584. this.count = data.count || 0;
  585. const items = Array.isArray(data.sample) ? data.sample : [];
  586. this.sample = items.map((raw, idx) => this.shapeEntry(raw, idx));
  587. } catch (e) {
  588. this.count = 0;
  589. this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
  590. } finally {
  591. this.loading = false;
  592. }
  593. },
  594. get notLoading() { return !this.loading; },
  595. shapeEntry(raw, idx) {
  596. if (typeof raw === 'string') {
  597. return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
  598. }
  599. const ip = raw.ip_or_cidr || '';
  600. let expiry = '';
  601. let tooltip = '';
  602. if (raw.expires_at) {
  603. const formatted = this.formatExpiry(raw.expires_at);
  604. expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
  605. tooltip = (raw.expires_estimated
  606. ? 'Estimated falls-off date (assumes no further reports). ISO: '
  607. : 'Configured manual block expiry. ISO: ') + raw.expires_at;
  608. } else if (raw.reason === 'manual') {
  609. expiry = 'never';
  610. tooltip = 'Manual block has no configured expiry';
  611. } else {
  612. expiry = '—';
  613. tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
  614. }
  615. return { key: 'r' + idx + ':' + ip, label: ip, expiry, tooltip };
  616. },
  617. formatExpiry(iso) {
  618. if (!iso) return '';
  619. const d = new Date(iso);
  620. if (isNaN(d.getTime())) return iso;
  621. try {
  622. return new Intl.DateTimeFormat(getDateLocales(), {
  623. year: 'numeric', month: '2-digit', day: '2-digit',
  624. hour: '2-digit', minute: '2-digit',
  625. }).format(d);
  626. } catch (_) { return iso; }
  627. },
  628. }));
  629. // Policies edit page: per-category histogram with shaded "blocked"
  630. // regions to the right of each threshold.
  631. Alpine.data('policyScoreDistribution', () => ({
  632. empty: false,
  633. chart: null,
  634. policyId: 0,
  635. init() {
  636. this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
  637. this.load();
  638. },
  639. async load() {
  640. try {
  641. const res = await fetch('/app/policies/' + this.policyId + '/score-distribution-proxy', { credentials: 'same-origin' });
  642. if (!res.ok) throw new Error('distribution ' + res.status);
  643. const data = await res.json();
  644. this.render(data);
  645. } catch (e) {
  646. this.empty = true;
  647. if (this.chart) { this.chart.destroy(); this.chart = null; }
  648. }
  649. },
  650. render(data) {
  651. const CATEGORY_COLORS = [
  652. '#f87171', '#fbbf24', '#facc15', '#4ade80',
  653. '#38bdf8', '#a78bfa', '#f472b6', '#2dd4bf',
  654. ];
  655. const bucketSize = Number(data.bucket_size) || 5;
  656. const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
  657. const categories = Array.isArray(data.categories) ? data.categories : [];
  658. const overallMaxScore = Number(data.overall_max_score) || Number(data.max_score) || 0;
  659. const thresholdByCat = {};
  660. const thresholdBySlug = {};
  661. for (const t of thresholds) {
  662. if (typeof t.threshold !== 'number') continue;
  663. if (t.category_id != null) thresholdByCat[t.category_id] = t.threshold;
  664. if (t.category_slug) thresholdBySlug[t.category_slug] = t.threshold;
  665. }
  666. const categoriesById = {};
  667. for (const c of categories) {
  668. if (c.category_id != null) categoriesById[c.category_id] = c;
  669. }
  670. const onChart = [];
  671. for (const t of thresholds) {
  672. if (typeof t.threshold !== 'number') continue;
  673. const cat = categoriesById[t.category_id];
  674. onChart.push({
  675. category_id: t.category_id,
  676. category_slug: t.category_slug || ('#' + t.category_id),
  677. threshold: t.threshold,
  678. buckets: cat && Array.isArray(cat.buckets) ? cat.buckets : [],
  679. });
  680. }
  681. this.empty = onChart.length === 0;
  682. let upperX = overallMaxScore;
  683. let yMax = 0;
  684. for (const oc of onChart) {
  685. if (oc.threshold > upperX) upperX = oc.threshold;
  686. for (const b of oc.buckets) {
  687. const start = Number(b.start) || 0;
  688. const cnt = Number(b.count) || 0;
  689. if (start + bucketSize > upperX) upperX = start + bucketSize;
  690. if (cnt > yMax) yMax = cnt;
  691. }
  692. }
  693. if (yMax <= 0) yMax = 1;
  694. if (upperX <= 0) upperX = bucketSize;
  695. upperX = upperX + bucketSize;
  696. const datasets = [];
  697. onChart.forEach((oc, i) => {
  698. const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
  699. datasets.push({
  700. label: oc.category_slug + ' ≥ ' + oc.threshold,
  701. data: [
  702. { x: oc.threshold, y: yMax },
  703. { x: upperX, y: yMax },
  704. ],
  705. borderColor: hexToRgba(color, 0.45),
  706. backgroundColor: hexToRgba(color, 0.10),
  707. borderWidth: 1,
  708. pointRadius: 0,
  709. pointHoverRadius: 0,
  710. fill: 'origin',
  711. tension: 0,
  712. stepped: false,
  713. spanGaps: false,
  714. order: 5 + i,
  715. _isThresholdRegion: true,
  716. });
  717. const points = oc.buckets.map((b) => ({
  718. x: Number(b.start) || 0,
  719. y: Number(b.count) || 0,
  720. }));
  721. datasets.push({
  722. label: oc.category_slug,
  723. data: points,
  724. borderColor: color,
  725. backgroundColor: color,
  726. borderWidth: 2,
  727. tension: 0.25,
  728. fill: false,
  729. pointRadius: 3,
  730. pointHoverRadius: 5,
  731. pointBackgroundColor: color,
  732. order: i,
  733. });
  734. });
  735. const t = chartTheme();
  736. const canvas = this.$refs.canvas;
  737. if (!canvas) return;
  738. if (this.chart) {
  739. this.chart.destroy();
  740. }
  741. this.chart = new Chart(canvas, {
  742. type: 'line',
  743. data: { datasets },
  744. options: {
  745. responsive: true,
  746. maintainAspectRatio: false,
  747. parsing: false,
  748. plugins: {
  749. legend: {
  750. position: 'bottom',
  751. labels: {
  752. color: t.legendColor,
  753. boxWidth: 12,
  754. font: { size: 11 },
  755. filter: (item, chartData) => {
  756. const ds = chartData.datasets[item.datasetIndex];
  757. return !ds || !ds._isThresholdRegion;
  758. },
  759. },
  760. },
  761. tooltip: {
  762. filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
  763. callbacks: {
  764. title: (items) => {
  765. if (!items.length) return '';
  766. const it = items[0];
  767. const start = it.parsed.x;
  768. return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
  769. },
  770. label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
  771. },
  772. },
  773. },
  774. scales: {
  775. x: {
  776. type: 'linear',
  777. min: 0,
  778. max: upperX,
  779. title: { display: true, text: 'score', color: t.tickColor },
  780. ticks: { color: t.tickColor, stepSize: bucketSize },
  781. grid: { color: t.gridColor },
  782. },
  783. y: {
  784. beginAtZero: true,
  785. title: { display: true, text: 'IPs', color: t.tickColor },
  786. ticks: { color: t.tickColor, precision: 0 },
  787. grid: { color: t.gridColor },
  788. },
  789. },
  790. },
  791. });
  792. },
  793. }));
  794. // IP detail page: rolling decay-aware score plotted across week / month
  795. // / all / future ranges. Reads its inputs from data attributes set by
  796. // the template.
  797. Alpine.data('scoreOverTime', () => ({
  798. range: 'month',
  799. ranges: [
  800. { id: 'week', label: 'Week' },
  801. { id: 'month', label: 'Month' },
  802. { id: 'all', label: 'All' },
  803. { id: 'future', label: '+30d' },
  804. ],
  805. _now: 0,
  806. _reports: [],
  807. _categories: {},
  808. init() {
  809. const ds = this.$el.dataset;
  810. let payload = { reports: [], categories: [], now: '' };
  811. try {
  812. if (ds.scoreChart) {
  813. payload = JSON.parse(ds.scoreChart);
  814. }
  815. } catch (e) { /* fall through with defaults */ }
  816. const DAY_MS = 24 * 3600 * 1000;
  817. this._now = new Date(payload.now || Date.now()).getTime();
  818. this._reports = (payload.reports || [])
  819. .map(r => ({
  820. t: new Date(r.at).getTime(),
  821. category: r.category,
  822. weight: Number(r.weight) || 1,
  823. }))
  824. .filter(r => Number.isFinite(r.t));
  825. this._categories = (payload.categories || []).reduce((acc, c) => {
  826. if (c && c.slug) {
  827. acc[c.slug] = {
  828. fn: c.decay_function === 'linear' ? 'linear' : 'exponential',
  829. param: Math.max(0.1, Number(c.decay_param) || 14),
  830. };
  831. }
  832. return acc;
  833. }, {});
  834. this.DAY_MS = DAY_MS;
  835. },
  836. setRange(id) { this.range = id; },
  837. isRange(id) { return this.range === id; },
  838. isFuture() { return this.range === 'future'; },
  839. classForRange(id) {
  840. return this.range === id
  841. ? 'bg-indigo-600 text-white border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700'
  842. : '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';
  843. },
  844. decayFor(report, t) {
  845. const DEFAULT = { fn: 'exponential', param: 14 };
  846. const cat = this._categories[report.category] || DEFAULT;
  847. const ageDays = (t - report.t) / this.DAY_MS;
  848. if (ageDays < 0) return 0;
  849. if (cat.fn === 'linear') return Math.max(0, 1 - ageDays / cat.param);
  850. return Math.pow(0.5, ageDays / cat.param);
  851. },
  852. totalScoreAt(t) {
  853. let total = 0;
  854. for (const r of this._reports) total += r.weight * this.decayFor(r, t);
  855. return total;
  856. },
  857. fmtDate(ts) {
  858. const d = new Date(ts);
  859. if (isNaN(d.getTime())) return '';
  860. try {
  861. return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  862. } catch (e) {
  863. return d.toISOString().slice(0, 10);
  864. }
  865. },
  866. get hasReports() { return this._reports.length > 0; },
  867. get hasNoReports() { return this._reports.length === 0; },
  868. get bounds() {
  869. const NOW = this._now;
  870. const DAY = this.DAY_MS;
  871. switch (this.range) {
  872. case 'week': return { start: NOW - 7 * DAY, end: NOW };
  873. case 'future': return { start: NOW, end: NOW + 30 * DAY };
  874. case 'all': {
  875. const earliest = this._reports.length
  876. ? Math.min.apply(null, this._reports.map(r => r.t))
  877. : NOW - 30 * DAY;
  878. return { start: Math.min(earliest, NOW - DAY), end: NOW };
  879. }
  880. case 'month':
  881. default: return { start: NOW - 30 * DAY, end: NOW };
  882. }
  883. },
  884. get points() {
  885. const { start, end } = this.bounds;
  886. const N = 120;
  887. const span = end - start;
  888. const out = [];
  889. for (let i = 0; i <= N; i++) {
  890. const t = start + (span * i) / N;
  891. out.push({ t, v: this.totalScoreAt(t) });
  892. }
  893. return out;
  894. },
  895. get maxScoreDisplay() {
  896. let max = 0;
  897. for (const p of this.points) if (p.v > max) max = p.v;
  898. return max;
  899. },
  900. get maxScoreLabel() {
  901. return this.maxScoreDisplay.toFixed(2);
  902. },
  903. path() {
  904. const pts = this.points;
  905. if (pts.length === 0) return '';
  906. const x0 = 50, y0 = 20, w = 590, h = 180;
  907. const max = Math.max(this.maxScoreDisplay, 1e-6);
  908. const out = [];
  909. for (let i = 0; i < pts.length; i++) {
  910. const x = x0 + (i / (pts.length - 1)) * w;
  911. const y = y0 + (1 - pts[i].v / max) * h;
  912. out.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
  913. }
  914. return out.join(' ');
  915. },
  916. yLabel(frac) {
  917. return (this.maxScoreDisplay * frac).toFixed(2);
  918. },
  919. xLabel(frac) {
  920. const { start, end } = this.bounds;
  921. return this.fmtDate(start + (end - start) * frac);
  922. },
  923. xAxisCaption() {
  924. switch (this.range) {
  925. case 'week': return 'last 7 days';
  926. case 'month': return 'last 30 days';
  927. case 'all': return 'all reported activity';
  928. case 'future': return 'next 30 days (forecast)';
  929. default: return '';
  930. }
  931. },
  932. rangeLabel() {
  933. const opt = this.ranges.find(r => r.id === this.range);
  934. return opt ? opt.label : '';
  935. },
  936. }));
  937. // Tokens index page: copy-button for the just-issued raw token.
  938. Alpine.data('rawTokenCopy', () => ({
  939. open: true,
  940. hide() { this.open = false; },
  941. copy() {
  942. const el = document.getElementById('raw-token');
  943. if (el) navigator.clipboard.writeText(el.value);
  944. },
  945. }));
  946. Alpine.start();