edit.twig 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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. IPs grouped by max score across categories in steps of 5; vertical lines mark this policy's thresholds.
  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 THRESHOLD_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. return {
  215. empty: false,
  216. chart: null,
  217. async load() {
  218. try {
  219. const res = await fetch('/app/policies/' + id + '/score-distribution-proxy', { credentials: 'same-origin' });
  220. if (!res.ok) throw new Error('distribution ' + res.status);
  221. const data = await res.json();
  222. this.render(data);
  223. } catch (e) {
  224. this.empty = true;
  225. if (this.chart) { this.chart.destroy(); this.chart = null; }
  226. }
  227. },
  228. render(data) {
  229. const buckets = Array.isArray(data.buckets) ? data.buckets : [];
  230. const bucketSize = Number(data.bucket_size) || 5;
  231. const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
  232. const maxScore = Number(data.max_score) || 0;
  233. // Pad the histogram with zero-count buckets through the highest
  234. // observed bucket OR the highest threshold so the threshold
  235. // lines are always visible on the X axis.
  236. let upperX = maxScore;
  237. for (const t of thresholds) {
  238. if (typeof t.threshold === 'number' && t.threshold > upperX) {
  239. upperX = t.threshold;
  240. }
  241. }
  242. if (buckets.length > 0) {
  243. const lastStart = buckets[buckets.length - 1].start;
  244. if (lastStart + bucketSize > upperX) {
  245. upperX = lastStart + bucketSize;
  246. }
  247. }
  248. const points = buckets.map((b) => ({ x: Number(b.start) || 0, y: Number(b.count) || 0 }));
  249. this.empty = points.length === 0;
  250. const maxCount = points.reduce((m, p) => p.y > m ? p.y : m, 0);
  251. const yMax = maxCount > 0 ? maxCount : 1;
  252. const datasets = [{
  253. label: 'IPs',
  254. data: points,
  255. borderColor: '#6366f1',
  256. backgroundColor: 'rgba(99,102,241,0.18)',
  257. tension: 0.25,
  258. fill: true,
  259. pointRadius: 3,
  260. pointHoverRadius: 5,
  261. pointBackgroundColor: '#6366f1',
  262. stepped: false,
  263. order: 2,
  264. }];
  265. thresholds.forEach((t, i) => {
  266. if (typeof t.threshold !== 'number') return;
  267. const color = THRESHOLD_COLORS[i % THRESHOLD_COLORS.length];
  268. datasets.push({
  269. label: 'threshold ' + (t.category_slug || ('#' + t.category_id)),
  270. data: [
  271. { x: t.threshold, y: 0 },
  272. { x: t.threshold, y: yMax },
  273. ],
  274. borderColor: color,
  275. backgroundColor: color,
  276. borderWidth: 2,
  277. borderDash: [6, 4],
  278. pointRadius: 0,
  279. pointHoverRadius: 0,
  280. fill: false,
  281. tension: 0,
  282. order: 1,
  283. });
  284. });
  285. const t = chartTheme();
  286. const canvas = this.$refs.canvas;
  287. if (!canvas) return;
  288. if (this.chart) {
  289. this.chart.destroy();
  290. }
  291. // Chart is registered globally by app.js; if it isn't yet
  292. // (script ordering), bail silently.
  293. if (typeof window.Chart === 'undefined' && typeof Chart === 'undefined') {
  294. return;
  295. }
  296. const C = (typeof window.Chart !== 'undefined') ? window.Chart : Chart;
  297. this.chart = new C(canvas, {
  298. type: 'line',
  299. data: { datasets },
  300. options: {
  301. responsive: true,
  302. maintainAspectRatio: false,
  303. parsing: false,
  304. plugins: {
  305. legend: {
  306. position: 'bottom',
  307. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  308. },
  309. tooltip: {
  310. callbacks: {
  311. title: (items) => {
  312. if (!items.length) return '';
  313. const it = items[0];
  314. if (it.dataset.label === 'IPs') {
  315. const start = it.parsed.x;
  316. return 'score ' + start + '–' + (start + bucketSize);
  317. }
  318. return it.dataset.label + ' = ' + it.parsed.x;
  319. },
  320. label: (it) => it.dataset.label === 'IPs'
  321. ? (it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'))
  322. : '',
  323. },
  324. },
  325. },
  326. scales: {
  327. x: {
  328. type: 'linear',
  329. min: 0,
  330. max: upperX > 0 ? upperX : bucketSize,
  331. title: { display: true, text: 'score', color: t.tickColor },
  332. ticks: { color: t.tickColor, stepSize: bucketSize },
  333. grid: { color: t.gridColor },
  334. },
  335. y: {
  336. beginAtZero: true,
  337. title: { display: true, text: 'IPs', color: t.tickColor },
  338. ticks: { color: t.tickColor, precision: 0 },
  339. grid: { color: t.gridColor },
  340. },
  341. },
  342. },
  343. });
  344. },
  345. };
  346. };
  347. </script>
  348. {% endblock %}