Add Loki and Sentinel utility classes for web API endpoints

- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads.
- Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications.
- Both classes include error handling and JSON response formatting.
This commit is contained in:
infinition
2026-03-14 22:33:10 +01:00
parent eb20b168a6
commit aac77a3e76
525 changed files with 29400 additions and 13136 deletions

752
web/js/pages/sentinel.js Normal file
View File

@@ -0,0 +1,752 @@
/**
* Sentinel Watchdog — SPA page
* Real-time network monitoring, event feed, rules engine, device baselines.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty, toast, escapeHtml, confirmT } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'sentinel';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let poller = null;
let root = null;
let sentinelEnabled = false;
let events = [];
let rules = [];
let devices = [];
let unreadCount = 0;
let sideTab = 'rules'; // 'rules' | 'devices' | 'notifiers'
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await refresh();
poller = new Poller(refresh, 5000);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
events = [];
rules = [];
devices = [];
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'sentinel-page' }, [
/* ── Header ───────────────────────────────────────── */
el('div', { class: 'sentinel-header' }, [
el('h1', { class: 'sentinel-title' }, [
el('span', { class: 'sentinel-title-icon' }, ['🛡️']),
el('span', { 'data-i18n': 'sentinel.title' }, [t('sentinel.title')]),
]),
el('div', { class: 'sentinel-controls' }, [
el('button', { class: 'sentinel-toggle', id: 'sentinel-toggle' }, [
el('span', { class: 'dot' }),
el('span', { class: 'sentinel-toggle-label', 'data-i18n': 'sentinel.disabled' }, [t('sentinel.disabled')]),
]),
]),
]),
/* ── Stats bar ────────────────────────────────────── */
el('div', { class: 'sentinel-stats', id: 'sentinel-stats' }),
/* ── Main grid ────────────────────────────────────── */
el('div', { class: 'sentinel-grid' }, [
/* Left: event feed */
el('div', { class: 'sentinel-panel' }, [
el('div', { class: 'sentinel-panel-head' }, [
el('span', { 'data-i18n': 'sentinel.eventFeed' }, [t('sentinel.eventFeed')]),
el('div', { style: 'display:flex;gap:6px' }, [
el('button', {
class: 'sentinel-toggle', id: 'sentinel-ack-all',
style: 'padding:3px 8px;font-size:0.65rem',
}, [t('sentinel.ackAll')]),
el('button', {
class: 'sentinel-toggle', id: 'sentinel-clear',
style: 'padding:3px 8px;font-size:0.65rem',
}, [t('sentinel.clearAll')]),
]),
]),
el('div', { class: 'sentinel-panel-body', id: 'sentinel-events' }, [
el('div', { style: 'color:var(--muted);text-align:center;padding:40px 10px;font-size:0.8rem' },
[t('common.loading')]),
]),
]),
/* Right: sidebar */
el('div', { class: 'sentinel-panel' }, [
el('div', { class: 'sentinel-side-tabs' }, [
sideTabBtn('rules', t('sentinel.rules')),
sideTabBtn('devices', t('sentinel.devices')),
sideTabBtn('notifiers', t('sentinel.notifiers')),
]),
el('div', { class: 'sentinel-panel-body', id: 'sentinel-sidebar' }),
]),
]),
]);
}
function sideTabBtn(id, label) {
return el('button', {
class: `sentinel-side-tab${sideTab === id ? ' active' : ''}`,
'data-stab': id,
}, [label]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
// Toggle sentinel on/off
root.addEventListener('click', async (e) => {
const toggle = e.target.closest('#sentinel-toggle');
if (toggle) {
try {
const res = await api.post('/api/sentinel/toggle', { enabled: !sentinelEnabled });
sentinelEnabled = res.enabled;
paintToggle();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Ack all
if (e.target.closest('#sentinel-ack-all')) {
try {
await api.post('/api/sentinel/ack', { all: true });
toast(t('sentinel.allAcked'), 2000, 'success');
await refreshEvents();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Clear all
if (e.target.closest('#sentinel-clear')) {
if (!confirmT(t('sentinel.confirmClear'))) return;
try {
await api.post('/api/sentinel/clear', {});
toast(t('sentinel.eventsCleared'), 2000, 'success');
await refreshEvents();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Side tab switch
const stab = e.target.closest('[data-stab]');
if (stab) {
sideTab = stab.dataset.stab;
$$('.sentinel-side-tab', root).forEach(b =>
b.classList.toggle('active', b.dataset.stab === sideTab));
paintSidebar();
return;
}
// Ack single event
const ackBtn = e.target.closest('[data-ack]');
if (ackBtn) {
try {
await api.post('/api/sentinel/ack', { id: parseInt(ackBtn.dataset.ack) });
await refreshEvents();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Toggle rule enabled
const ruleToggle = e.target.closest('[data-rule-toggle]');
if (ruleToggle) {
const ruleId = parseInt(ruleToggle.dataset.ruleToggle);
const rule = rules.find(r => r.id === ruleId);
if (rule) {
try {
await api.post('/api/sentinel/rule', { id: ruleId, name: rule.name, trigger_type: rule.trigger_type, enabled: rule.enabled ? 0 : 1 });
await refreshRules();
} catch (err) { toast(err.message, 3000, 'error'); }
}
return;
}
// Delete rule
const ruleDel = e.target.closest('[data-rule-del]');
if (ruleDel) {
if (!confirmT(t('sentinel.confirmDeleteRule'))) return;
try {
await api.post('/api/sentinel/rule/delete', { id: parseInt(ruleDel.dataset.ruleDel) });
toast(t('sentinel.ruleDeleted'), 2000, 'success');
await refreshRules();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Add rule
if (e.target.closest('#sentinel-add-rule')) {
showRuleEditor();
return;
}
// Edit rule
const ruleEdit = e.target.closest('[data-rule-edit]');
if (ruleEdit) {
const ruleId = parseInt(ruleEdit.dataset.ruleEdit);
const rule = rules.find(r => r.id === ruleId);
if (rule) showRuleEditor(rule);
return;
}
// Save notifiers
if (e.target.closest('#sentinel-save-notifiers')) {
saveNotifiers();
return;
}
// Save device
const devSave = e.target.closest('[data-dev-save]');
if (devSave) {
saveDevice(devSave.dataset.devSave);
return;
}
});
}
/* ── Data refresh ──────────────────────────────────────── */
async function refresh() {
try {
const [statusData, eventsData, rulesData, devicesData] = await Promise.all([
api.get('/api/sentinel/status'),
api.get('/api/sentinel/events?limit=100'),
api.get('/api/sentinel/rules'),
api.get('/api/sentinel/devices'),
]);
sentinelEnabled = statusData.enabled;
events = eventsData.events || [];
unreadCount = eventsData.unread_count || 0;
rules = rulesData.rules || [];
devices = devicesData.devices || [];
paint();
} catch (err) {
console.warn('[sentinel] refresh error:', err.message);
}
}
async function refreshEvents() {
try {
const data = await api.get('/api/sentinel/events?limit=100');
events = data.events || [];
unreadCount = data.unread_count || 0;
paintStats();
paintEvents();
} catch (err) { console.warn('[sentinel] events error:', err.message); }
}
async function refreshRules() {
try {
const data = await api.get('/api/sentinel/rules');
rules = data.rules || [];
paintSidebar();
} catch (err) { console.warn('[sentinel] rules error:', err.message); }
}
/* ── Paint ─────────────────────────────────────────────── */
function paint() {
paintToggle();
paintStats();
paintEvents();
paintSidebar();
}
function paintToggle() {
const btn = $('#sentinel-toggle', root);
if (!btn) return;
btn.classList.toggle('active', sentinelEnabled);
const lbl = $('.sentinel-toggle-label', btn);
if (lbl) {
const key = sentinelEnabled ? 'sentinel.enabled' : 'sentinel.disabled';
lbl.textContent = t(key);
lbl.setAttribute('data-i18n', key);
}
}
function paintStats() {
const container = $('#sentinel-stats', root);
if (!container) return;
const alive = devices.filter(d => {
if (!d.last_seen) return false;
const diff = Date.now() - new Date(d.last_seen + 'Z').getTime();
return diff < 600000; // 10 min
}).length;
const stats = [
{ val: devices.length, lbl: t('sentinel.statDevices') },
{ val: alive, lbl: t('sentinel.statAlive') },
{ val: unreadCount, lbl: t('sentinel.statUnread') },
{ val: events.length, lbl: t('sentinel.statEvents') },
{ val: rules.filter(r => r.enabled).length, lbl: t('sentinel.statRules') },
];
empty(container);
for (const s of stats) {
container.appendChild(
el('div', { class: 'sentinel-stat' }, [
el('div', { class: 'sentinel-stat-val' }, [String(s.val)]),
el('div', { class: 'sentinel-stat-lbl' }, [s.lbl]),
])
);
}
}
function paintEvents() {
const container = $('#sentinel-events', root);
if (!container) return;
empty(container);
if (events.length === 0) {
container.appendChild(
el('div', {
style: 'color:var(--muted);text-align:center;padding:40px 10px;font-size:0.8rem'
}, [t('sentinel.noEvents')])
);
return;
}
for (const ev of events) {
const isUnread = !ev.acknowledged;
const sevClass = ev.severity === 'critical' ? ' sev-critical'
: ev.severity === 'warning' ? ' sev-warning' : '';
const card = el('div', {
class: `sentinel-event${isUnread ? ' unread' : ''}${sevClass}`,
}, [
el('div', { class: 'sentinel-event-head' }, [
el('div', { style: 'display:flex;align-items:center;flex:1;gap:6px;min-width:0' }, [
el('span', {
class: `sentinel-event-badge ${ev.event_type}`,
}, [formatEventType(ev.event_type)]),
el('span', { class: 'sentinel-event-title' }, [escapeHtml(ev.title)]),
]),
el('div', { style: 'display:flex;align-items:center;gap:6px;flex-shrink:0' }, [
el('span', { class: 'sentinel-event-time' }, [formatTime(ev.timestamp)]),
...(isUnread ? [
el('button', {
class: 'sentinel-toggle',
'data-ack': ev.id,
style: 'padding:1px 6px;font-size:0.6rem',
title: t('sentinel.acknowledge'),
}, ['✓'])
] : []),
]),
]),
el('div', { class: 'sentinel-event-body' }, [
escapeHtml(ev.details || ''),
...(ev.mac_address ? [
el('span', { style: 'margin-left:6px;opacity:0.6;font-family:monospace' },
[ev.mac_address])
] : []),
...(ev.ip_address ? [
el('span', { style: 'margin-left:4px;opacity:0.6;font-family:monospace' },
[ev.ip_address])
] : []),
]),
]);
container.appendChild(card);
}
}
/* ── Sidebar panels ────────────────────────────────────── */
function paintSidebar() {
const container = $('#sentinel-sidebar', root);
if (!container) return;
empty(container);
switch (sideTab) {
case 'rules': paintRules(container); break;
case 'devices': paintDevices(container); break;
case 'notifiers': paintNotifiers(container); break;
}
}
/* ── Rules ─────────────────────────────────────────────── */
function paintRules(container) {
// Add rule button
container.appendChild(
el('button', {
class: 'sentinel-toggle', id: 'sentinel-add-rule',
style: 'align-self:flex-start;margin-bottom:4px',
}, ['+ ' + t('sentinel.addRule')])
);
if (rules.length === 0) {
container.appendChild(
el('div', { style: 'color:var(--muted);text-align:center;padding:20px;font-size:0.75rem' },
[t('sentinel.noRules')])
);
return;
}
for (const rule of rules) {
let conditionsText = '';
try {
const conds = typeof rule.conditions === 'string' ? JSON.parse(rule.conditions) : rule.conditions;
conditionsText = Object.entries(conds || {}).map(([k, v]) => `${k}: ${v}`).join(', ');
} catch { conditionsText = ''; }
let actionsText = '';
try {
const acts = typeof rule.actions === 'string' ? JSON.parse(rule.actions) : rule.actions;
actionsText = (acts || []).join(', ');
} catch { actionsText = ''; }
container.appendChild(
el('div', { class: 'sentinel-rule' }, [
el('div', { class: 'sentinel-rule-info' }, [
el('div', { class: 'sentinel-rule-name' }, [
el('span', {
style: `color:${rule.enabled ? 'var(--acid)' : 'var(--muted)'}`,
}, [rule.enabled ? '● ' : '○ ']),
escapeHtml(rule.name),
]),
el('div', { class: 'sentinel-rule-type' }, [
rule.trigger_type,
conditionsText ? `${conditionsText}` : '',
]),
el('div', { class: 'sentinel-rule-type' }, [
`${t('sentinel.ruleLogic')}: ${rule.logic || 'AND'} · ${t('sentinel.ruleActions')}: ${actionsText}`,
]),
]),
el('div', { class: 'sentinel-rule-actions' }, [
el('button', {
class: 'sentinel-toggle',
'data-rule-toggle': rule.id,
style: 'padding:2px 6px;font-size:0.6rem',
title: rule.enabled ? t('sentinel.disable') : t('sentinel.enable'),
}, [rule.enabled ? '⏸' : '▶']),
el('button', {
class: 'sentinel-toggle',
'data-rule-edit': rule.id,
style: 'padding:2px 6px;font-size:0.6rem',
title: t('sentinel.editRule'),
}, ['✏️']),
el('button', {
class: 'sentinel-toggle',
'data-rule-del': rule.id,
style: 'padding:2px 6px;font-size:0.6rem',
title: t('sentinel.deleteRule'),
}, ['🗑']),
]),
])
);
}
}
/* ── Rule editor modal ─────────────────────────────────── */
const TRIGGER_TYPES = [
'new_device', 'device_join', 'device_leave',
'arp_spoof', 'port_change', 'mac_flood',
'rogue_dhcp', 'dns_anomaly',
];
const CONDITION_KEYS = [
'mac_contains', 'mac_not_contains',
'ip_prefix', 'ip_not_prefix',
'vendor_contains', 'min_new_devices', 'trusted_only',
];
const ACTION_TYPES = [
'notify_web', 'notify_discord', 'notify_webhook', 'notify_email',
];
function showRuleEditor(existing = null) {
const isEdit = !!existing;
let conditions = {};
let actions = ['notify_web'];
if (existing) {
try { conditions = typeof existing.conditions === 'string' ? JSON.parse(existing.conditions) : (existing.conditions || {}); } catch { conditions = {}; }
try { actions = typeof existing.actions === 'string' ? JSON.parse(existing.actions) : (existing.actions || ['notify_web']); } catch { actions = ['notify_web']; }
}
// Backdrop
const backdrop = el('div', {
class: 'sentinel-modal-backdrop',
style: 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100;display:flex;align-items:center;justify-content:center',
});
const modal = el('div', {
class: 'sentinel-modal',
style: 'background:var(--c-panel);border:1px solid var(--c-border);border-radius:12px;padding:16px;width:340px;max-width:90vw;max-height:80vh;overflow-y:auto;display:flex;flex-direction:column;gap:10px',
}, [
el('h3', { style: 'margin:0;font-size:0.95rem;color:var(--ink)' },
[isEdit ? t('sentinel.editRule') : t('sentinel.addRule')]),
labelInput(t('sentinel.ruleName'), 'rule-name', existing?.name || ''),
labelSelect(t('sentinel.triggerType'), 'rule-trigger', TRIGGER_TYPES, existing?.trigger_type || 'new_device'),
labelSelect(t('sentinel.ruleLogic'), 'rule-logic', ['AND', 'OR'], existing?.logic || 'AND'),
labelInput(t('sentinel.cooldown') + ' (s)', 'rule-cooldown', String(existing?.cooldown_s ?? 60), 'number'),
el('div', { style: 'font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px' },
[t('sentinel.conditions')]),
...CONDITION_KEYS.map(key =>
labelInput(key, `rule-cond-${key}`, conditions[key] ?? '', 'text', key === 'trusted_only' ? 'checkbox' : undefined)
),
el('div', { style: 'font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px' },
[t('sentinel.ruleActions')]),
...ACTION_TYPES.map(act =>
el('label', { style: 'display:flex;align-items:center;gap:6px;font-size:0.75rem;color:var(--ink);cursor:pointer' }, [
el('input', { type: 'checkbox', 'data-action': act, ...(actions.includes(act) ? { checked: '' } : {}) }),
act,
])
),
el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;margin-top:6px' }, [
el('button', {
class: 'sentinel-toggle', id: 'rule-cancel',
style: 'padding:5px 12px',
}, [t('sentinel.cancel')]),
el('button', {
class: 'sentinel-toggle active', id: 'rule-save',
style: 'padding:5px 12px',
}, [t('sentinel.save')]),
]),
]);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
// Close on backdrop click
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
// Cancel
$('#rule-cancel', modal).addEventListener('click', () => backdrop.remove());
// Save
$('#rule-save', modal).addEventListener('click', async () => {
const name = $('[data-field="rule-name"]', modal)?.value?.trim();
const triggerType = $('[data-field="rule-trigger"]', modal)?.value;
const logic = $('[data-field="rule-logic"]', modal)?.value;
const cooldown = parseInt($('[data-field="rule-cooldown"]', modal)?.value || '60');
if (!name) { toast(t('sentinel.nameRequired'), 2500, 'error'); return; }
// Gather conditions
const conds = {};
for (const key of CONDITION_KEYS) {
const input = $(`[data-field="rule-cond-${key}"]`, modal);
if (!input) continue;
const val = input.type === 'checkbox' ? (input.checked ? '1' : '') : input.value.trim();
if (val) conds[key] = val;
}
// Gather actions
const selectedActions = [];
$$('[data-action]', modal).forEach(cb => {
if (cb.checked) selectedActions.push(cb.dataset.action);
});
if (selectedActions.length === 0) selectedActions.push('notify_web');
const payload = {
rule: {
...(isEdit ? { id: existing.id } : {}),
name,
trigger_type: triggerType,
logic,
cooldown_s: cooldown,
conditions: conds,
actions: selectedActions,
enabled: isEdit ? existing.enabled : 1,
},
};
try {
await api.post('/api/sentinel/rule', payload);
toast(isEdit ? t('sentinel.ruleUpdated') : t('sentinel.ruleCreated'), 2000, 'success');
backdrop.remove();
await refreshRules();
} catch (err) { toast(err.message, 3000, 'error'); }
});
}
function labelInput(label, field, value, type = 'text', inputType) {
const actualType = inputType || type;
if (actualType === 'checkbox') {
return el('label', {
style: 'display:flex;align-items:center;gap:6px;font-size:0.75rem;color:var(--ink);cursor:pointer',
}, [
el('input', { type: 'checkbox', 'data-field': field, ...(value === '1' ? { checked: '' } : {}) }),
label,
]);
}
return el('div', { style: 'display:flex;flex-direction:column;gap:2px' }, [
el('label', { style: 'font-size:0.68rem;color:var(--muted);font-weight:600' }, [label]),
el('input', {
type: actualType,
'data-field': field,
value: value,
class: 'sentinel-notifier-input',
}),
]);
}
function labelSelect(label, field, options, selected) {
return el('div', { style: 'display:flex;flex-direction:column;gap:2px' }, [
el('label', { style: 'font-size:0.68rem;color:var(--muted);font-weight:600' }, [label]),
el('select', {
'data-field': field,
class: 'sentinel-notifier-input',
}, options.map(o =>
el('option', { value: o, ...(o === selected ? { selected: '' } : {}) }, [o])
)),
]);
}
/* ── Devices ───────────────────────────────────────────── */
function paintDevices(container) {
if (devices.length === 0) {
container.appendChild(
el('div', { style: 'color:var(--muted);text-align:center;padding:20px;font-size:0.75rem' },
[t('sentinel.noDevices')])
);
return;
}
for (const dev of devices) {
const mac = dev.mac_address;
container.appendChild(
el('div', { class: 'sentinel-notifier-row' }, [
el('div', { style: 'display:flex;justify-content:space-between;align-items:center' }, [
el('span', {
class: 'sentinel-rule-name',
style: 'font-family:monospace;font-size:0.75rem',
}, [mac]),
el('span', {
style: `font-size:0.6rem;padding:1px 6px;border-radius:4px;font-weight:700;${dev.trusted ? 'background:rgba(0,255,154,0.15);color:var(--acid)' : 'background:rgba(255,255,255,0.06);color:var(--muted)'}`,
}, [dev.trusted ? t('sentinel.trusted') : t('sentinel.untrusted')]),
]),
el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;align-items:center' }, [
miniInput(t('sentinel.alias'), `dev-alias-${mac}`, dev.alias || '', '80px'),
miniInput(t('sentinel.expectedIps'), `dev-ips-${mac}`, dev.expected_ips || '', '100px'),
el('label', { style: 'display:flex;align-items:center;gap:4px;font-size:0.65rem;color:var(--muted);cursor:pointer' }, [
el('input', { type: 'checkbox', 'data-field': `dev-trusted-${mac}`, ...(dev.trusted ? { checked: '' } : {}) }),
t('sentinel.trusted'),
]),
el('button', {
class: 'sentinel-toggle',
'data-dev-save': mac,
style: 'padding:2px 6px;font-size:0.6rem',
}, ['💾']),
]),
el('div', { class: 'sentinel-rule-type' }, [
`${t('sentinel.lastSeen')}: ${formatTime(dev.last_seen)}`,
dev.notes ? ` · ${dev.notes}` : '',
]),
])
);
}
}
function miniInput(placeholder, field, value, width) {
return el('input', {
type: 'text',
placeholder,
'data-field': field,
value,
class: 'sentinel-notifier-input',
style: `width:${width};padding:3px 5px;font-size:0.68rem`,
});
}
async function saveDevice(mac) {
const alias = $(`[data-field="dev-alias-${mac}"]`, root)?.value || '';
const ips = $(`[data-field="dev-ips-${mac}"]`, root)?.value || '';
const trusted = $(`[data-field="dev-trusted-${mac}"]`, root)?.checked ? 1 : 0;
try {
await api.post('/api/sentinel/device', { mac_address: mac, alias, expected_ips: ips, trusted });
toast(t('sentinel.deviceSaved'), 2000, 'success');
} catch (err) { toast(err.message, 3000, 'error'); }
}
/* ── Notifiers ─────────────────────────────────────────── */
function paintNotifiers(container) {
const fields = [
{ key: 'discord_webhook', label: t('sentinel.discordWebhook'), placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'webhook_url', label: t('sentinel.webhookUrl'), placeholder: 'https://example.com/hook' },
{ key: 'email_smtp_host', label: t('sentinel.smtpHost'), placeholder: 'smtp.gmail.com' },
{ key: 'email_smtp_port', label: t('sentinel.smtpPort'), placeholder: '587' },
{ key: 'email_username', label: t('sentinel.smtpUser'), placeholder: 'user@example.com' },
{ key: 'email_password', label: t('sentinel.smtpPass'), placeholder: '••••••••', type: 'password' },
{ key: 'email_from', label: t('sentinel.emailFrom'), placeholder: 'sentinel@bjorn.local' },
{ key: 'email_to', label: t('sentinel.emailTo'), placeholder: 'admin@example.com' },
];
for (const f of fields) {
container.appendChild(
el('div', { class: 'sentinel-notifier-row' }, [
el('label', { class: 'sentinel-notifier-label' }, [f.label]),
el('input', {
type: f.type || 'text',
'data-notifier': f.key,
placeholder: f.placeholder,
class: 'sentinel-notifier-input',
}),
])
);
}
container.appendChild(
el('button', {
class: 'sentinel-toggle active',
id: 'sentinel-save-notifiers',
style: 'align-self:flex-end;margin-top:6px;padding:5px 14px',
}, [t('sentinel.saveNotifiers')])
);
}
async function saveNotifiers() {
const notifiers = {};
$$('[data-notifier]', root).forEach(input => {
const val = input.value.trim();
if (val) notifiers[input.dataset.notifier] = val;
});
try {
await api.post('/api/sentinel/notifiers', { notifiers });
toast(t('sentinel.notifiersSaved'), 2000, 'success');
} catch (err) { toast(err.message, 3000, 'error'); }
}
/* ── Helpers ───────────────────────────────────────────── */
function formatEventType(type) {
return (type || 'unknown').replace(/_/g, ' ');
}
function formatTime(ts) {
if (!ts) return '—';
try {
const d = new Date(ts.includes('Z') || ts.includes('+') ? ts : ts + 'Z');
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60000) return t('sentinel.justNow');
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} catch { return ts; }
}