app.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  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 renderBansTrendChart() {
  159. const canvas = document.getElementById('bans-trend-chart');
  160. if (!canvas) return;
  161. const series = parseSeries(canvas, 'series');
  162. if (series.length === 0) return;
  163. // Show day-of-month as label; the full ISO date stays in the tooltip
  164. // title via the dataset.
  165. const labels = series.map((row) => {
  166. const d = new Date(row.day + 'T00:00:00Z');
  167. return isNaN(d.getTime()) ? row.day : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  168. });
  169. const data = series.map((row) => row.count || 0);
  170. const t = chartTheme();
  171. new Chart(canvas, {
  172. type: 'line',
  173. data: {
  174. labels,
  175. datasets: [{
  176. label: 'bans',
  177. data,
  178. borderColor: '#ef4444',
  179. backgroundColor: 'rgba(239,68,68,0.18)',
  180. tension: 0.3,
  181. fill: true,
  182. pointRadius: 3,
  183. pointHoverRadius: 5,
  184. pointBackgroundColor: '#ef4444',
  185. }],
  186. },
  187. options: {
  188. responsive: true,
  189. maintainAspectRatio: false,
  190. plugins: {
  191. legend: { display: false },
  192. tooltip: {
  193. callbacks: {
  194. title: (items) => (items[0] && series[items[0].dataIndex]?.day) || '',
  195. },
  196. },
  197. },
  198. scales: {
  199. x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  200. y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
  201. },
  202. },
  203. });
  204. }
  205. document.addEventListener('DOMContentLoaded', () => {
  206. renderReportsChart();
  207. renderPieChart('top-reporters-chart');
  208. renderPieChart('top-categories-chart');
  209. renderBansTrendChart();
  210. });
  211. // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
  212. // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
  213. // string as a no-JS fallback. This pass replaces it with the user's
  214. // browser locale formatting, with an optional configured fallback (set
  215. // via UI_LOCALE on the html data attribute) appended so browser locale
  216. // wins but a deployment can still ensure something sensible if the
  217. // browser's preference isn't supported. `data-irdb-dt-format="date"`
  218. // switches to date-only output.
  219. function getDateLocales() {
  220. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  221. const locales = [];
  222. if (typeof navigator !== 'undefined' && navigator.language) {
  223. locales.push(navigator.language);
  224. }
  225. if (fallback && fallback.trim()) {
  226. locales.push(fallback.trim());
  227. }
  228. return locales.length > 0 ? locales : undefined;
  229. }
  230. function buildDateFormatters() {
  231. const locales = getDateLocales();
  232. return {
  233. datetime: new Intl.DateTimeFormat(locales, {
  234. year: 'numeric', month: '2-digit', day: '2-digit',
  235. hour: '2-digit', minute: '2-digit', second: '2-digit',
  236. }),
  237. date: new Intl.DateTimeFormat(locales, {
  238. year: 'numeric', month: '2-digit', day: '2-digit',
  239. }),
  240. };
  241. }
  242. function formatTimes(root) {
  243. const scope = root && root.querySelectorAll ? root : document;
  244. const formatters = buildDateFormatters();
  245. const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
  246. elements.forEach((el) => {
  247. const iso = el.getAttribute('datetime');
  248. if (!iso) return;
  249. const d = new Date(iso);
  250. if (isNaN(d.getTime())) return;
  251. const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
  252. try {
  253. el.textContent = fmt.format(d);
  254. if (!el.hasAttribute('title')) {
  255. el.setAttribute('title', iso);
  256. }
  257. } catch (e) {
  258. /* leave the ISO fallback in place */
  259. }
  260. });
  261. }
  262. document.addEventListener('DOMContentLoaded', () => formatTimes(document));
  263. document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
  264. // Sortable tables. Tables marked with `data-sortable-table="<id>"` get
  265. // click-to-sort headers. Sort state lives in sessionStorage under
  266. // `irdb-sort:<id>` and is wiped on the login page so logging out
  267. // forgets it. The comparator is selected by the th's `data-sort-type`
  268. // (`string` | `number` | `date`); cells may override the displayed
  269. // text via `data-sort-value`.
  270. const SORT_KEY_PREFIX = 'irdb-sort:';
  271. function readSortState(tableId) {
  272. try {
  273. const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
  274. if (!raw) return null;
  275. const parsed = JSON.parse(raw);
  276. if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
  277. return parsed;
  278. }
  279. } catch (e) { /* ignore */ }
  280. return null;
  281. }
  282. function writeSortState(tableId, state) {
  283. try {
  284. if (state) {
  285. sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
  286. } else {
  287. sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
  288. }
  289. } catch (e) { /* ignore */ }
  290. }
  291. function clearAllSortState() {
  292. try {
  293. const keys = [];
  294. for (let i = 0; i < sessionStorage.length; i++) {
  295. const k = sessionStorage.key(i);
  296. if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
  297. }
  298. keys.forEach((k) => sessionStorage.removeItem(k));
  299. } catch (e) { /* ignore */ }
  300. }
  301. function cellSortValue(cell, type) {
  302. if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
  303. const explicit = cell.getAttribute('data-sort-value');
  304. if (explicit !== null) return explicit;
  305. // <time datetime="..."> is the canonical date carrier.
  306. const timeEl = cell.querySelector('time[datetime]');
  307. if (timeEl) return timeEl.getAttribute('datetime') || '';
  308. return (cell.textContent || '').trim();
  309. }
  310. function compareValues(a, b, type) {
  311. if (type === 'number') {
  312. const na = parseFloat(a);
  313. const nb = parseFloat(b);
  314. const aNaN = Number.isNaN(na);
  315. const bNaN = Number.isNaN(nb);
  316. if (aNaN && bNaN) return 0;
  317. if (aNaN) return 1; // empty/— goes last on asc
  318. if (bNaN) return -1;
  319. return na - nb;
  320. }
  321. if (type === 'date') {
  322. const ta = a ? Date.parse(a) : NaN;
  323. const tb = b ? Date.parse(b) : NaN;
  324. const aNaN = Number.isNaN(ta);
  325. const bNaN = Number.isNaN(tb);
  326. if (aNaN && bNaN) return 0;
  327. if (aNaN) return 1;
  328. if (bNaN) return -1;
  329. return ta - tb;
  330. }
  331. // string
  332. return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
  333. }
  334. function findSortableThs(table) {
  335. // Only <th> in the *first* thead row (avoid scanning nested tables
  336. // — the score-chart has its own SVG layout, not a sortable table).
  337. const head = table.tHead;
  338. if (!head || !head.rows.length) return [];
  339. return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
  340. }
  341. function applySort(table, key, dir) {
  342. const ths = findSortableThs(table);
  343. const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
  344. if (!th) return;
  345. const colIndex = th.cellIndex;
  346. const type = th.getAttribute('data-sort-type') || 'string';
  347. const tbody = table.tBodies[0];
  348. if (!tbody) return;
  349. // Treat each contiguous run of rows with the same `data-sort-row-group`
  350. // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
  351. // expandable rows (e.g. the audit log payload toggle) stay glued to
  352. // their parent. By default, every row sorts independently.
  353. const allRows = Array.from(tbody.rows);
  354. const groups = [];
  355. let current = null;
  356. for (const row of allRows) {
  357. if (row.hasAttribute('data-sort-row-detail') && current) {
  358. current.push(row);
  359. continue;
  360. }
  361. current = [row];
  362. groups.push(current);
  363. }
  364. groups.sort((ga, gb) => {
  365. const va = cellSortValue(ga[0].cells[colIndex], type);
  366. const vb = cellSortValue(gb[0].cells[colIndex], type);
  367. const cmp = compareValues(va, vb, type);
  368. return dir === 'desc' ? -cmp : cmp;
  369. });
  370. const frag = document.createDocumentFragment();
  371. groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
  372. tbody.appendChild(frag);
  373. // Update indicators.
  374. ths.forEach((t) => {
  375. const ind = t.querySelector('.sort-indicator');
  376. const isActive = t === th;
  377. if (ind) {
  378. ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
  379. ind.classList.toggle('text-slate-300', !isActive);
  380. ind.classList.toggle('dark:text-slate-600', !isActive);
  381. ind.classList.toggle('text-indigo-600', isActive);
  382. ind.classList.toggle('dark:text-indigo-400', isActive);
  383. }
  384. t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
  385. });
  386. }
  387. function initSortableTable(table) {
  388. if (!table || table.dataset.sortableInit === '1') return;
  389. table.dataset.sortableInit = '1';
  390. const tableId = table.getAttribute('data-sortable-table');
  391. if (!tableId) return;
  392. const ths = findSortableThs(table);
  393. if (ths.length === 0) return;
  394. ths.forEach((th) => {
  395. th.classList.add('cursor-pointer', 'select-none');
  396. th.addEventListener('click', () => {
  397. const key = th.getAttribute('data-sort-key');
  398. const current = readSortState(tableId);
  399. const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
  400. writeSortState(tableId, { key, dir });
  401. applySort(table, key, dir);
  402. });
  403. });
  404. const initial = readSortState(tableId);
  405. if (initial) {
  406. applySort(table, initial.key, initial.dir);
  407. }
  408. }
  409. function initSortableTables(root) {
  410. const scope = root && root.querySelectorAll ? root : document;
  411. scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
  412. }
  413. document.addEventListener('DOMContentLoaded', () => {
  414. // The login page is the canonical "logged out" landing spot —
  415. // wipe any sort state left over from the previous session.
  416. if (window.location && window.location.pathname === '/login') {
  417. clearAllSortState();
  418. }
  419. initSortableTables(document);
  420. });
  421. document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
  422. window.Alpine = Alpine;
  423. Alpine.start();