mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 17:01:58 +00:00
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:
528
web/js/pages/bifrost.js
Normal file
528
web/js/pages/bifrost.js
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user