edit.twig 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. {% extends 'layout.twig' %}
  2. {% block title %}{{ policy.name }} — Policy — IRDB{% endblock %}
  3. {% block content %}
  4. {# Twig's |merge calls PHP array_merge, which renumbers integer keys; key by
  5. slug (a string) so {scanners:40, indexer:30,…} round-trips faithfully. #}
  6. {% set thresholds_by_slug = {} %}
  7. {% for t in policy.thresholds|default([]) %}
  8. {% if t.category_slug %}
  9. {% set thresholds_by_slug = thresholds_by_slug|merge({(t.category_slug): t.threshold}) %}
  10. {% endif %}
  11. {% endfor %}
  12. <div class="mx-auto max-w-5xl">
  13. <a href="/app/policies" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to policies</a>
  14. <div class="mt-3 flex items-center justify-between">
  15. <h1 class="text-2xl font-semibold tracking-tight">
  16. <span class="font-mono">{{ policy.name }}</span>
  17. </h1>
  18. {% if can_write %}
  19. {% include 'partials/confirm_form.twig' with {
  20. action: '/app/policies/' ~ policy.id ~ '/delete',
  21. label: 'Delete policy',
  22. description: 'Refused if any consumer references this policy.',
  23. } only %}
  24. {% endif %}
  25. </div>
  26. <form method="post" action="/app/policies/{{ policy.id }}" class="mt-6">
  27. <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
  28. <section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
  29. <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Metadata</h2>
  30. <div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
  31. <div>
  32. <label for="p-name" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Name</label>
  33. <input type="text" id="p-name" name="name" value="{{ policy.name }}" {% if not can_write %}readonly{% endif %}
  34. class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
  35. </div>
  36. <div class="md:col-span-2">
  37. <label for="p-desc" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Description</label>
  38. <input type="text" id="p-desc" name="description" value="{{ policy.description|default('') }}" {% if not can_write %}readonly{% endif %}
  39. class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
  40. </div>
  41. <div class="md:col-span-3">
  42. <label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
  43. <input type="checkbox" name="include_manual_blocks" value="1"
  44. {% if policy.include_manual_blocks %}checked{% endif %}
  45. {% if not can_write %}disabled{% endif %}>
  46. include manual blocks
  47. </label>
  48. </div>
  49. </div>
  50. </section>
  51. <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
  52. <div class="flex items-center justify-between">
  53. <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Threshold matrix</h2>
  54. <span class="text-xs text-slate-400">Empty value ⇒ category not in policy</span>
  55. </div>
  56. <table class="mt-3 w-full text-sm">
  57. <thead class="text-left text-xs uppercase tracking-wider text-slate-400">
  58. <tr>
  59. <th class="pb-2 font-medium">Category</th>
  60. <th class="pb-2 font-medium">Decay</th>
  61. <th class="pb-2 text-right font-medium">Threshold</th>
  62. </tr>
  63. </thead>
  64. <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
  65. {% for c in categories %}
  66. <tr>
  67. <td class="py-2"><span class="font-mono">{{ c.slug }}</span> <span class="text-slate-400">— {{ c.name }}</span></td>
  68. <td class="py-2 text-xs text-slate-500 dark:text-slate-400">{{ c.decay_function }} ({{ c.decay_param }})</td>
  69. <td class="py-2 text-right">
  70. <input type="number" step="0.01" min="0"
  71. name="thresholds[{{ c.slug }}]"
  72. value="{{ thresholds_by_slug[c.slug]|default('') }}"
  73. {% if not can_write %}readonly{% endif %}
  74. class="w-32 rounded-md border border-slate-300 bg-white px-2 py-1 text-right font-mono dark:border-slate-700 dark:bg-slate-950">
  75. </td>
  76. </tr>
  77. {% endfor %}
  78. </tbody>
  79. </table>
  80. </section>
  81. {% if can_write %}
  82. <div class="mt-6 flex justify-end">
  83. <button type="submit" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">Save policy</button>
  84. </div>
  85. {% endif %}
  86. </form>
  87. <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
  88. x-data="policyScoreDistribution({{ policy.id }})" x-init="load()">
  89. <div class="flex items-center justify-between">
  90. <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Score distribution</h2>
  91. <button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
  92. </div>
  93. <p class="mt-2 text-xs text-slate-400">
  94. One line per thresholded category, IPs grouped by score in steps of 5; the shaded area to the right of each threshold marks scores high enough to land on this policy's blocklist.
  95. </p>
  96. <div class="mt-3 h-64">
  97. <canvas x-ref="canvas"></canvas>
  98. </div>
  99. <p class="mt-2 text-xs text-slate-400" x-show="empty">No scored IPs in the database yet.</p>
  100. </section>
  101. <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
  102. x-data="policyPreview({{ policy.id }})" x-init="load()">
  103. <div class="flex items-center justify-between">
  104. <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Preview</h2>
  105. <button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
  106. </div>
  107. <p class="mt-2 text-sm">
  108. <span x-show="loading">Loading…</span>
  109. <template x-if="!loading">
  110. <span><span class="font-mono" x-text="count"></span> entries</span>
  111. </template>
  112. </p>
  113. <ul class="mt-3 max-h-60 divide-y divide-slate-100 overflow-y-auto text-xs dark:divide-slate-800">
  114. <template x-for="entry in sample" :key="entry.key">
  115. <li class="flex items-baseline justify-between gap-3 py-1">
  116. <span class="font-mono text-slate-700 dark:text-slate-300" x-text="entry.label"></span>
  117. <span class="shrink-0 text-slate-500 dark:text-slate-400" :title="entry.tooltip" x-text="entry.expiry"></span>
  118. </li>
  119. </template>
  120. </ul>
  121. <p class="mt-2 text-xs text-slate-400">
  122. Sample = first 50 entries from the rendered blocklist. Expiry for scored
  123. entries is an estimate assuming no further reports; manual entries show
  124. the configured expiry.
  125. </p>
  126. </section>
  127. </div>
  128. <script>
  129. window.policyPreview = function (id) {
  130. function localeFallback() {
  131. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  132. const locales = [];
  133. if (typeof navigator !== 'undefined' && navigator.language) {
  134. locales.push(navigator.language);
  135. }
  136. if (fallback && fallback.trim()) {
  137. locales.push(fallback.trim());
  138. }
  139. return locales.length > 0 ? locales : undefined;
  140. }
  141. let formatter;
  142. try {
  143. formatter = new Intl.DateTimeFormat(localeFallback(), {
  144. year: 'numeric', month: '2-digit', day: '2-digit',
  145. hour: '2-digit', minute: '2-digit',
  146. });
  147. } catch (e) {
  148. formatter = null;
  149. }
  150. function formatExpiry(iso) {
  151. if (!iso) return '';
  152. const d = new Date(iso);
  153. if (isNaN(d.getTime())) return iso;
  154. if (!formatter) return iso;
  155. try { return formatter.format(d); } catch (_) { return iso; }
  156. }
  157. function shapeEntry(raw, idx) {
  158. if (typeof raw === 'string') {
  159. return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
  160. }
  161. const ip = raw.ip_or_cidr || '';
  162. let expiry = '';
  163. let tooltip = '';
  164. if (raw.expires_at) {
  165. const formatted = formatExpiry(raw.expires_at);
  166. expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
  167. tooltip = (raw.expires_estimated
  168. ? 'Estimated falls-off date (assumes no further reports). ISO: '
  169. : 'Configured manual block expiry. ISO: ') + raw.expires_at;
  170. } else if (raw.reason === 'manual') {
  171. expiry = 'never';
  172. tooltip = 'Manual block has no configured expiry';
  173. } else {
  174. expiry = '—';
  175. tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
  176. }
  177. return { key: 'r' + idx + ':' + ip, label: ip, expiry: expiry, tooltip: tooltip };
  178. }
  179. return {
  180. loading: true,
  181. count: 0,
  182. sample: [],
  183. async load() {
  184. this.loading = true;
  185. try {
  186. const res = await fetch('/app/policies/' + id + '/preview-proxy', { credentials: 'same-origin' });
  187. if (!res.ok) throw new Error('preview ' + res.status);
  188. const data = await res.json();
  189. this.count = data.count || 0;
  190. const items = Array.isArray(data.sample) ? data.sample : [];
  191. this.sample = items.map(shapeEntry);
  192. } catch (e) {
  193. this.count = 0;
  194. this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
  195. } finally {
  196. this.loading = false;
  197. }
  198. },
  199. };
  200. };
  201. window.policyScoreDistribution = function (id) {
  202. const CATEGORY_COLORS = [
  203. '#ef4444', '#f97316', '#eab308', '#22c55e',
  204. '#0ea5e9', '#a855f7', '#ec4899', '#14b8a6',
  205. ];
  206. function chartTheme() {
  207. const isDark = document.documentElement.classList.contains('dark');
  208. return {
  209. tickColor: isDark ? '#94a3b8' : '#475569',
  210. gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
  211. legendColor: isDark ? '#cbd5e1' : '#334155',
  212. };
  213. }
  214. function hexToRgba(hex, alpha) {
  215. const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
  216. if (!m) return hex;
  217. return 'rgba(' + parseInt(m[1], 16) + ',' + parseInt(m[2], 16) + ',' + parseInt(m[3], 16) + ',' + alpha + ')';
  218. }
  219. return {
  220. empty: false,
  221. chart: null,
  222. async load() {
  223. try {
  224. const res = await fetch('/app/policies/' + id + '/score-distribution-proxy', { credentials: 'same-origin' });
  225. if (!res.ok) throw new Error('distribution ' + res.status);
  226. const data = await res.json();
  227. this.render(data);
  228. } catch (e) {
  229. this.empty = true;
  230. if (this.chart) { this.chart.destroy(); this.chart = null; }
  231. }
  232. },
  233. render(data) {
  234. const bucketSize = Number(data.bucket_size) || 5;
  235. const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
  236. const categories = Array.isArray(data.categories) ? data.categories : [];
  237. const overallMaxScore = Number(data.overall_max_score) || Number(data.max_score) || 0;
  238. // Index thresholds by category so we can look them up while
  239. // iterating the per-category histograms. Only categories with
  240. // a numeric threshold appear on the chart.
  241. const thresholdByCat = {};
  242. const thresholdBySlug = {};
  243. for (const t of thresholds) {
  244. if (typeof t.threshold !== 'number') continue;
  245. if (t.category_id != null) thresholdByCat[t.category_id] = t.threshold;
  246. if (t.category_slug) thresholdBySlug[t.category_slug] = t.threshold;
  247. }
  248. const categoriesById = {};
  249. for (const c of categories) {
  250. if (c.category_id != null) categoriesById[c.category_id] = c;
  251. }
  252. // Build the on-chart category list — only those with a threshold.
  253. // Iterate over thresholds first so the legend ordering matches the
  254. // policy's threshold list rather than database insertion order.
  255. const onChart = [];
  256. for (const t of thresholds) {
  257. if (typeof t.threshold !== 'number') continue;
  258. const cat = categoriesById[t.category_id];
  259. onChart.push({
  260. category_id: t.category_id,
  261. category_slug: t.category_slug || ('#' + t.category_id),
  262. threshold: t.threshold,
  263. buckets: cat && Array.isArray(cat.buckets) ? cat.buckets : [],
  264. });
  265. }
  266. this.empty = onChart.length === 0;
  267. // Compute axis bounds: extend X to the largest threshold or score
  268. // observed across the included categories. Y to the largest count.
  269. let upperX = overallMaxScore;
  270. let yMax = 0;
  271. for (const oc of onChart) {
  272. if (oc.threshold > upperX) upperX = oc.threshold;
  273. for (const b of oc.buckets) {
  274. const start = Number(b.start) || 0;
  275. const cnt = Number(b.count) || 0;
  276. if (start + bucketSize > upperX) upperX = start + bucketSize;
  277. if (cnt > yMax) yMax = cnt;
  278. }
  279. }
  280. if (yMax <= 0) yMax = 1;
  281. if (upperX <= 0) upperX = bucketSize;
  282. // Pad by one bucket so the rightmost shaded area has somewhere to
  283. // extend into when a threshold sits exactly at the data maximum.
  284. upperX = upperX + bucketSize;
  285. const datasets = [];
  286. onChart.forEach((oc, i) => {
  287. const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
  288. // Shaded "blocked by this category" region. Drawn first
  289. // (higher `order`) so the histogram lines render on top.
  290. datasets.push({
  291. label: oc.category_slug + ' ≥ ' + oc.threshold,
  292. data: [
  293. { x: oc.threshold, y: yMax },
  294. { x: upperX, y: yMax },
  295. ],
  296. borderColor: hexToRgba(color, 0.45),
  297. backgroundColor: hexToRgba(color, 0.10),
  298. borderWidth: 1,
  299. pointRadius: 0,
  300. pointHoverRadius: 0,
  301. fill: 'origin',
  302. tension: 0,
  303. stepped: false,
  304. spanGaps: false,
  305. order: 5 + i,
  306. _isThresholdRegion: true,
  307. });
  308. // Per-category histogram line.
  309. const points = oc.buckets.map((b) => ({
  310. x: Number(b.start) || 0,
  311. y: Number(b.count) || 0,
  312. }));
  313. datasets.push({
  314. label: oc.category_slug,
  315. data: points,
  316. borderColor: color,
  317. backgroundColor: color,
  318. borderWidth: 2,
  319. tension: 0.25,
  320. fill: false,
  321. pointRadius: 3,
  322. pointHoverRadius: 5,
  323. pointBackgroundColor: color,
  324. order: i,
  325. });
  326. });
  327. const t = chartTheme();
  328. const canvas = this.$refs.canvas;
  329. if (!canvas) return;
  330. if (this.chart) {
  331. this.chart.destroy();
  332. }
  333. // Chart is registered globally by app.js; if it isn't yet
  334. // (script ordering), bail silently.
  335. if (typeof window.Chart === 'undefined' && typeof Chart === 'undefined') {
  336. return;
  337. }
  338. const C = (typeof window.Chart !== 'undefined') ? window.Chart : Chart;
  339. this.chart = new C(canvas, {
  340. type: 'line',
  341. data: { datasets },
  342. options: {
  343. responsive: true,
  344. maintainAspectRatio: false,
  345. parsing: false,
  346. plugins: {
  347. legend: {
  348. position: 'bottom',
  349. labels: {
  350. color: t.legendColor,
  351. boxWidth: 12,
  352. font: { size: 11 },
  353. // Hide the synthetic threshold-region datasets;
  354. // their meaning is conveyed by the shading next
  355. // to the matching category line.
  356. filter: (item, chartData) => {
  357. const ds = chartData.datasets[item.datasetIndex];
  358. return !ds || !ds._isThresholdRegion;
  359. },
  360. },
  361. },
  362. tooltip: {
  363. filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
  364. callbacks: {
  365. title: (items) => {
  366. if (!items.length) return '';
  367. const it = items[0];
  368. const start = it.parsed.x;
  369. return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
  370. },
  371. label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
  372. },
  373. },
  374. },
  375. scales: {
  376. x: {
  377. type: 'linear',
  378. min: 0,
  379. max: upperX,
  380. title: { display: true, text: 'score', color: t.tickColor },
  381. ticks: { color: t.tickColor, stepSize: bucketSize },
  382. grid: { color: t.gridColor },
  383. },
  384. y: {
  385. beginAtZero: true,
  386. title: { display: true, text: 'IPs', color: t.tickColor },
  387. ticks: { color: t.tickColor, precision: 0 },
  388. grid: { color: t.gridColor },
  389. },
  390. },
  391. },
  392. });
  393. },
  394. };
  395. };
  396. </script>
  397. {% endblock %}