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

528
web/js/pages/bifrost.js Normal file
View File

@@ -0,0 +1,528 @@
/**
* Bifrost — Pwnagotchi Mode SPA page
* Real-time WiFi recon dashboard with face, mood, activity feed, networks, plugins.
*/
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 = 'bifrost';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let poller = null;
let root = null;
let bifrostEnabled = false;
let status = {};
let stats = {};
let networks = [];
let activity = [];
let plugins = [];
let epochs = [];
let sideTab = 'networks'; // 'networks' | 'plugins' | 'history'
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await refresh();
poller = new Poller(refresh, 4000);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
networks = [];
activity = [];
plugins = [];
epochs = [];
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'bifrost-page' }, [
/* ── Header ───────────────────────────────────────── */
el('div', { class: 'bifrost-header' }, [
el('h1', { class: 'bifrost-title' }, [
el('span', { class: 'bifrost-title-icon' }, ['🌈']),
el('span', { 'data-i18n': 'bifrost.title' }, [t('bifrost.title')]),
]),
el('div', { class: 'bifrost-controls' }, [
el('button', { class: 'bifrost-btn', id: 'bifrost-toggle' }, [
el('span', { class: 'dot' }),
el('span', { class: 'bifrost-toggle-label', 'data-i18n': 'bifrost.disabled' }, [t('bifrost.disabled')]),
]),
el('button', { class: 'bifrost-btn', id: 'bifrost-mode' }, [
el('span', { class: 'bifrost-mode-label' }, ['Auto']),
]),
]),
]),
/* ── Stats bar ────────────────────────────────────── */
el('div', { class: 'bifrost-stats', id: 'bifrost-stats' }),
/* ── Main grid ────────────────────────────────────── */
el('div', { class: 'bifrost-grid' }, [
/* Left: Live view */
el('div', { class: 'bifrost-panel bifrost-live' }, [
el('div', { class: 'bifrost-face-wrap' }, [
el('div', { class: 'bifrost-face', id: 'bifrost-face' }, ['(. .)']),
el('div', { class: 'bifrost-mood', id: 'bifrost-mood' }, ['sleeping']),
el('div', { class: 'bifrost-voice', id: 'bifrost-voice' }),
]),
el('div', { class: 'bifrost-info-row', id: 'bifrost-info' }),
el('div', { class: 'bifrost-panel-head' }, [
el('span', { 'data-i18n': 'bifrost.activityFeed' }, [t('bifrost.activityFeed')]),
el('button', {
class: 'bifrost-btn', id: 'bifrost-clear-activity',
style: 'padding:3px 8px;font-size:0.65rem',
}, [t('bifrost.clearActivity')]),
]),
el('div', { class: 'bifrost-activity', id: 'bifrost-activity' }),
]),
/* Right: sidebar */
el('div', { class: 'bifrost-panel' }, [
el('div', { class: 'bifrost-side-tabs' }, [
sideTabBtn('networks', t('bifrost.networks')),
sideTabBtn('plugins', t('bifrost.plugins')),
sideTabBtn('history', t('bifrost.history')),
]),
el('div', { class: 'bifrost-sidebar', id: 'bifrost-sidebar' }),
]),
]),
]);
}
function sideTabBtn(id, label) {
return el('button', {
class: `bifrost-side-tab${sideTab === id ? ' active' : ''}`,
'data-btab': id,
}, [label]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
root.addEventListener('click', async (e) => {
// Toggle enable/disable — BIFROST is a 4th exclusive mode
const toggle = e.target.closest('#bifrost-toggle');
if (toggle) {
const willEnable = !bifrostEnabled;
// Warn user: enabling puts WiFi in monitor mode, kills network
if (willEnable && !confirmT(t('bifrost.confirmEnable'))) return;
try {
const res = await api.post('/api/bifrost/toggle', { enabled: willEnable });
bifrostEnabled = res.enabled;
paintToggle();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Toggle mode
const modeBtn = e.target.closest('#bifrost-mode');
if (modeBtn) {
const newMode = status.mode === 'auto' ? 'manual' : 'auto';
try {
await api.post('/api/bifrost/mode', { mode: newMode });
status.mode = newMode;
paintMode();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Clear activity
if (e.target.closest('#bifrost-clear-activity')) {
try {
await api.post('/api/bifrost/activity/clear', {});
toast(t('bifrost.activityCleared'), 2000, 'success');
activity = [];
paintActivity();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Side tab switch
const stab = e.target.closest('[data-btab]');
if (stab) {
sideTab = stab.dataset.btab;
$$('.bifrost-side-tab', root).forEach(b =>
b.classList.toggle('active', b.dataset.btab === sideTab));
paintSidebar();
return;
}
// Plugin toggle
const pluginToggle = e.target.closest('[data-plugin-toggle]');
if (pluginToggle) {
const name = pluginToggle.dataset.pluginToggle;
const plugin = plugins.find(p => p.name === name);
if (plugin) {
try {
await api.post('/api/bifrost/plugin/toggle', { name, enabled: !plugin.enabled });
await refreshPlugins();
} catch (err) { toast(err.message, 3000, 'error'); }
}
return;
}
});
}
/* ── Data refresh ──────────────────────────────────────── */
async function refresh() {
try {
const [statusData, statsData, actData] = await Promise.all([
api.get('/api/bifrost/status'),
api.get('/api/bifrost/stats'),
api.get('/api/bifrost/activity?limit=50'),
]);
status = statusData || {};
stats = statsData || {};
bifrostEnabled = status.enabled || false;
activity = actData.activity || [];
paint();
} catch (err) {
console.warn('[bifrost] refresh error:', err.message);
}
// Lazy-load sidebar data
if (sideTab === 'networks') refreshNetworks();
else if (sideTab === 'plugins') refreshPlugins();
else if (sideTab === 'history') refreshEpochs();
}
async function refreshNetworks() {
try {
const data = await api.get('/api/bifrost/networks');
networks = data.networks || [];
paintSidebar();
} catch (err) { console.warn('[bifrost] networks error:', err.message); }
}
async function refreshPlugins() {
try {
const data = await api.get('/api/bifrost/plugins');
plugins = data.plugins || [];
paintSidebar();
} catch (err) { console.warn('[bifrost] plugins error:', err.message); }
}
async function refreshEpochs() {
try {
const data = await api.get('/api/bifrost/epochs');
epochs = data.epochs || [];
paintSidebar();
} catch (err) { console.warn('[bifrost] epochs error:', err.message); }
}
/* ── Paint ─────────────────────────────────────────────── */
function paint() {
paintToggle();
paintMode();
paintStats();
paintFace();
paintInfo();
paintActivity();
paintSidebar();
}
function paintToggle() {
const btn = $('#bifrost-toggle', root);
if (!btn) return;
btn.classList.toggle('active', bifrostEnabled);
const lbl = $('.bifrost-toggle-label', btn);
if (lbl) {
const key = bifrostEnabled ? 'bifrost.enabled' : 'bifrost.disabled';
lbl.textContent = t(key);
lbl.setAttribute('data-i18n', key);
}
}
function paintMode() {
const lbl = $('.bifrost-mode-label', root);
if (lbl) {
const mode = status.mode || 'auto';
lbl.textContent = mode === 'auto' ? 'Auto' : 'Manual';
}
}
function paintStats() {
const container = $('#bifrost-stats', root);
if (!container) return;
const items = [
{ val: stats.total_networks || 0, lbl: t('bifrost.statNetworks') },
{ val: stats.total_handshakes || 0, lbl: t('bifrost.statHandshakes') },
{ val: stats.total_deauths || 0, lbl: t('bifrost.statDeauths') },
{ val: stats.total_assocs || 0, lbl: t('bifrost.statAssocs') },
{ val: stats.total_epochs || 0, lbl: t('bifrost.statEpochs') },
{ val: stats.total_peers || 0, lbl: t('bifrost.statPeers') },
];
empty(container);
for (const s of items) {
container.appendChild(
el('div', { class: 'bifrost-stat' }, [
el('div', { class: 'bifrost-stat-val' }, [String(s.val)]),
el('div', { class: 'bifrost-stat-lbl' }, [s.lbl]),
])
);
}
}
function paintFace() {
const faceEl = $('#bifrost-face', root);
const moodEl = $('#bifrost-mood', root);
const voiceEl = $('#bifrost-voice', root);
if (faceEl) {
if (status.monitor_failed) {
faceEl.textContent = '(X_X)';
faceEl.className = 'bifrost-face mood-angry';
} else {
faceEl.textContent = status.face || '(. .)';
faceEl.className = 'bifrost-face';
if (status.mood) faceEl.classList.add('mood-' + status.mood);
}
}
if (moodEl) {
if (status.monitor_failed) {
moodEl.textContent = t('bifrost.monitorFailed');
moodEl.className = 'bifrost-mood mood-badge-angry';
} else {
const mood = status.mood || 'sleeping';
moodEl.textContent = mood;
moodEl.className = 'bifrost-mood mood-badge-' + mood;
}
}
if (voiceEl) {
if (status.monitor_failed) {
voiceEl.textContent = t('bifrost.monitorFailedHint');
} else {
voiceEl.textContent = status.voice || '';
}
}
}
function paintInfo() {
const container = $('#bifrost-info', root);
if (!container) return;
empty(container);
const items = [
{ label: 'Ch', value: status.channel || 0 },
{ label: 'APs', value: status.num_aps || 0 },
{ label: '🤝', value: status.num_handshakes || 0 },
{ label: '⏱', value: formatUptime(status.uptime || 0) },
{ label: 'Ep', value: status.epoch || 0 },
];
for (const item of items) {
container.appendChild(
el('span', { class: 'bifrost-info-chip' }, [
el('span', { class: 'bifrost-info-label' }, [item.label]),
el('span', { class: 'bifrost-info-value' }, [String(item.value)]),
])
);
}
if (status.last_pwnd) {
container.appendChild(
el('span', { class: 'bifrost-info-chip pwnd' }, [
el('span', { class: 'bifrost-info-label' }, ['🏆']),
el('span', { class: 'bifrost-info-value' }, [escapeHtml(status.last_pwnd)]),
])
);
}
}
function paintActivity() {
const container = $('#bifrost-activity', root);
if (!container) return;
empty(container);
if (activity.length === 0) {
container.appendChild(
el('div', { class: 'bifrost-empty' }, [t('bifrost.noActivity')])
);
return;
}
for (const ev of activity) {
const icon = eventIcon(ev.event_type);
container.appendChild(
el('div', { class: 'bifrost-activity-item' }, [
el('span', { class: 'bifrost-act-time' }, [formatTime(ev.timestamp)]),
el('span', { class: 'bifrost-act-icon' }, [icon]),
el('span', { class: 'bifrost-act-title' }, [escapeHtml(ev.title || '')]),
ev.details ? el('span', { class: 'bifrost-act-detail' }, [escapeHtml(ev.details)]) : '',
].filter(Boolean))
);
}
}
/* ── Sidebar panels ────────────────────────────────────── */
function paintSidebar() {
const container = $('#bifrost-sidebar', root);
if (!container) return;
empty(container);
switch (sideTab) {
case 'networks': paintNetworks(container); break;
case 'plugins': paintPlugins(container); break;
case 'history': paintEpochs(container); break;
}
}
/* ── Networks ─────────────────────────────────────────── */
function paintNetworks(container) {
if (networks.length === 0) {
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noNetworks')]));
return;
}
for (const net of networks) {
const signal = signalBars(net.rssi);
const encBadge = encryptionBadge(net.encryption || 'OPEN');
container.appendChild(
el('div', { class: 'bifrost-net-row' }, [
el('div', { class: 'bifrost-net-main' }, [
el('span', { class: 'bifrost-net-signal' }, [signal]),
el('span', { class: 'bifrost-net-essid' }, [escapeHtml(net.essid || '<hidden>')]),
el('span', { class: 'bifrost-net-enc' }, [encBadge]),
]),
el('div', { class: 'bifrost-net-meta' }, [
`ch${net.channel || '?'}`,
net.rssi ? ` ${net.rssi}dB` : '',
net.clients ? ` · ${net.clients} sta` : '',
net.handshake ? ' · ✅' : '',
].join('')),
])
);
}
}
/* ── Plugins ──────────────────────────────────────────── */
function paintPlugins(container) {
if (plugins.length === 0) {
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noPlugins')]));
return;
}
for (const plug of plugins) {
container.appendChild(
el('div', { class: 'bifrost-plugin-row' }, [
el('div', { class: 'bifrost-plugin-info' }, [
el('span', { class: 'bifrost-plugin-name' }, [escapeHtml(plug.name)]),
el('span', { class: 'bifrost-plugin-desc' }, [escapeHtml(plug.description || '')]),
]),
el('button', {
class: `bifrost-btn${plug.enabled ? ' active' : ''}`,
'data-plugin-toggle': plug.name,
style: 'padding:2px 8px;font-size:0.6rem',
}, [plug.enabled ? '⏸' : '▶']),
])
);
}
}
/* ── Epoch History ────────────────────────────────────── */
function paintEpochs(container) {
if (epochs.length === 0) {
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noEpochs')]));
return;
}
const table = el('div', { class: 'bifrost-epoch-table' }, [
el('div', { class: 'bifrost-epoch-header' }, [
el('span', {}, ['#']),
el('span', {}, ['🤝']),
el('span', {}, ['💀']),
el('span', {}, ['📡']),
el('span', {}, [t('bifrost.mood')]),
el('span', {}, ['⭐']),
]),
]);
for (const ep of epochs.slice(0, 50)) {
const rewardStr = typeof ep.reward === 'number' ? ep.reward.toFixed(2) : '—';
table.appendChild(
el('div', { class: 'bifrost-epoch-row' }, [
el('span', {}, [String(ep.epoch_num ?? ep.id ?? '?')]),
el('span', {}, [String(ep.num_handshakes ?? 0)]),
el('span', {}, [String(ep.num_deauths ?? 0)]),
el('span', {}, [String(ep.num_hops ?? 0)]),
el('span', { class: `mood-badge-${ep.mood || 'sleeping'}` }, [ep.mood || '—']),
el('span', {}, [rewardStr]),
])
);
}
container.appendChild(table);
}
/* ── Helpers ───────────────────────────────────────────── */
function eventIcon(type) {
const icons = {
handshake: '🤝', deauth: '💀', association: '📡',
new_ap: '📶', channel_hop: '📻', epoch: '🔄',
plugin: '🧩', error: '❌', start: '▶️', stop: '⏹️',
};
return icons[type] || '📝';
}
function signalBars(rssi) {
if (!rssi) return '▂';
const val = Math.abs(rssi);
if (val < 50) return '▂▄▆█';
if (val < 60) return '▂▄▆';
if (val < 70) return '▂▄';
return '▂';
}
function encryptionBadge(enc) {
if (!enc || enc === 'OPEN' || enc === '') return 'OPEN';
if (enc.includes('WPA3')) return 'WPA3';
if (enc.includes('WPA2')) return 'WPA2';
if (enc.includes('WPA')) return 'WPA';
if (enc.includes('WEP')) return 'WEP';
return enc;
}
function formatUptime(secs) {
if (!secs || secs < 0) return '0s';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`;
return `${m}m`;
}
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('bifrost.justNow');
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch { return ts; }
}