Prechádzať zdrojové kódy

feat(ui): click-to-sort table headers, session-scoped

Adds a sort.twig macro and a small JS module that turns suitable
column headers into click-to-sort buttons across the IPs, audit,
categories, consumers, reporters, tokens, policies, manual blocks,
allowlist, settings/jobs, and search-result tables. Sort state is
stored per-table in sessionStorage and wiped on the login page,
so logging out forgets it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 týždeň pred
rodič
commit
7992816ac7

+ 176 - 0
ui/resources/js/app.js

@@ -280,5 +280,181 @@ function formatTimes(root) {
 document.addEventListener('DOMContentLoaded', () => formatTimes(document));
 document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
 
+// Sortable tables. Tables marked with `data-sortable-table="<id>"` get
+// click-to-sort headers. Sort state lives in sessionStorage under
+// `irdb-sort:<id>` and is wiped on the login page so logging out
+// forgets it. The comparator is selected by the th's `data-sort-type`
+// (`string` | `number` | `date`); cells may override the displayed
+// text via `data-sort-value`.
+const SORT_KEY_PREFIX = 'irdb-sort:';
+
+function readSortState(tableId) {
+    try {
+        const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
+        if (!raw) return null;
+        const parsed = JSON.parse(raw);
+        if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
+            return parsed;
+        }
+    } catch (e) { /* ignore */ }
+    return null;
+}
+
+function writeSortState(tableId, state) {
+    try {
+        if (state) {
+            sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
+        } else {
+            sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
+        }
+    } catch (e) { /* ignore */ }
+}
+
+function clearAllSortState() {
+    try {
+        const keys = [];
+        for (let i = 0; i < sessionStorage.length; i++) {
+            const k = sessionStorage.key(i);
+            if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
+        }
+        keys.forEach((k) => sessionStorage.removeItem(k));
+    } catch (e) { /* ignore */ }
+}
+
+function cellSortValue(cell, type) {
+    if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
+    const explicit = cell.getAttribute('data-sort-value');
+    if (explicit !== null) return explicit;
+    // <time datetime="..."> is the canonical date carrier.
+    const timeEl = cell.querySelector('time[datetime]');
+    if (timeEl) return timeEl.getAttribute('datetime') || '';
+    return (cell.textContent || '').trim();
+}
+
+function compareValues(a, b, type) {
+    if (type === 'number') {
+        const na = parseFloat(a);
+        const nb = parseFloat(b);
+        const aNaN = Number.isNaN(na);
+        const bNaN = Number.isNaN(nb);
+        if (aNaN && bNaN) return 0;
+        if (aNaN) return 1;   // empty/—  goes last on asc
+        if (bNaN) return -1;
+        return na - nb;
+    }
+    if (type === 'date') {
+        const ta = a ? Date.parse(a) : NaN;
+        const tb = b ? Date.parse(b) : NaN;
+        const aNaN = Number.isNaN(ta);
+        const bNaN = Number.isNaN(tb);
+        if (aNaN && bNaN) return 0;
+        if (aNaN) return 1;
+        if (bNaN) return -1;
+        return ta - tb;
+    }
+    // string
+    return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
+}
+
+function findSortableThs(table) {
+    // Only <th> in the *first* thead row (avoid scanning nested tables
+    // — the score-chart has its own SVG layout, not a sortable table).
+    const head = table.tHead;
+    if (!head || !head.rows.length) return [];
+    return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
+}
+
+function applySort(table, key, dir) {
+    const ths = findSortableThs(table);
+    const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
+    if (!th) return;
+    const colIndex = th.cellIndex;
+    const type = th.getAttribute('data-sort-type') || 'string';
+
+    const tbody = table.tBodies[0];
+    if (!tbody) return;
+
+    // Treat each contiguous run of rows with the same `data-sort-row-group`
+    // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
+    // expandable rows (e.g. the audit log payload toggle) stay glued to
+    // their parent. By default, every row sorts independently.
+    const allRows = Array.from(tbody.rows);
+    const groups = [];
+    let current = null;
+    for (const row of allRows) {
+        if (row.hasAttribute('data-sort-row-detail') && current) {
+            current.push(row);
+            continue;
+        }
+        current = [row];
+        groups.push(current);
+    }
+
+    groups.sort((ga, gb) => {
+        const va = cellSortValue(ga[0].cells[colIndex], type);
+        const vb = cellSortValue(gb[0].cells[colIndex], type);
+        const cmp = compareValues(va, vb, type);
+        return dir === 'desc' ? -cmp : cmp;
+    });
+
+    const frag = document.createDocumentFragment();
+    groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
+    tbody.appendChild(frag);
+
+    // Update indicators.
+    ths.forEach((t) => {
+        const ind = t.querySelector('.sort-indicator');
+        const isActive = t === th;
+        if (ind) {
+            ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
+            ind.classList.toggle('text-slate-300', !isActive);
+            ind.classList.toggle('dark:text-slate-600', !isActive);
+            ind.classList.toggle('text-indigo-600', isActive);
+            ind.classList.toggle('dark:text-indigo-400', isActive);
+        }
+        t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
+    });
+}
+
+function initSortableTable(table) {
+    if (!table || table.dataset.sortableInit === '1') return;
+    table.dataset.sortableInit = '1';
+    const tableId = table.getAttribute('data-sortable-table');
+    if (!tableId) return;
+    const ths = findSortableThs(table);
+    if (ths.length === 0) return;
+
+    ths.forEach((th) => {
+        th.classList.add('cursor-pointer', 'select-none');
+        th.addEventListener('click', () => {
+            const key = th.getAttribute('data-sort-key');
+            const current = readSortState(tableId);
+            const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
+            writeSortState(tableId, { key, dir });
+            applySort(table, key, dir);
+        });
+    });
+
+    const initial = readSortState(tableId);
+    if (initial) {
+        applySort(table, initial.key, initial.dir);
+    }
+}
+
+function initSortableTables(root) {
+    const scope = root && root.querySelectorAll ? root : document;
+    scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+    // The login page is the canonical "logged out" landing spot —
+    // wipe any sort state left over from the previous session.
+    if (window.location && window.location.pathname === '/login') {
+        clearAllSortState();
+    }
+    initSortableTables(document);
+});
+document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
+
 window.Alpine = Alpine;
 Alpine.start();

+ 10 - 9
ui/resources/views/pages/allowlist/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Allowlist — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">Allowlist</h1>
@@ -58,23 +59,23 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="allowlist-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Kind</th>
-                    <th class="px-4 py-2 font-medium">Target</th>
-                    <th class="px-4 py-2 font-medium">Reason</th>
-                    <th class="px-4 py-2 font-medium">Created</th>
+                    {{ sort.th('Kind', 'kind') }}
+                    {{ sort.th('Target', 'target') }}
+                    {{ sort.th('Reason', 'reason') }}
+                    {{ sort.th('Created', 'created', 'date') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for item in list.items|default([]) %}
                     <tr>
-                        <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
-                        <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
-                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
+                        <td class="px-4 py-2 font-mono text-xs uppercase" data-sort-value="{{ item.kind }}">{{ item.kind }}</td>
+                        <td class="px-4 py-2 font-mono" data-sort-value="{{ item.kind == 'ip' ? item.ip : item.cidr }}">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ item.reason|default('') }}">{{ item.reason|default('—') }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.created_at|default('') }}">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                         {% if can_write %}
                             <td class="px-4 py-2 text-right">
                                 {% include 'partials/confirm_form.twig' with {

+ 13 - 12
ui/resources/views/pages/audit/index.twig

@@ -19,6 +19,7 @@
 
 {% block content %}
 {% import _self as h %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-6xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
@@ -85,31 +86,31 @@
 
     {% if list %}
         <div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" x-data="{ open: null }">
-            <table class="w-full text-sm">
+            <table class="w-full text-sm" data-sortable-table="audit-index">
                 <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                     <tr>
-                        <th class="px-4 py-2 font-medium">When</th>
-                        <th class="px-4 py-2 font-medium">Actor</th>
-                        <th class="px-4 py-2 font-medium">Action</th>
-                        <th class="px-4 py-2 font-medium">Entity</th>
-                        <th class="px-4 py-2 font-medium">Source IP</th>
+                        {{ sort.th('When', 'when', 'date') }}
+                        {{ sort.th('Actor', 'actor') }}
+                        {{ sort.th('Action', 'action') }}
+                        {{ sort.th('Entity', 'entity') }}
+                        {{ sort.th('Source IP', 'source_ip') }}
                         <th class="px-4 py-2 font-medium text-right">Payload</th>
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                     {% for ev in list.items %}
                         <tr>
-                            <td class="px-4 py-2 align-top"><time class="irdb-dt font-mono text-xs text-slate-600 dark:text-slate-300" datetime="{{ ev.occurred_at }}" title="{{ ev.occurred_at }}">{{ ev.occurred_at }}</time></td>
-                            <td class="px-4 py-2 align-top text-xs">
+                            <td class="px-4 py-2 align-top" data-sort-value="{{ ev.occurred_at }}"><time class="irdb-dt font-mono text-xs text-slate-600 dark:text-slate-300" datetime="{{ ev.occurred_at }}" title="{{ ev.occurred_at }}">{{ ev.occurred_at }}</time></td>
+                            <td class="px-4 py-2 align-top text-xs" data-sort-value="{{ ev.actor_kind }} {{ ev.actor_id|default('') }}">
                                 <span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono uppercase tracking-tight text-slate-700 dark:bg-slate-800 dark:text-slate-300">{{ ev.actor_kind }}</span>
                                 {% if ev.actor_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.actor_id }}</span>{% endif %}
                             </td>
-                            <td class="px-4 py-2 align-top">{{ h.action_pill(ev.action) }}</td>
-                            <td class="px-4 py-2 align-top text-xs">
+                            <td class="px-4 py-2 align-top" data-sort-value="{{ ev.action }}">{{ h.action_pill(ev.action) }}</td>
+                            <td class="px-4 py-2 align-top text-xs" data-sort-value="{{ ev.entity_type|default('') }} {{ ev.entity_id|default('') }}">
                                 <span class="font-mono text-slate-600 dark:text-slate-300">{{ ev.entity_type|default('—') }}</span>
                                 {% if ev.entity_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.entity_id }}</span>{% endif %}
                             </td>
-                            <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">{{ ev.source_ip|default('—') }}</td>
+                            <td class="px-4 py-2 align-top font-mono text-xs text-slate-500" data-sort-value="{{ ev.source_ip|default('') }}">{{ ev.source_ip|default('—') }}</td>
                             <td class="px-4 py-2 align-top text-right">
                                 {% if ev.details %}
                                     <button type="button" x-on:click="open = (open === {{ ev.id }} ? null : {{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
@@ -119,7 +120,7 @@
                             </td>
                         </tr>
                         {% if ev.details %}
-                            <tr x-show="open === {{ ev.id }}" x-cloak>
+                            <tr x-show="open === {{ ev.id }}" x-cloak data-sort-row-detail>
                                 <td colspan="6" class="bg-slate-50 px-4 py-3 dark:bg-slate-950">
                                     <pre class="overflow-x-auto rounded bg-white p-3 text-xs dark:bg-slate-900">{{ ev.details|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
                                 </td>

+ 10 - 9
ui/resources/views/pages/categories/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Categories — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">Abuse categories</h1>
@@ -51,23 +52,23 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="categories-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Slug</th>
-                    <th class="px-4 py-2 font-medium">Name</th>
-                    <th class="px-4 py-2 font-medium">Decay</th>
-                    <th class="px-4 py-2 font-medium">Status</th>
+                    {{ sort.th('Slug', 'slug') }}
+                    {{ sort.th('Name', 'name') }}
+                    {{ sort.th('Decay', 'decay') }}
+                    {{ sort.th('Status', 'status') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for c in list.items|default([]) %}
                     <tr>
-                        <td class="px-4 py-2"><a href="/app/categories/{{ c.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ c.slug }}</a></td>
-                        <td class="px-4 py-2">{{ c.name }}</td>
-                        <td class="px-4 py-2 text-xs text-slate-500 dark:text-slate-400">{{ c.decay_function }} ({{ c.decay_param }})</td>
-                        <td class="px-4 py-2">
+                        <td class="px-4 py-2" data-sort-value="{{ c.slug }}"><a href="/app/categories/{{ c.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ c.slug }}</a></td>
+                        <td class="px-4 py-2" data-sort-value="{{ c.name }}">{{ c.name }}</td>
+                        <td class="px-4 py-2 text-xs text-slate-500 dark:text-slate-400" data-sort-value="{{ c.decay_function }} {{ c.decay_param }}">{{ c.decay_function }} ({{ c.decay_param }})</td>
+                        <td class="px-4 py-2" data-sort-value="{{ c.is_active ? 'active' : 'inactive' }}">
                             {% if c.is_active %}
                                 <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
                             {% else %}

+ 10 - 9
ui/resources/views/pages/consumers/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Consumers — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 {% set policy_name_by_id = {} %}
 {% for p in policies %}
     {% set policy_name_by_id = policy_name_by_id|merge({(p.id): p.name}) %}
@@ -47,23 +48,23 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="consumers-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Name</th>
-                    <th class="px-4 py-2 font-medium">Policy</th>
-                    <th class="px-4 py-2 font-medium">Description</th>
-                    <th class="px-4 py-2 font-medium">Status</th>
+                    {{ sort.th('Name', 'name') }}
+                    {{ sort.th('Policy', 'policy') }}
+                    {{ sort.th('Description', 'description') }}
+                    {{ sort.th('Status', 'status') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for c in list.data|default([]) %}
                     <tr>
-                        <td class="px-4 py-2"><a href="/app/consumers/{{ c.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ c.name }}</a></td>
-                        <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ policy_name_by_id[c.policy_id]|default('?') }}</td>
-                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ c.description|default('—') }}</td>
-                        <td class="px-4 py-2">
+                        <td class="px-4 py-2" data-sort-value="{{ c.name }}"><a href="/app/consumers/{{ c.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ c.name }}</a></td>
+                        <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300" data-sort-value="{{ policy_name_by_id[c.policy_id]|default('') }}">{{ policy_name_by_id[c.policy_id]|default('?') }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ c.description|default('') }}">{{ c.description|default('—') }}</td>
+                        <td class="px-4 py-2" data-sort-value="{{ c.is_active ? 'active' : 'inactive' }}">
                             {% if c.is_active %}
                                 <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
                             {% else %}

+ 16 - 15
ui/resources/views/pages/ips/index.twig

@@ -24,6 +24,7 @@
 
 {% block content %}
 {% import _self as h %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-6xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">IPs</h1>
@@ -99,28 +100,28 @@
 
     {% if list %}
         <div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-            <table class="w-full text-sm">
+            <table class="w-full text-sm" data-sortable-table="ips-index">
                 <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                     <tr>
-                        <th class="px-4 py-2 font-medium">IP</th>
-                        <th class="px-4 py-2 font-medium">Country</th>
-                        <th class="px-4 py-2 font-medium">ASN</th>
-                        <th class="px-4 py-2 font-medium">Top category</th>
-                        <th class="px-4 py-2 text-right font-medium">Max score</th>
-                        <th class="px-4 py-2 font-medium">Last report</th>
-                        <th class="px-4 py-2 font-medium">Status</th>
+                        {{ sort.th('IP', 'ip') }}
+                        {{ sort.th('Country', 'country') }}
+                        {{ sort.th('ASN', 'asn', 'number') }}
+                        {{ sort.th('Top category', 'top_category') }}
+                        {{ sort.th('Max score', 'max_score', 'number', 'right') }}
+                        {{ sort.th('Last report', 'last_report', 'date') }}
+                        {{ sort.th('Status', 'status') }}
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                     {% for item in list.items %}
                         <tr>
-                            <td class="px-4 py-2"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
-                            <td class="px-4 py-2">{{ h.flag(item.enrichment.country_code|default('')) }}</td>
-                            <td class="px-4 py-2 font-mono text-slate-500">{{ item.enrichment.asn|default('—') }}</td>
-                            <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ item.topCategory|default('—') }}</td>
-                            <td class="px-4 py-2 text-right font-mono">{{ item.maxScore|number_format(2) }}</td>
-                            <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.lastReportAt %}<time class="irdb-dt" datetime="{{ item.lastReportAt }}">{{ item.lastReportAt }}</time>{% else %}—{% endif %}</td>
-                            <td class="px-4 py-2">{{ h.status_pill(item.status) }}</td>
+                            <td class="px-4 py-2" data-sort-value="{{ item.ip }}"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
+                            <td class="px-4 py-2" data-sort-value="{{ item.enrichment.country_code|default('') }}">{{ h.flag(item.enrichment.country_code|default('')) }}</td>
+                            <td class="px-4 py-2 font-mono text-slate-500" data-sort-value="{{ item.enrichment.asn|default('') }}">{{ item.enrichment.asn|default('—') }}</td>
+                            <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300" data-sort-value="{{ item.topCategory|default('') }}">{{ item.topCategory|default('—') }}</td>
+                            <td class="px-4 py-2 text-right font-mono" data-sort-value="{{ item.maxScore }}">{{ item.maxScore|number_format(2) }}</td>
+                            <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.lastReportAt|default('') }}">{% if item.lastReportAt %}<time class="irdb-dt" datetime="{{ item.lastReportAt }}">{{ item.lastReportAt }}</time>{% else %}—{% endif %}</td>
+                            <td class="px-4 py-2" data-sort-value="{{ item.status }}">{{ h.status_pill(item.status) }}</td>
                         </tr>
                     {% else %}
                         <tr><td colspan="7" class="px-4 py-6 text-center text-slate-400">No results.</td></tr>

+ 12 - 11
ui/resources/views/pages/manual-blocks/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Manual blocks — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">
@@ -68,25 +69,25 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="manual-blocks-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Kind</th>
-                    <th class="px-4 py-2 font-medium">Target</th>
-                    <th class="px-4 py-2 font-medium">Reason</th>
-                    <th class="px-4 py-2 font-medium">Expires</th>
-                    <th class="px-4 py-2 font-medium">Created</th>
+                    {{ sort.th('Kind', 'kind') }}
+                    {{ sort.th('Target', 'target') }}
+                    {{ sort.th('Reason', 'reason') }}
+                    {{ sort.th('Expires', 'expires', 'date') }}
+                    {{ sort.th('Created', 'created', 'date') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for item in list.items|default([]) %}
                     <tr>
-                        <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
-                        <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
-                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.expires_at %}<time class="irdb-dt" datetime="{{ item.expires_at }}">{{ item.expires_at }}</time>{% else %}—{% endif %}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
+                        <td class="px-4 py-2 font-mono text-xs uppercase" data-sort-value="{{ item.kind }}">{{ item.kind }}</td>
+                        <td class="px-4 py-2 font-mono" data-sort-value="{{ item.kind == 'ip' ? item.ip : item.cidr }}">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ item.reason|default('') }}">{{ item.reason|default('—') }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.expires_at|default('') }}">{% if item.expires_at %}<time class="irdb-dt" datetime="{{ item.expires_at }}">{{ item.expires_at }}</time>{% else %}—{% endif %}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.created_at|default('') }}">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                         {% if can_write %}
                             <td class="px-4 py-2 text-right">
                                 {% include 'partials/confirm_form.twig' with {

+ 10 - 9
ui/resources/views/pages/policies/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Policies — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">Policies</h1>
@@ -36,23 +37,23 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="policies-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Name</th>
-                    <th class="px-4 py-2 font-medium">Description</th>
-                    <th class="px-4 py-2 text-right font-medium">Categories</th>
-                    <th class="px-4 py-2 font-medium">Manual blocks</th>
+                    {{ sort.th('Name', 'name') }}
+                    {{ sort.th('Description', 'description') }}
+                    {{ sort.th('Categories', 'categories', 'number', 'right') }}
+                    {{ sort.th('Manual blocks', 'manual_blocks') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for p in list.items|default([]) %}
                     <tr>
-                        <td class="px-4 py-2"><a href="/app/policies/{{ p.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ p.name }}</a></td>
-                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ p.description|default('—') }}</td>
-                        <td class="px-4 py-2 text-right font-mono">{{ p.thresholds|length }}</td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ p.include_manual_blocks ? 'yes' : 'no' }}</td>
+                        <td class="px-4 py-2" data-sort-value="{{ p.name }}"><a href="/app/policies/{{ p.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ p.name }}</a></td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ p.description|default('') }}">{{ p.description|default('—') }}</td>
+                        <td class="px-4 py-2 text-right font-mono" data-sort-value="{{ p.thresholds|length }}">{{ p.thresholds|length }}</td>
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ p.include_manual_blocks ? 'yes' : 'no' }}">{{ p.include_manual_blocks ? 'yes' : 'no' }}</td>
                         {% if can_write %}
                             <td class="px-4 py-2 text-right">
                                 <a href="/app/policies/{{ p.id }}" class="mr-2 text-xs text-indigo-600 hover:underline dark:text-indigo-400">Edit</a>

+ 10 - 9
ui/resources/views/pages/reporters/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Reporters — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">Reporters</h1>
@@ -38,23 +39,23 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="reporters-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Name</th>
-                    <th class="px-4 py-2 text-right font-medium">Trust</th>
-                    <th class="px-4 py-2 font-medium">Description</th>
-                    <th class="px-4 py-2 font-medium">Status</th>
+                    {{ sort.th('Name', 'name') }}
+                    {{ sort.th('Trust', 'trust', 'number', 'right') }}
+                    {{ sort.th('Description', 'description') }}
+                    {{ sort.th('Status', 'status') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for r in list.data|default([]) %}
                     <tr>
-                        <td class="px-4 py-2"><a href="/app/reporters/{{ r.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ r.name }}</a></td>
-                        <td class="px-4 py-2 text-right font-mono">{{ r.trust_weight|number_format(2) }}</td>
-                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ r.description|default('—') }}</td>
-                        <td class="px-4 py-2">
+                        <td class="px-4 py-2" data-sort-value="{{ r.name }}"><a href="/app/reporters/{{ r.id }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ r.name }}</a></td>
+                        <td class="px-4 py-2 text-right font-mono" data-sort-value="{{ r.trust_weight }}">{{ r.trust_weight|number_format(2) }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ r.description|default('') }}">{{ r.description|default('—') }}</td>
+                        <td class="px-4 py-2" data-sort-value="{{ r.is_active ? 'active' : 'inactive' }}">
                             {% if r.is_active %}
                                 <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
                             {% else %}

+ 32 - 31
ui/resources/views/pages/search/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Search — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-baseline justify-between gap-4">
         <h1 class="text-2xl font-semibold tracking-tight">Search</h1>
@@ -45,24 +46,24 @@
                 </div>
             {% elseif ips and ips.items|length > 0 %}
                 <div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-                    <table class="w-full text-sm">
+                    <table class="w-full text-sm" data-sortable-table="search-ips">
                         <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                             <tr>
-                                <th class="px-4 py-2 font-medium">IP</th>
-                                <th class="px-4 py-2 font-medium">Top category</th>
-                                <th class="px-4 py-2 text-right font-medium">Max score</th>
-                                <th class="px-4 py-2 font-medium">Last report</th>
-                                <th class="px-4 py-2 font-medium">Status</th>
+                                {{ sort.th('IP', 'ip') }}
+                                {{ sort.th('Top category', 'top_category') }}
+                                {{ sort.th('Max score', 'max_score', 'number', 'right') }}
+                                {{ sort.th('Last report', 'last_report', 'date') }}
+                                {{ sort.th('Status', 'status') }}
                             </tr>
                         </thead>
                         <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                             {% for item in ips.items %}
                                 <tr>
-                                    <td class="px-4 py-2"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
-                                    <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300">{{ item.topCategory|default('—') }}</td>
-                                    <td class="px-4 py-2 text-right font-mono">{{ item.maxScore|number_format(2) }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.lastReportAt %}<time class="irdb-dt" datetime="{{ item.lastReportAt }}">{{ item.lastReportAt }}</time>{% else %}—{% endif %}</td>
-                                    <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.status }}</td>
+                                    <td class="px-4 py-2" data-sort-value="{{ item.ip }}"><a href="/app/ips/{{ item.ip|url_encode }}" class="font-mono text-indigo-600 hover:underline dark:text-indigo-400">{{ item.ip }}</a></td>
+                                    <td class="px-4 py-2 font-mono text-slate-600 dark:text-slate-300" data-sort-value="{{ item.topCategory|default('') }}">{{ item.topCategory|default('—') }}</td>
+                                    <td class="px-4 py-2 text-right font-mono" data-sort-value="{{ item.maxScore }}">{{ item.maxScore|number_format(2) }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.lastReportAt|default('') }}">{% if item.lastReportAt %}<time class="irdb-dt" datetime="{{ item.lastReportAt }}">{{ item.lastReportAt }}</time>{% else %}—{% endif %}</td>
+                                    <td class="px-4 py-2 font-mono text-xs uppercase" data-sort-value="{{ item.status }}">{{ item.status }}</td>
                                 </tr>
                             {% endfor %}
                         </tbody>
@@ -95,24 +96,24 @@
                 </div>
             {% elseif manual_blocks|length > 0 %}
                 <div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-                    <table class="w-full text-sm">
+                    <table class="w-full text-sm" data-sortable-table="search-manual-blocks">
                         <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                             <tr>
-                                <th class="px-4 py-2 font-medium">Kind</th>
-                                <th class="px-4 py-2 font-medium">Target</th>
-                                <th class="px-4 py-2 font-medium">Reason</th>
-                                <th class="px-4 py-2 font-medium">Expires</th>
-                                <th class="px-4 py-2 font-medium">Created</th>
+                                {{ sort.th('Kind', 'kind') }}
+                                {{ sort.th('Target', 'target') }}
+                                {{ sort.th('Reason', 'reason') }}
+                                {{ sort.th('Expires', 'expires', 'date') }}
+                                {{ sort.th('Created', 'created', 'date') }}
                             </tr>
                         </thead>
                         <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                             {% for item in manual_blocks %}
                                 <tr>
-                                    <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
-                                    <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
-                                    <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.expires_at %}<time class="irdb-dt" datetime="{{ item.expires_at }}">{{ item.expires_at }}</time>{% else %}—{% endif %}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
+                                    <td class="px-4 py-2 font-mono text-xs uppercase" data-sort-value="{{ item.kind }}">{{ item.kind }}</td>
+                                    <td class="px-4 py-2 font-mono" data-sort-value="{{ item.kind == 'ip' ? item.ip : item.cidr }}">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                                    <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ item.reason|default('') }}">{{ item.reason|default('—') }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.expires_at|default('') }}">{% if item.expires_at %}<time class="irdb-dt" datetime="{{ item.expires_at }}">{{ item.expires_at }}</time>{% else %}—{% endif %}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.created_at|default('') }}">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                                 </tr>
                             {% endfor %}
                         </tbody>
@@ -139,22 +140,22 @@
                 </div>
             {% elseif allowlist|length > 0 %}
                 <div class="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-                    <table class="w-full text-sm">
+                    <table class="w-full text-sm" data-sortable-table="search-allowlist">
                         <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                             <tr>
-                                <th class="px-4 py-2 font-medium">Kind</th>
-                                <th class="px-4 py-2 font-medium">Target</th>
-                                <th class="px-4 py-2 font-medium">Reason</th>
-                                <th class="px-4 py-2 font-medium">Created</th>
+                                {{ sort.th('Kind', 'kind') }}
+                                {{ sort.th('Target', 'target') }}
+                                {{ sort.th('Reason', 'reason') }}
+                                {{ sort.th('Created', 'created', 'date') }}
                             </tr>
                         </thead>
                         <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                             {% for item in allowlist %}
                                 <tr>
-                                    <td class="px-4 py-2 font-mono text-xs uppercase">{{ item.kind }}</td>
-                                    <td class="px-4 py-2 font-mono">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
-                                    <td class="px-4 py-2 text-slate-600 dark:text-slate-300">{{ item.reason|default('—') }}</td>
-                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
+                                    <td class="px-4 py-2 font-mono text-xs uppercase" data-sort-value="{{ item.kind }}">{{ item.kind }}</td>
+                                    <td class="px-4 py-2 font-mono" data-sort-value="{{ item.kind == 'ip' ? item.ip : item.cidr }}">{{ item.kind == 'ip' ? item.ip : item.cidr }}</td>
+                                    <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ item.reason|default('') }}">{{ item.reason|default('—') }}</td>
+                                    <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ item.created_at|default('') }}">{% if item.created_at %}<time class="irdb-dt" datetime="{{ item.created_at }}">{{ item.created_at }}</time>{% endif %}</td>
                                 </tr>
                             {% endfor %}
                         </tbody>

+ 10 - 9
ui/resources/views/pages/settings/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Settings — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl space-y-6">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">Settings</h1>
@@ -49,26 +50,26 @@
             <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Latest run, lock state, and manual-trigger buttons. Manual triggers run synchronously — wait for the response.</p>
 
             <div class="mt-4 overflow-hidden rounded-lg border border-slate-100 dark:border-slate-800">
-                <table class="w-full text-sm">
+                <table class="w-full text-sm" data-sortable-table="settings-jobs">
                     <thead class="border-b border-slate-100 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                         <tr>
-                            <th class="px-4 py-2 font-medium">Name</th>
-                            <th class="px-4 py-2 font-medium">Last status</th>
-                            <th class="px-4 py-2 font-medium">Last finished</th>
-                            <th class="px-4 py-2 font-medium">Items</th>
+                            {{ sort.th('Name', 'name') }}
+                            {{ sort.th('Last status', 'status') }}
+                            {{ sort.th('Last finished', 'last_finished', 'date') }}
+                            {{ sort.th('Items', 'items', 'number') }}
                             <th class="px-4 py-2 text-right font-medium">Trigger</th>
                         </tr>
                     </thead>
                     <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                         {% for name, info in jobs.jobs %}
                             <tr>
-                                <td class="px-4 py-2 align-top font-mono text-xs">
+                                <td class="px-4 py-2 align-top font-mono text-xs" data-sort-value="{{ name }}">
                                     {{ name }}
                                     {% if info.overdue %}
                                         <span class="ml-1 rounded bg-red-100 px-1.5 py-0.5 text-[0.65rem] font-mono uppercase text-red-800 dark:bg-red-950 dark:text-red-300">overdue</span>
                                     {% endif %}
                                 </td>
-                                <td class="px-4 py-2 align-top">
+                                <td class="px-4 py-2 align-top" data-sort-value="{{ info.last_run.status|default('') }}">
                                     {% if info.last_run %}
                                         {% set s = info.last_run.status %}
                                         {% set classes = {
@@ -82,10 +83,10 @@
                                         <span class="text-xs text-slate-400">never run</span>
                                     {% endif %}
                                 </td>
-                                <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">
+                                <td class="px-4 py-2 align-top font-mono text-xs text-slate-500" data-sort-value="{{ info.last_run.finished_at|default('') }}">
                                     {% if info.last_run.finished_at %}<time class="irdb-dt" datetime="{{ info.last_run.finished_at }}">{{ info.last_run.finished_at }}</time>{% else %}—{% endif %}
                                 </td>
-                                <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">
+                                <td class="px-4 py-2 align-top font-mono text-xs text-slate-500" data-sort-value="{{ info.last_run.items_processed|default('') }}">
                                     {{ info.last_run.items_processed|default('—') }}
                                 </td>
                                 <td class="px-4 py-2 align-top text-right">

+ 13 - 11
ui/resources/views/pages/tokens/index.twig

@@ -3,6 +3,7 @@
 {% block title %}Tokens — IRDB{% endblock %}
 
 {% block content %}
+{% import 'partials/sort.twig' as sort %}
 <div class="mx-auto max-w-5xl">
     <div class="flex items-center justify-between">
         <h1 class="text-2xl font-semibold tracking-tight">API tokens</h1>
@@ -61,30 +62,31 @@
     {% endif %}
 
     <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
-        <table class="w-full text-sm">
+        <table class="w-full text-sm" data-sortable-table="tokens-index">
             <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                 <tr>
-                    <th class="px-4 py-2 font-medium">Kind</th>
-                    <th class="px-4 py-2 font-medium">Prefix</th>
-                    <th class="px-4 py-2 font-medium">Role / target</th>
-                    <th class="px-4 py-2 font-medium">Last used</th>
-                    <th class="px-4 py-2 font-medium">Status</th>
+                    {{ sort.th('Kind', 'kind') }}
+                    {{ sort.th('Prefix', 'prefix') }}
+                    {{ sort.th('Role / target', 'role_target') }}
+                    {{ sort.th('Last used', 'last_used', 'date') }}
+                    {{ sort.th('Status', 'status') }}
                     {% if can_write %}<th class="px-4 py-2 text-right font-medium">Actions</th>{% endif %}
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                 {% for t in list.data|default([]) %}
+                    {% set role_target_value = (t.kind == 'admin') ? ('role:' ~ (t.role|default(''))) : ((t.kind == 'reporter') ? ('reporter:' ~ t.reporter_id) : ((t.kind == 'consumer') ? ('consumer:' ~ t.consumer_id) : '')) %}
                     <tr>
-                        <td class="px-4 py-2 font-mono text-xs uppercase">{{ t.kind }}</td>
-                        <td class="px-4 py-2 font-mono">{{ t.token_prefix }}</td>
-                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300">
+                        <td class="px-4 py-2 font-mono text-xs uppercase" data-sort-value="{{ t.kind }}">{{ t.kind }}</td>
+                        <td class="px-4 py-2 font-mono" data-sort-value="{{ t.token_prefix }}">{{ t.token_prefix }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-300" data-sort-value="{{ role_target_value }}">
                             {%- if t.kind == 'admin' -%}role: <span class="font-mono">{{ t.role|default('—') }}</span>
                             {%- elseif t.kind == 'reporter' -%}reporter #{{ t.reporter_id }}
                             {%- elseif t.kind == 'consumer' -%}consumer #{{ t.consumer_id }}
                             {%- else -%}—{%- endif -%}
                         </td>
-                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400">{% if t.last_used_at %}<time class="irdb-dt" datetime="{{ t.last_used_at }}">{{ t.last_used_at }}</time>{% else %}never{% endif %}</td>
-                        <td class="px-4 py-2">
+                        <td class="px-4 py-2 text-slate-500 dark:text-slate-400" data-sort-value="{{ t.last_used_at|default('') }}">{% if t.last_used_at %}<time class="irdb-dt" datetime="{{ t.last_used_at }}">{{ t.last_used_at }}</time>{% else %}never{% endif %}</td>
+                        <td class="px-4 py-2" data-sort-value="{{ t.revoked_at ? 'revoked' : 'active' }}">
                             {% if t.revoked_at %}
                                 <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs uppercase text-slate-500 dark:bg-slate-800 dark:text-slate-400">revoked</span>
                             {% else %}

+ 17 - 0
ui/resources/views/partials/sort.twig

@@ -0,0 +1,17 @@
+{# Sortable table header cell.
+
+   Use together with `data-sortable-table="<id>"` on the wrapping <table>.
+   `type` controls the comparator: `string` (case-insensitive locale),
+   `number`, or `date` (ISO-8601). Cells in this column may set
+   `data-sort-value="<raw>"` to override the displayed text. #}
+{% macro th(label, key, type='string', align='left', extra='', th_class='px-4 py-2 font-medium') %}
+    <th class="{{ th_class }} {% if align == 'right' %}text-right{% endif %} {{ extra }}"
+        data-sort-key="{{ key }}" data-sort-type="{{ type }}">
+        <button type="button"
+                class="sort-btn group inline-flex items-baseline gap-1 uppercase tracking-wider {% if align == 'right' %}flex-row-reverse{% endif %}"
+                aria-label="Sort by {{ label }}">
+            <span>{{ label }}</span>
+            <span class="sort-indicator text-slate-300 dark:text-slate-600" aria-hidden="true">↕</span>
+        </button>
+    </th>
+{% endmacro %}