Files
Bjorn/web/js/pages/loki.js
infinition aac77a3e76 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.
2026-03-14 22:33:10 +01:00

537 lines
18 KiB
JavaScript

/**
* Loki — HID Attack Suite SPA page
* Script editor, library, job management, quick-type.
*/
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 = 'loki';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let poller = null;
let root = null;
let lokiEnabled = false;
let status = {};
let scripts = [];
let payloads = [];
let jobs = [];
let layouts = ['us'];
let currentScript = { id: null, name: '', content: '' };
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await refresh();
poller = new Poller(refreshJobs, 4000);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
scripts = [];
payloads = [];
jobs = [];
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'loki-page' }, [
/* ── Header ───────────────────────────────────────── */
el('div', { class: 'loki-header' }, [
el('h1', { class: 'loki-title' }, [
el('span', { class: 'loki-title-icon' }, ['\uD83D\uDC0D']),
el('span', { 'data-i18n': 'loki.title' }, [t('loki.title')]),
]),
el('div', { class: 'loki-controls' }, [
el('span', { 'data-i18n': 'loki.enable' }, [t('loki.enable')]),
el('input', { type: 'checkbox', class: 'loki-toggle', id: 'loki-toggle' }),
]),
]),
/* ── Status bar ───────────────────────────────────── */
el('div', { class: 'loki-status-bar', id: 'loki-status-bar' }),
/* ── Grid: editor + library ───────────────────────── */
el('div', { class: 'loki-grid', id: 'loki-grid' }, [
/* Editor column */
el('div', { class: 'loki-editor-panel' }, [
el('textarea', {
class: 'loki-editor',
id: 'loki-editor',
spellcheck: 'false',
placeholder: '// HIDScript editor\nlayout(\'us\');\ndelay(1000);\npress("GUI r");\ndelay(500);\ntype("notepad\\n");\ndelay(1000);\ntype("Hello from Loki!");',
}),
el('div', { class: 'loki-editor-toolbar' }, [
el('button', { class: 'loki-btn primary', id: 'loki-run' }, ['\u25B6 ', t('loki.run')]),
el('button', { class: 'loki-btn', id: 'loki-save' }, ['\uD83D\uDCBE ', t('loki.save')]),
el('button', { class: 'loki-btn', id: 'loki-new' }, ['\uD83D\uDCC4 ', t('loki.new')]),
el('select', { id: 'loki-layout-select' }),
]),
/* Quick type row */
el('div', { class: 'loki-quick-row' }, [
el('input', {
type: 'text', class: 'loki-quick-input', id: 'loki-quick-input',
placeholder: t('loki.quick_placeholder'),
}),
el('button', { class: 'loki-btn', id: 'loki-quick-send' }, [t('loki.quick_send')]),
]),
]),
/* Library column */
el('div', { class: 'loki-library' }, [
/* Payloads section */
el('div', { class: 'loki-library-section' }, [
el('div', { class: 'loki-library-heading', id: 'loki-payloads-heading' }, [t('loki.payloads')]),
el('ul', { class: 'loki-library-list', id: 'loki-payloads-list' }),
]),
/* Custom scripts section */
el('div', { class: 'loki-library-section' }, [
el('div', { class: 'loki-library-heading', id: 'loki-scripts-heading' }, [t('loki.custom_scripts')]),
el('ul', { class: 'loki-library-list', id: 'loki-scripts-list' }),
]),
]),
]),
/* ── Jobs panel ───────────────────────────────────── */
el('div', { class: 'loki-jobs' }, [
el('div', { class: 'loki-jobs-header' }, [
el('h3', {}, [t('loki.jobs')]),
el('button', { class: 'loki-btn', id: 'loki-clear-jobs' }, [t('loki.clear_completed')]),
]),
el('div', { id: 'loki-jobs-body' }),
]),
]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
// Toggle enable/disable
const tog = $('#loki-toggle', root);
if (tog) tog.addEventListener('change', async () => {
const enabled = tog.checked;
const res = await api.post('/api/loki/toggle', { enabled });
if (res?.status === 'ok') {
lokiEnabled = enabled;
toast(enabled ? t('loki.enabled_msg') : t('loki.disabled_msg'));
await refresh();
}
});
// Run
const runBtn = $('#loki-run', root);
if (runBtn) runBtn.addEventListener('click', runScript);
// Save
const saveBtn = $('#loki-save', root);
if (saveBtn) saveBtn.addEventListener('click', saveScript);
// New
const newBtn = $('#loki-new', root);
if (newBtn) newBtn.addEventListener('click', newScript);
// Quick type
const quickBtn = $('#loki-quick-send', root);
if (quickBtn) quickBtn.addEventListener('click', quickType);
const quickInput = $('#loki-quick-input', root);
if (quickInput) quickInput.addEventListener('keydown', e => {
if (e.key === 'Enter') quickType();
});
// Clear jobs
const clearBtn = $('#loki-clear-jobs', root);
if (clearBtn) clearBtn.addEventListener('click', async () => {
await api.post('/api/loki/jobs/clear', {});
await refreshJobs();
});
// Layout select
const layoutSel = $('#loki-layout-select', root);
if (layoutSel) layoutSel.addEventListener('change', () => {
// Layout is sent per-run, stored in editor state
});
// Tab on editor inserts two spaces
const editor = $('#loki-editor', root);
if (editor) editor.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
});
}
/* ── Data fetch ────────────────────────────────────────── */
async function refresh() {
const [sRes, scrRes, payRes, jobRes, layRes] = await Promise.all([
api.get('/api/loki/status'),
api.get('/api/loki/scripts'),
api.get('/api/loki/payloads'),
api.get('/api/loki/jobs'),
api.get('/api/loki/layouts'),
]);
if (sRes) { status = sRes; lokiEnabled = sRes.enabled; }
if (scrRes) scripts = scrRes.scripts || [];
if (payRes) payloads = payRes.payloads || [];
if (jobRes) jobs = jobRes.jobs || [];
if (layRes) layouts = layRes.layouts || ['us'];
paint();
}
async function refreshJobs() {
const [sRes, jobRes] = await Promise.all([
api.get('/api/loki/status'),
api.get('/api/loki/jobs'),
]);
if (sRes) { status = sRes; lokiEnabled = sRes.enabled; }
if (jobRes) jobs = jobRes.jobs || [];
paintStatus();
paintJobs();
}
/* ── Render ────────────────────────────────────────────── */
function paint() {
paintToggle();
paintStatus();
paintLayouts();
paintPayloads();
paintScripts();
paintJobs();
paintDisabledState();
}
function paintToggle() {
const tog = $('#loki-toggle', root);
if (tog) tog.checked = lokiEnabled;
}
function paintStatus() {
const bar = $('#loki-status-bar', root);
if (!bar) return;
empty(bar);
const running = status.running;
const gadget = status.gadget_ready;
const installed = status.gadget_installed !== false;
if (!installed) {
bar.append(
statusItem(t('loki.gadget_label'), t('loki.not_installed') || 'Not installed', false),
);
return;
}
bar.append(
statusItem(t('loki.status_label'), running ? t('loki.running') : t('loki.idle'), running),
statusItem(t('loki.gadget_label'), gadget ? t('loki.ready') : t('loki.not_ready'), gadget),
statusItem(t('loki.layout_label'), (status.layout || 'us').toUpperCase()),
statusItem(t('loki.jobs_label'), `${status.jobs_running || 0} ${t('loki.running_lc')}`),
);
}
function statusItem(label, value, dotState) {
const children = [];
if (dotState !== undefined) {
children.push(el('span', { class: `dot ${dotState ? 'on' : 'off'}` }));
}
children.push(el('span', { class: 'label' }, [label + ': ']));
children.push(el('span', { class: 'value' }, [String(value)]));
return el('span', { class: 'loki-status-item' }, children);
}
function paintLayouts() {
const sel = $('#loki-layout-select', root);
if (!sel) return;
empty(sel);
for (const lay of layouts) {
const opt = el('option', { value: lay }, [lay.toUpperCase()]);
if (lay === (status.layout || 'us')) opt.selected = true;
sel.appendChild(opt);
}
}
function paintPayloads() {
const list = $('#loki-payloads-list', root);
if (!list) return;
empty(list);
for (const p of payloads) {
const item = el('li', { class: 'loki-library-item' }, [
el('span', { class: 'name', title: p.description || '' }, [p.name]),
]);
item.addEventListener('click', () => loadPayload(p));
list.appendChild(item);
}
if (!payloads.length) {
list.appendChild(el('li', { class: 'loki-library-item' }, [
el('span', { class: 'name', style: 'color:var(--muted)' }, [t('loki.no_payloads')]),
]));
}
}
function paintScripts() {
const list = $('#loki-scripts-list', root);
if (!list) return;
empty(list);
for (const s of scripts) {
const item = el('li', {
class: `loki-library-item${currentScript.id === s.id ? ' active' : ''}`,
}, [
el('span', { class: 'name' }, [s.name]),
el('button', {
class: 'loki-btn danger',
style: 'padding:2px 6px;font-size:0.65rem;',
title: t('loki.delete'),
}, ['\u2715']),
]);
// Click name → load
item.querySelector('.name').addEventListener('click', () => loadScript(s));
// Click delete
item.querySelector('.loki-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(t('loki.confirm_delete', { name: s.name }))) return;
await api.post('/api/loki/script/delete', { id: s.id });
await refresh();
});
list.appendChild(item);
}
if (!scripts.length) {
list.appendChild(el('li', { class: 'loki-library-item' }, [
el('span', { class: 'name', style: 'color:var(--muted)' }, [t('loki.no_scripts')]),
]));
}
}
function paintJobs() {
const body = $('#loki-jobs-body', root);
if (!body) return;
empty(body);
if (!jobs.length) {
body.appendChild(el('div', { class: 'loki-jobs-empty' }, [t('loki.no_jobs')]));
return;
}
const table = el('table', { class: 'loki-jobs-table' }, [
el('thead', {}, [
el('tr', {}, [
el('th', {}, ['ID']),
el('th', {}, [t('loki.script')]),
el('th', {}, [t('loki.status_col')]),
el('th', {}, [t('loki.started')]),
el('th', {}, [t('loki.actions')]),
]),
]),
el('tbody', {}, jobs.slice(0, 20).map(j => {
const badge = el('span', { class: `loki-badge ${j.status}` }, [
statusIcon(j.status), ' ', j.status,
]);
const row = el('tr', {}, [
el('td', {}, [j.id ? j.id.substring(0, 6) : '...']),
el('td', {}, [j.script_name || '-']),
el('td', {}, [badge]),
el('td', {}, [formatTime(j.started_at)]),
el('td', {}),
]);
const actions = row.lastChild;
if (j.status === 'running') {
const cancelBtn = el('button', { class: 'loki-btn danger', style: 'padding:2px 8px;font-size:0.7rem;' }, [t('loki.cancel')]);
cancelBtn.addEventListener('click', async () => {
await api.post('/api/loki/job/cancel', { job_id: j.id });
await refreshJobs();
});
actions.appendChild(cancelBtn);
}
if (j.output) {
const outBtn = el('button', { class: 'loki-btn', style: 'padding:2px 8px;font-size:0.7rem;' }, [t('loki.output')]);
outBtn.addEventListener('click', () => {
alert(j.output || t('loki.no_output'));
});
actions.appendChild(outBtn);
}
return row;
})),
]);
body.appendChild(table);
}
function paintDisabledState() {
const grid = $('#loki-grid', root);
if (!grid) return;
const installed = status.gadget_installed !== false;
if (!installed) {
grid.classList.add('loki-disabled-overlay');
// Show install banner
let banner = $('#loki-install-banner', root);
if (!banner) {
banner = el('div', { id: 'loki-install-banner', class: 'loki-install-banner' }, [
el('p', {}, [t('loki.install_msg') || 'HID gadget not installed. Install it and reboot to enable Loki.']),
el('button', { class: 'loki-btn primary', id: 'loki-install-btn' }, [
t('loki.install_btn') || 'Install HID Gadget & Reboot',
]),
]);
grid.parentNode.insertBefore(banner, grid);
$('#loki-install-btn', root).addEventListener('click', installGadget);
}
} else if (!lokiEnabled) {
grid.classList.add('loki-disabled-overlay');
// Remove install banner if present
const banner = $('#loki-install-banner', root);
if (banner) banner.remove();
} else {
grid.classList.remove('loki-disabled-overlay');
const banner = $('#loki-install-banner', root);
if (banner) banner.remove();
}
}
async function installGadget() {
const btn = $('#loki-install-btn', root);
if (btn) { btn.disabled = true; btn.textContent = 'Installing...'; }
const res = await api.post('/api/loki/install', {});
if (res?.success) {
toast(res.message || 'Installed!');
if (res.reboot_required) {
if (confirm(t('loki.reboot_confirm') || 'HID gadget installed. Reboot now?')) {
await api.post('/api/loki/reboot', {});
toast('Rebooting...');
}
}
} else {
toast(res?.message || 'Installation failed', 'error');
if (btn) { btn.disabled = false; btn.textContent = t('loki.install_btn') || 'Install HID Gadget & Reboot'; }
}
}
/* ── Actions ───────────────────────────────────────────── */
async function runScript() {
const editor = $('#loki-editor', root);
if (!editor) return;
const content = editor.value.trim();
if (!content) { toast(t('loki.empty_script'), 'warn'); return; }
const name = currentScript.name || 'editor';
const res = await api.post('/api/loki/script/run', { content, name });
if (res?.status === 'ok') {
toast(t('loki.job_started', { id: res.job_id }));
await refreshJobs();
} else {
toast(res?.message || t('loki.run_error'), 'error');
}
}
async function saveScript() {
const editor = $('#loki-editor', root);
if (!editor) return;
const content = editor.value.trim();
if (!content) { toast(t('loki.empty_script'), 'warn'); return; }
let name = currentScript.name;
if (!name) {
name = prompt(t('loki.script_name_prompt'), 'my_script');
if (!name) return;
}
const res = await api.post('/api/loki/script/save', {
id: currentScript.id || undefined,
name,
content,
description: '',
});
if (res?.status === 'ok') {
toast(t('loki.saved'));
currentScript.name = name;
await refresh();
} else {
toast(res?.message || t('loki.save_error'), 'error');
}
}
function newScript() {
const editor = $('#loki-editor', root);
if (editor) editor.value = '';
currentScript = { id: null, name: '', content: '' };
paintScripts();
}
async function loadScript(s) {
// Fetch full content
const res = await api.get(`/api/loki/script?id=${s.id}`);
if (res?.script) {
const editor = $('#loki-editor', root);
if (editor) editor.value = res.script.content || '';
currentScript = { id: s.id, name: s.name, content: res.script.content };
paintScripts();
}
}
function loadPayload(p) {
const editor = $('#loki-editor', root);
if (editor) editor.value = p.content || '';
currentScript = { id: null, name: p.name, content: p.content };
paintScripts();
}
async function quickType() {
const input = $('#loki-quick-input', root);
if (!input) return;
const text = input.value;
if (!text) return;
const res = await api.post('/api/loki/quick', { text });
if (res?.status === 'ok') {
toast(t('loki.quick_sent'));
input.value = '';
await refreshJobs();
} else {
toast(res?.message || t('loki.quick_error'), 'error');
}
}
/* ── Helpers ───────────────────────────────────────────── */
function statusIcon(status) {
switch (status) {
case 'running': return '\u26A1';
case 'succeeded': return '\u2705';
case 'failed': return '\u274C';
case 'cancelled': return '\u23F9';
case 'pending': return '\u23F3';
default: return '\u2022';
}
}
function formatTime(iso) {
if (!iso) return '-';
try {
const d = new Date(iso);
return d.toLocaleTimeString();
} catch {
return iso;
}
}