Files
Alpine_5G/web/static/app.js
nearxos 160ad641ce Add web GUI, docs, scripts, and 5G router config
- Web app (Flask): status, config, firewall, logs, users, restart
- Docs: AT commands, deploy, DNS, quickstart, web GUI
- Scripts: connect, deploy, diag, healthcheck, modem-status, speedtest, status, troubleshoot
- Init and iptables: 5g-router, 5g-webgui, rules.v4
- CHANGELOG, TODO, REVISION; config and README updates
2026-02-02 09:38:23 +02:00

421 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
// Alpine 5G Router Rev: 2 (see REVISION in repo root)
const API = '/api';
const pageId = document.body.getAttribute('data-page');
if (!pageId) return;
async function api(path, opts = {}) {
const res = await fetch(API + path, {
...opts,
headers: {
'Content-Type': 'application/json',
...(opts.headers || {}),
},
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function showMsg(elId, text, type) {
const el = document.getElementById(elId);
if (!el) return;
el.textContent = text;
el.className = 'msg visible ' + (type || '');
el.classList.remove('success', 'error');
if (type) el.classList.add(type);
if (text) setTimeout(() => { el.classList.remove('visible'); }, 4000);
}
function escapeHtml(s) {
if (s == null) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// ---- Status page ----
if (pageId === 'status') {
const grid = document.getElementById('statusGrid');
const modemGrid = document.getElementById('modemGrid');
const refreshBtn = document.getElementById('refreshStatus');
const speedtest5gBtn = document.getElementById('speedtest5gBtn');
const speedtestWanBtn = document.getElementById('speedtestWanBtn');
const speedtestResult = document.getElementById('speedtestResult');
const speedtestMsg = document.getElementById('speedtestMsg');
if (!grid) return;
function statusItem(label, value) {
return '<div class="status-item"><label>' + escapeHtml(label) + '</label><div class="value">' + escapeHtml(value == null || value === '' ? '' : String(value)) + '</div></div>';
}
async function loadStatus() {
grid.innerHTML = '<span style="color: var(--text-muted);">Loading…</span>';
if (modemGrid) modemGrid.innerHTML = '';
try {
const s = await api('/status');
grid.innerHTML = [
{ label: 'Modem USB', value: s.modem_usb || '' },
{ label: 'WAN interface', value: s.wan_interface || '' },
{ label: 'WAN state', value: s.wan_state || '' },
{ label: 'WAN IP', value: s.wan_ip || '' },
{ label: 'Default route', value: s.default_route || '' },
{ label: 'AT port', value: (s.at_port || '') + ' (' + (s.at_available === 'yes' ? 'available' : 'no') + ')' },
].map(function (o) { return statusItem(o.label, o.value); }).join('');
if (modemGrid && s.modem && Object.keys(s.modem).length) {
const m = s.modem;
modemGrid.innerHTML = [
{ label: 'AT port', value: m.at_port },
{ label: 'Manufacturer', value: m.manufacturer },
{ label: 'Model', value: m.model },
{ label: 'Revision', value: m.revision },
{ label: 'IMEI', value: m.imei },
{ label: 'Signal (CSQ)', value: m.signal_csq != null ? m.signal_csq + ' (031)' : null },
{ label: '2G/3G registration', value: m.creg_status },
{ label: 'LTE/5G registration', value: m.cereg_status },
{ label: 'USB mode', value: m.usb_mode ? m.usb_mode + ' (40=RNDIS)' : null },
{ label: 'ICCID (SIM)', value: m.iccid },
{ label: 'Operator', value: m.operator },
{ label: 'Modem IP', value: m.modem_ip },
].map(function (o) { return statusItem(o.label, o.value); }).join('');
} else if (modemGrid) {
modemGrid.innerHTML = '<span style="color: var(--text-muted);">No modem AT data (check AT port).</span>';
}
} catch (e) {
grid.innerHTML = '<span class="msg error visible">' + escapeHtml(e.message) + '</span>';
if (modemGrid) modemGrid.innerHTML = '';
}
}
async function runSpeedtest(iface) {
if (!speedtestResult) return;
speedtestResult.textContent = 'Running speedtest… (may take 3060s)';
if (speedtestMsg) { speedtestMsg.textContent = ''; speedtestMsg.classList.remove('visible', 'success', 'error'); }
if (speedtest5gBtn) speedtest5gBtn.disabled = true;
if (speedtestWanBtn) speedtestWanBtn.disabled = true;
try {
const data = await api('/speedtest', { method: 'POST', body: JSON.stringify({ interface: iface }) });
const ifLabel = data.interface === '5g' ? '5G (modem)' : 'WAN';
const ifDev = data.iface ? ` (${data.iface})` : '';
let out = ifLabel + ifDev + '\n';
if (data.source_ip) out += 'Local IP: ' + data.source_ip + '\n';
if (data.public_ip) out += 'Public IP: ' + data.public_ip + '\n';
else if (data.interface) out += 'Public IP: (unknown)\n';
out += '\n';
if (data.ping) out += 'Ping: ' + data.ping + '\n';
if (data.download) out += 'Download: ' + data.download + '\n';
if (data.upload) out += 'Upload: ' + data.upload + '\n';
if (data.raw) out += '\n' + data.raw;
speedtestResult.textContent = out;
if (speedtestMsg) { showMsg('speedtestMsg', 'Speedtest done.', 'success'); }
} catch (e) {
speedtestResult.textContent = 'Error: ' + e.message;
if (speedtestMsg) showMsg('speedtestMsg', e.message, 'error');
} finally {
if (speedtest5gBtn) speedtest5gBtn.disabled = false;
if (speedtestWanBtn) speedtestWanBtn.disabled = false;
}
}
if (speedtest5gBtn) speedtest5gBtn.addEventListener('click', function () { runSpeedtest('5g'); });
if (speedtestWanBtn) speedtestWanBtn.addEventListener('click', function () { runSpeedtest('wan'); });
loadStatus();
if (refreshBtn) refreshBtn.addEventListener('click', function () { loadStatus(); });
}
// ---- Logs page ----
if (pageId === 'logs') {
const view = document.getElementById('logView');
const buttons = document.querySelectorAll('button[data-log]');
if (!view) return;
async function loadLog(which) {
view.textContent = 'Loading…';
try {
const data = await api('/logs?which=' + (which === 'speedtest' ? 'speedtest' : '5g') + '&tail=300');
view.textContent = data.lines && data.lines.length ? data.lines.join('\n') : '(empty)';
} catch (e) {
view.textContent = 'Error: ' + e.message;
}
}
buttons.forEach(btn => btn.addEventListener('click', () => loadLog(btn.getAttribute('data-log'))));
loadLog('5g');
}
// ---- Restart page ----
if (pageId === 'restart') {
const btn = document.getElementById('restart5gBtn');
if (!btn) return;
btn.addEventListener('click', async () => {
btn.disabled = true;
showMsg('restartMsg', '', '');
try {
await api('/5g/restart', { method: 'POST' });
showMsg('restartMsg', 'Restart initiated.', 'success');
} catch (e) {
showMsg('restartMsg', e.message, 'error');
} finally {
btn.disabled = false;
}
});
}
// ---- Config page ----
if (pageId === 'config') {
const editor = document.getElementById('configEditor');
const loadBtn = document.getElementById('loadConfig');
const saveBtn = document.getElementById('saveConfig');
if (!editor) return;
async function loadConfig() {
try {
const cfg = await api('/config');
editor.value = Object.entries(cfg).map(([k, v]) => k + '="' + (v || '') + '"').join('\n');
} catch (e) {
showMsg('configMsg', e.message, 'error');
}
}
saveBtn.addEventListener('click', async () => {
const raw = editor.value;
const cfg = {};
raw.split('\n').forEach(line => {
line = line.trim();
if (!line || line.startsWith('#')) return;
const eq = line.indexOf('=');
if (eq > 0) {
const k = line.slice(0, eq).trim();
let v = line.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
cfg[k] = v;
}
});
try {
await api('/config', { method: 'PUT', body: JSON.stringify(cfg) });
showMsg('configMsg', 'Saved.', 'success');
} catch (e) {
showMsg('configMsg', e.message, 'error');
}
});
loadConfig();
if (loadBtn) loadBtn.addEventListener('click', loadConfig);
}
// ---- Firewall page (SQLite) ----
if (pageId === 'firewall') {
const tableBody = document.getElementById('firewallTable');
const tableNameEl = document.getElementById('firewallTableName');
const ruleLineEl = document.getElementById('firewallRuleLine');
const addBtn = document.getElementById('firewallAddBtn');
const applyBtn = document.getElementById('firewallApplyBtn');
if (!tableBody) return;
async function loadFirewall() {
tableBody.innerHTML = '<tr><td colspan="4">Loading…</td></tr>';
try {
const data = await api('/firewall');
const rules = data.rules || [];
if (rules.length === 0) {
tableBody.innerHTML = '<tr><td colspan="4">No rules. Add one below.</td></tr>';
return;
}
tableBody.innerHTML = rules.map(r => {
const delBtn = '<button type="button" class="btn btn-danger" data-delete-id="' + r.id + '">Delete</button>';
return '<tr><td>' + escapeHtml(r.table_name) + '</td><td class="value">' + escapeHtml(r.rule_line) + '</td><td>' + (r.enabled ? 'yes' : 'no') + '</td><td>' + delBtn + '</td></tr>';
}).join('');
tableBody.querySelectorAll('[data-delete-id]').forEach(btn => {
btn.addEventListener('click', () => deleteRule(parseInt(btn.getAttribute('data-delete-id'), 10)));
});
} catch (e) {
tableBody.innerHTML = '<tr><td colspan="4" class="error">' + escapeHtml(e.message) + '</td></tr>';
}
}
async function deleteRule(id) {
if (!confirm('Delete this rule?')) return;
try {
await api('/firewall/' + id, { method: 'DELETE' });
showMsg('firewallMsg', 'Rule deleted.', 'success');
loadFirewall();
} catch (e) {
showMsg('firewallMsg', e.message, 'error');
}
}
addBtn.addEventListener('click', async () => {
const table_name = (tableNameEl && tableNameEl.value) || 'filter';
const rule_line = (ruleLineEl && ruleLineEl.value) || '';
if (!rule_line.trim()) { showMsg('firewallMsg', 'Rule line required.', 'error'); return; }
try {
await api('/firewall', { method: 'POST', body: JSON.stringify({ table_name, rule_line }) });
showMsg('firewallMsg', 'Rule added.', 'success');
if (ruleLineEl) ruleLineEl.value = '';
loadFirewall();
} catch (e) {
showMsg('firewallMsg', e.message, 'error');
}
});
applyBtn.addEventListener('click', async () => {
try {
await api('/firewall/apply', { method: 'POST' });
showMsg('firewallMsg', 'Rules applied (iptables-restore).', 'success');
} catch (e) {
showMsg('firewallMsg', e.message, 'error');
}
});
loadFirewall();
}
// ---- Routes page (SQLite) ----
if (pageId === 'routes') {
const tableBody = document.getElementById('routesTable');
const liveEl = document.getElementById('routesLive');
const destEl = document.getElementById('routeDest');
const gwEl = document.getElementById('routeGw');
const devEl = document.getElementById('routeDev');
const metricEl = document.getElementById('routeMetric');
const addBtn = document.getElementById('routeAddBtn');
const applyBtn = document.getElementById('routesApplyBtn');
const refreshLiveBtn = document.getElementById('routesRefreshLive');
if (!tableBody) return;
async function loadRoutes() {
tableBody.innerHTML = '<tr><td colspan="6">Loading…</td></tr>';
try {
const data = await api('/routes');
const routes = data.routes || [];
if (routes.length === 0) {
tableBody.innerHTML = '<tr><td colspan="6">No routes. Add one below.</td></tr>';
return;
}
tableBody.innerHTML = routes.map(r => {
const delBtn = '<button type="button" class="btn btn-danger" data-delete-id="' + r.id + '">Delete</button>';
return '<tr><td>' + escapeHtml(r.destination) + '</td><td>' + escapeHtml(r.gateway) + '</td><td>' + escapeHtml(r.dev) + '</td><td>' + (r.metric != null ? r.metric : '') + '</td><td>' + (r.enabled ? 'yes' : 'no') + '</td><td>' + delBtn + '</td></tr>';
}).join('');
tableBody.querySelectorAll('[data-delete-id]').forEach(btn => {
btn.addEventListener('click', () => deleteRoute(parseInt(btn.getAttribute('data-delete-id'), 10)));
});
} catch (e) {
tableBody.innerHTML = '<tr><td colspan="6" class="error">' + escapeHtml(e.message) + '</td></tr>';
}
}
async function loadLive() {
if (!liveEl) return;
liveEl.innerHTML = 'Loading…';
try {
const data = await api('/routes/live');
const lines = data.routes || [];
liveEl.innerHTML = lines.length ? lines.map(l => '<div>' + escapeHtml(l) + '</div>').join('') : '(none)';
} catch (e) {
liveEl.innerHTML = 'Error: ' + escapeHtml(e.message);
}
}
async function deleteRoute(id) {
if (!confirm('Delete this route?')) return;
try {
await api('/routes/' + id, { method: 'DELETE' });
showMsg('routesMsg', 'Route deleted.', 'success');
loadRoutes();
} catch (e) {
showMsg('routesMsg', e.message, 'error');
}
}
addBtn.addEventListener('click', async () => {
const destination = (destEl && destEl.value) || '';
const gateway = (gwEl && gwEl.value) || '';
const dev = (devEl && devEl.value) || '';
const metric = metricEl && metricEl.value !== '' ? parseInt(metricEl.value, 10) : undefined;
if (!destination.trim()) { showMsg('routesMsg', 'Destination required.', 'error'); return; }
try {
await api('/routes', { method: 'POST', body: JSON.stringify({ destination: destination.trim(), gateway: gateway.trim() || undefined, dev: dev.trim() || undefined, metric }) });
showMsg('routesMsg', 'Route added.', 'success');
if (destEl) destEl.value = '';
if (gwEl) gwEl.value = '';
if (devEl) devEl.value = '';
if (metricEl) metricEl.value = '';
loadRoutes();
} catch (e) {
showMsg('routesMsg', e.message, 'error');
}
});
applyBtn.addEventListener('click', async () => {
try {
await api('/routes/apply', { method: 'POST' });
showMsg('routesMsg', 'Routes applied (ip route add).', 'success');
loadLive();
} catch (e) {
showMsg('routesMsg', e.message, 'error');
}
});
if (refreshLiveBtn) refreshLiveBtn.addEventListener('click', loadLive);
loadRoutes();
loadLive();
}
// ---- Users page ----
if (pageId === 'users') {
const tbody = document.getElementById('usersTable');
const addBtn = document.getElementById('addUserBtn');
if (!tbody) return;
async function loadUsers() {
try {
const data = await api('/users');
tbody.innerHTML = data.users.map(u => {
const del = u.username !== 'admin' ? '<button type="button" class="btn btn-danger" data-delete="' + escapeHtml(u.username) + '">Delete</button>' : '';
return '<tr><td>' + escapeHtml(u.username) + '</td><td>' + escapeHtml(u.role) + '</td><td>' + del + '</td></tr>';
}).join('');
tbody.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.getAttribute('data-delete')));
});
} catch (e) {
showMsg('usersMsg', e.message, 'error');
}
}
async function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return;
try {
await api('/users', { method: 'PUT', body: JSON.stringify({ action: 'delete', username }) });
showMsg('usersMsg', 'User deleted.', 'success');
loadUsers();
} catch (e) {
showMsg('usersMsg', e.message, 'error');
}
}
addBtn.addEventListener('click', () => {
const username = prompt('Username:');
if (!username) return;
const password = prompt('Password:');
if (!password) return;
const role = (prompt('Role (admin or support):', 'support') || 'support').toLowerCase();
if (role !== 'admin' && role !== 'support') {
showMsg('usersMsg', 'Role must be admin or support.', 'error');
return;
}
api('/users', { method: 'PUT', body: JSON.stringify({ action: 'add', username: username.trim(), password, role }) })
.then(() => { showMsg('usersMsg', 'User added.', 'success'); loadUsers(); })
.catch(e => showMsg('usersMsg', e.message, 'error'));
});
loadUsers();
}
})();