app.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. import Alpine from 'alpinejs';
  2. import 'htmx.org';
  3. import {
  4. Chart,
  5. BarController,
  6. BarElement,
  7. LineController,
  8. LineElement,
  9. PointElement,
  10. PieController,
  11. ArcElement,
  12. CategoryScale,
  13. LinearScale,
  14. Tooltip,
  15. Title,
  16. Legend,
  17. Filler,
  18. } from 'chart.js';
  19. // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
  20. // initial paint; this just wires the toggle button.
  21. function applyTheme(theme) {
  22. if (theme === 'dark') {
  23. document.documentElement.classList.add('dark');
  24. } else {
  25. document.documentElement.classList.remove('dark');
  26. }
  27. try {
  28. localStorage.setItem('irdb-theme', theme);
  29. } catch (e) {
  30. /* ignore */
  31. }
  32. }
  33. document.addEventListener('click', (e) => {
  34. const target = e.target.closest('[data-theme-toggle]');
  35. if (!target) return;
  36. const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
  37. applyTheme(next);
  38. });
  39. // htmx: send the per-session CSRF token on every state-changing request.
  40. document.body.addEventListener('htmx:configRequest', (e) => {
  41. const meta = document.querySelector('meta[name="csrf-token"]');
  42. if (meta && meta.content) {
  43. e.detail.headers['X-CSRF-Token'] = meta.content;
  44. }
  45. });
  46. // Dashboard charts. Each canvas carries its data in a data-attribute
  47. // (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the
  48. // pieces we use so the bundle stays small.
  49. Chart.register(
  50. BarController,
  51. BarElement,
  52. LineController,
  53. LineElement,
  54. PointElement,
  55. PieController,
  56. ArcElement,
  57. CategoryScale,
  58. LinearScale,
  59. Tooltip,
  60. Title,
  61. Legend,
  62. Filler,
  63. );
  64. // Expose Chart globally so per-page inline scripts (e.g. the policy
  65. // score-distribution component) can render charts without re-bundling.
  66. window.Chart = Chart;
  67. // Palette tuned for both light and dark backgrounds. Reused across the
  68. // pie charts so categories/reporters don't share colours.
  69. const PIE_COLORS = [
  70. '#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
  71. '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6',
  72. ];
  73. function chartTheme() {
  74. const isDark = document.documentElement.classList.contains('dark');
  75. return {
  76. tickColor: isDark ? '#94a3b8' : '#475569',
  77. gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
  78. legendColor: isDark ? '#cbd5e1' : '#334155',
  79. };
  80. }
  81. function parseSeries(canvas, attr) {
  82. try {
  83. return JSON.parse(canvas.dataset[attr] || '[]');
  84. } catch (e) {
  85. return [];
  86. }
  87. }
  88. function renderReportsChart() {
  89. const canvas = document.getElementById('reports-chart');
  90. if (!canvas) return;
  91. const buckets = parseSeries(canvas, 'buckets');
  92. const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
  93. const data = buckets.map((b) => b.count || 0);
  94. const t = chartTheme();
  95. new Chart(canvas, {
  96. type: 'bar',
  97. data: {
  98. labels,
  99. datasets: [{
  100. label: 'reports',
  101. data,
  102. backgroundColor: '#6366f1',
  103. }],
  104. },
  105. options: {
  106. responsive: true,
  107. maintainAspectRatio: false,
  108. plugins: { legend: { display: false } },
  109. scales: {
  110. x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  111. y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
  112. },
  113. },
  114. });
  115. }
  116. function renderPieChart(canvasId) {
  117. const canvas = document.getElementById(canvasId);
  118. if (!canvas) return;
  119. const series = parseSeries(canvas, 'series');
  120. if (series.length === 0) return;
  121. const labelKey = canvas.dataset.labelKey || 'name';
  122. const labels = series.map((row) => String(row[labelKey] ?? ''));
  123. const data = series.map((row) => row.count || 0);
  124. const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
  125. const t = chartTheme();
  126. new Chart(canvas, {
  127. type: 'pie',
  128. data: {
  129. labels,
  130. datasets: [{
  131. data,
  132. backgroundColor: colors,
  133. borderColor: 'rgba(255,255,255,0.1)',
  134. borderWidth: 1,
  135. }],
  136. },
  137. options: {
  138. responsive: true,
  139. maintainAspectRatio: false,
  140. plugins: {
  141. legend: {
  142. position: 'bottom',
  143. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  144. },
  145. tooltip: {
  146. callbacks: {
  147. label: (ctx) => {
  148. const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
  149. const pct = ((ctx.parsed / total) * 100).toFixed(1);
  150. return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
  151. },
  152. },
  153. },
  154. },
  155. },
  156. });
  157. }
  158. function renderBlockedIpsChart() {
  159. const canvas = document.getElementById('blocked-ips-chart');
  160. if (!canvas) return;
  161. let payload;
  162. try {
  163. payload = JSON.parse(canvas.dataset.blocked || '{}');
  164. } catch (e) {
  165. return;
  166. }
  167. const days = Array.isArray(payload.days) ? payload.days : [];
  168. const series = Array.isArray(payload.series) ? payload.series : [];
  169. if (days.length === 0 || series.length === 0) return;
  170. // Day-of-month for axis ticks; the ISO date is preserved in the
  171. // tooltip title via the closure on `days`.
  172. const labels = days.map((iso) => {
  173. const d = new Date(iso + 'T00:00:00Z');
  174. return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  175. });
  176. const datasets = series.map((row, i) => {
  177. const colour = PIE_COLORS[i % PIE_COLORS.length];
  178. return {
  179. label: row.category || '—',
  180. data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
  181. backgroundColor: colour,
  182. borderColor: colour,
  183. borderWidth: 0,
  184. };
  185. });
  186. const t = chartTheme();
  187. new Chart(canvas, {
  188. type: 'bar',
  189. data: { labels, datasets },
  190. options: {
  191. responsive: true,
  192. maintainAspectRatio: false,
  193. plugins: {
  194. legend: {
  195. position: 'bottom',
  196. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  197. },
  198. tooltip: {
  199. callbacks: {
  200. title: (items) => (items[0] && days[items[0].dataIndex]) || '',
  201. },
  202. },
  203. },
  204. scales: {
  205. x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  206. y: {
  207. stacked: true,
  208. ticks: { color: t.tickColor, precision: 0 },
  209. grid: { color: t.gridColor },
  210. beginAtZero: true,
  211. },
  212. },
  213. },
  214. });
  215. }
  216. document.addEventListener('DOMContentLoaded', () => {
  217. renderReportsChart();
  218. renderPieChart('top-reporters-chart');
  219. renderPieChart('top-categories-chart');
  220. renderBlockedIpsChart();
  221. });
  222. // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
  223. // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
  224. // string as a no-JS fallback. This pass replaces it with the user's
  225. // browser locale formatting, with an optional configured fallback (set
  226. // via UI_LOCALE on the html data attribute) appended so browser locale
  227. // wins but a deployment can still ensure something sensible if the
  228. // browser's preference isn't supported. `data-irdb-dt-format="date"`
  229. // switches to date-only output.
  230. function getDateLocales() {
  231. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  232. const locales = [];
  233. if (typeof navigator !== 'undefined' && navigator.language) {
  234. locales.push(navigator.language);
  235. }
  236. if (fallback && fallback.trim()) {
  237. locales.push(fallback.trim());
  238. }
  239. return locales.length > 0 ? locales : undefined;
  240. }
  241. function buildDateFormatters() {
  242. const locales = getDateLocales();
  243. return {
  244. datetime: new Intl.DateTimeFormat(locales, {
  245. year: 'numeric', month: '2-digit', day: '2-digit',
  246. hour: '2-digit', minute: '2-digit', second: '2-digit',
  247. }),
  248. date: new Intl.DateTimeFormat(locales, {
  249. year: 'numeric', month: '2-digit', day: '2-digit',
  250. }),
  251. };
  252. }
  253. function formatTimes(root) {
  254. const scope = root && root.querySelectorAll ? root : document;
  255. const formatters = buildDateFormatters();
  256. const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
  257. elements.forEach((el) => {
  258. const iso = el.getAttribute('datetime');
  259. if (!iso) return;
  260. const d = new Date(iso);
  261. if (isNaN(d.getTime())) return;
  262. const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
  263. try {
  264. el.textContent = fmt.format(d);
  265. if (!el.hasAttribute('title')) {
  266. el.setAttribute('title', iso);
  267. }
  268. } catch (e) {
  269. /* leave the ISO fallback in place */
  270. }
  271. });
  272. }
  273. document.addEventListener('DOMContentLoaded', () => formatTimes(document));
  274. document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
  275. // Sortable tables. Tables marked with `data-sortable-table="<id>"` get
  276. // click-to-sort headers. Sort state lives in sessionStorage under
  277. // `irdb-sort:<id>` and is wiped on the login page so logging out
  278. // forgets it. The comparator is selected by the th's `data-sort-type`
  279. // (`string` | `number` | `date`); cells may override the displayed
  280. // text via `data-sort-value`.
  281. const SORT_KEY_PREFIX = 'irdb-sort:';
  282. function readSortState(tableId) {
  283. try {
  284. const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
  285. if (!raw) return null;
  286. const parsed = JSON.parse(raw);
  287. if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
  288. return parsed;
  289. }
  290. } catch (e) { /* ignore */ }
  291. return null;
  292. }
  293. function writeSortState(tableId, state) {
  294. try {
  295. if (state) {
  296. sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
  297. } else {
  298. sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
  299. }
  300. } catch (e) { /* ignore */ }
  301. }
  302. function clearAllSortState() {
  303. try {
  304. const keys = [];
  305. for (let i = 0; i < sessionStorage.length; i++) {
  306. const k = sessionStorage.key(i);
  307. if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
  308. }
  309. keys.forEach((k) => sessionStorage.removeItem(k));
  310. } catch (e) { /* ignore */ }
  311. }
  312. function cellSortValue(cell, type) {
  313. if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
  314. const explicit = cell.getAttribute('data-sort-value');
  315. if (explicit !== null) return explicit;
  316. // <time datetime="..."> is the canonical date carrier.
  317. const timeEl = cell.querySelector('time[datetime]');
  318. if (timeEl) return timeEl.getAttribute('datetime') || '';
  319. return (cell.textContent || '').trim();
  320. }
  321. function compareValues(a, b, type) {
  322. if (type === 'number') {
  323. const na = parseFloat(a);
  324. const nb = parseFloat(b);
  325. const aNaN = Number.isNaN(na);
  326. const bNaN = Number.isNaN(nb);
  327. if (aNaN && bNaN) return 0;
  328. if (aNaN) return 1; // empty/— goes last on asc
  329. if (bNaN) return -1;
  330. return na - nb;
  331. }
  332. if (type === 'date') {
  333. const ta = a ? Date.parse(a) : NaN;
  334. const tb = b ? Date.parse(b) : NaN;
  335. const aNaN = Number.isNaN(ta);
  336. const bNaN = Number.isNaN(tb);
  337. if (aNaN && bNaN) return 0;
  338. if (aNaN) return 1;
  339. if (bNaN) return -1;
  340. return ta - tb;
  341. }
  342. // string
  343. return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
  344. }
  345. function findSortableThs(table) {
  346. // Only <th> in the *first* thead row (avoid scanning nested tables
  347. // — the score-chart has its own SVG layout, not a sortable table).
  348. const head = table.tHead;
  349. if (!head || !head.rows.length) return [];
  350. return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
  351. }
  352. function applySort(table, key, dir) {
  353. const ths = findSortableThs(table);
  354. const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
  355. if (!th) return;
  356. const colIndex = th.cellIndex;
  357. const type = th.getAttribute('data-sort-type') || 'string';
  358. const tbody = table.tBodies[0];
  359. if (!tbody) return;
  360. // Treat each contiguous run of rows with the same `data-sort-row-group`
  361. // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
  362. // expandable rows (e.g. the audit log payload toggle) stay glued to
  363. // their parent. By default, every row sorts independently.
  364. const allRows = Array.from(tbody.rows);
  365. const groups = [];
  366. let current = null;
  367. for (const row of allRows) {
  368. if (row.hasAttribute('data-sort-row-detail') && current) {
  369. current.push(row);
  370. continue;
  371. }
  372. current = [row];
  373. groups.push(current);
  374. }
  375. groups.sort((ga, gb) => {
  376. const va = cellSortValue(ga[0].cells[colIndex], type);
  377. const vb = cellSortValue(gb[0].cells[colIndex], type);
  378. const cmp = compareValues(va, vb, type);
  379. return dir === 'desc' ? -cmp : cmp;
  380. });
  381. const frag = document.createDocumentFragment();
  382. groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
  383. tbody.appendChild(frag);
  384. // Update indicators.
  385. ths.forEach((t) => {
  386. const ind = t.querySelector('.sort-indicator');
  387. const isActive = t === th;
  388. if (ind) {
  389. ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
  390. ind.classList.toggle('text-slate-300', !isActive);
  391. ind.classList.toggle('dark:text-slate-600', !isActive);
  392. ind.classList.toggle('text-indigo-600', isActive);
  393. ind.classList.toggle('dark:text-indigo-400', isActive);
  394. }
  395. t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
  396. });
  397. }
  398. function initSortableTable(table) {
  399. if (!table || table.dataset.sortableInit === '1') return;
  400. table.dataset.sortableInit = '1';
  401. const tableId = table.getAttribute('data-sortable-table');
  402. if (!tableId) return;
  403. const ths = findSortableThs(table);
  404. if (ths.length === 0) return;
  405. ths.forEach((th) => {
  406. th.classList.add('cursor-pointer', 'select-none');
  407. th.addEventListener('click', () => {
  408. const key = th.getAttribute('data-sort-key');
  409. const current = readSortState(tableId);
  410. const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
  411. writeSortState(tableId, { key, dir });
  412. applySort(table, key, dir);
  413. });
  414. });
  415. const initial = readSortState(tableId);
  416. if (initial) {
  417. applySort(table, initial.key, initial.dir);
  418. }
  419. }
  420. function initSortableTables(root) {
  421. const scope = root && root.querySelectorAll ? root : document;
  422. scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
  423. }
  424. document.addEventListener('DOMContentLoaded', () => {
  425. // The login page is the canonical "logged out" landing spot —
  426. // wipe any sort state left over from the previous session.
  427. if (window.location && window.location.pathname === '/login') {
  428. clearAllSortState();
  429. }
  430. initSortableTables(document);
  431. });
  432. document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
  433. window.Alpine = Alpine;
  434. Alpine.start();