mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 08:52:00 +00:00
- 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.
753 lines
27 KiB
JavaScript
753 lines
27 KiB
JavaScript
/**
|
|
* 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; }
|
|
}
|