mirror of
https://github.com/smittix/intercept.git
synced 2026-05-17 05:14:50 -07:00
feat: ship platform UX and reliability upgrades
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
const AlertCenter = (function() {
|
||||
'use strict';
|
||||
|
||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||
|
||||
let alerts = [];
|
||||
let rules = [];
|
||||
let eventSource = null;
|
||||
|
||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||
let reconnectTimer = null;
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
@@ -17,6 +18,7 @@ const AlertCenter = (function() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/alerts/stream');
|
||||
eventSource.onmessage = function(e) {
|
||||
try {
|
||||
@@ -27,21 +29,26 @@ const AlertCenter = (function() {
|
||||
console.error('[Alerts] SSE parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[Alerts] SSE connection error');
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2500);
|
||||
};
|
||||
}
|
||||
|
||||
function handleAlert(alert) {
|
||||
alerts.unshift(alert);
|
||||
alerts = alerts.slice(0, 50);
|
||||
alerts = alerts.slice(0, 60);
|
||||
updateFeedUI();
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
const severity = (alert.severity || '').toLowerCase();
|
||||
if (['high', 'critical'].includes(severity)) {
|
||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||
}
|
||||
const severity = String(alert.severity || '').toLowerCase();
|
||||
if (typeof showNotification === 'function' && ['high', 'critical'].includes(severity)) {
|
||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||
}
|
||||
|
||||
if (typeof showAppToast === 'function' && ['high', 'critical'].includes(severity)) {
|
||||
showAppToast(alert.title || 'Alert', alert.message || 'Alert triggered', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +63,7 @@ const AlertCenter = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = alerts.map(alert => {
|
||||
list.innerHTML = alerts.map((alert) => {
|
||||
const title = escapeHtml(alert.title || 'Alert');
|
||||
const message = escapeHtml(alert.message || '');
|
||||
const severity = escapeHtml(alert.severity || 'medium');
|
||||
@@ -74,27 +81,218 @@ const AlertCenter = (function() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRulesUI() {
|
||||
const list = document.getElementById('alertsRulesList');
|
||||
if (!list) return;
|
||||
|
||||
if (!rules.length) {
|
||||
list.innerHTML = '<div class="settings-feed-empty">No rules yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = rules.map((rule) => {
|
||||
const enabled = Boolean(rule.enabled);
|
||||
const mode = rule.mode || 'all';
|
||||
const eventType = rule.event_type || 'any';
|
||||
const severity = (rule.severity || 'medium').toUpperCase();
|
||||
const match = formatMatch(rule.match);
|
||||
const statusText = enabled ? 'ENABLED' : 'DISABLED';
|
||||
|
||||
return `
|
||||
<div class="settings-feed-item" style="border-left: 2px solid ${enabled ? 'var(--accent-green)' : 'var(--text-dim)'};">
|
||||
<div class="settings-feed-title" style="display:flex; gap:8px; align-items:center; justify-content:space-between;">
|
||||
<span>${escapeHtml(rule.name || 'Rule')}</span>
|
||||
<span style="color: var(--text-dim); font-size: 10px;">${statusText}</span>
|
||||
</div>
|
||||
<div class="settings-feed-meta">Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}</div>
|
||||
<div class="settings-feed-meta">Match: ${escapeHtml(match)}</div>
|
||||
<div style="display:flex; gap:8px; margin-top: 8px;">
|
||||
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.editRule(${Number(rule.id)})">Edit</button>
|
||||
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.toggleRule(${Number(rule.id)}, ${enabled ? 'false' : 'true'})">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px; border-color: var(--accent-red); color: var(--accent-red);" onclick="AlertCenter.deleteRule(${Number(rule.id)})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function formatMatch(match) {
|
||||
if (!match || typeof match !== 'object' || !Object.keys(match).length) {
|
||||
return 'none';
|
||||
}
|
||||
const [k, v] = Object.entries(match)[0];
|
||||
return `${k}=${v}`;
|
||||
}
|
||||
|
||||
function loadFeed() {
|
||||
fetch('/alerts/events?limit=20')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
fetch('/alerts/events?limit=30')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
alerts = data.events || [];
|
||||
updateFeedUI();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[Alerts] Load feed failed', err));
|
||||
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||
}
|
||||
|
||||
function loadRules() {
|
||||
fetch('/alerts/rules?all=1')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
return fetch('/alerts/rules?all=1')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
rules = data.rules || [];
|
||||
renderRulesUI();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[Alerts] Load rules failed', err));
|
||||
.catch((err) => {
|
||||
console.error('[Alerts] Load rules failed', err);
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveRule() {
|
||||
const editingId = getEditingRuleId();
|
||||
const payload = buildRulePayload();
|
||||
|
||||
if (!payload.name) {
|
||||
payload.name = payload.mode ? `${payload.mode} alert` : 'Alert Rule';
|
||||
}
|
||||
|
||||
const url = editingId ? `/alerts/rules/${editingId}` : '/alerts/rules';
|
||||
const method = editingId ? 'PATCH' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to save rule');
|
||||
}
|
||||
clearRuleForm();
|
||||
return loadRules();
|
||||
})
|
||||
.then(() => {
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Alerts', editingId ? 'Rule updated' : 'Rule created', 'info');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Save Alert Rule', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildRulePayload() {
|
||||
const nameEl = document.getElementById('alertsRuleName');
|
||||
const modeEl = document.getElementById('alertsRuleMode');
|
||||
const eventTypeEl = document.getElementById('alertsRuleEventType');
|
||||
const keyEl = document.getElementById('alertsRuleMatchKey');
|
||||
const valueEl = document.getElementById('alertsRuleMatchValue');
|
||||
const severityEl = document.getElementById('alertsRuleSeverity');
|
||||
|
||||
const match = {};
|
||||
const key = keyEl ? String(keyEl.value || '').trim() : '';
|
||||
const value = valueEl ? String(valueEl.value || '').trim() : '';
|
||||
if (key && value) {
|
||||
match[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameEl ? String(nameEl.value || '').trim() : 'Alert Rule',
|
||||
mode: modeEl ? String(modeEl.value || '').trim() || null : null,
|
||||
event_type: eventTypeEl ? String(eventTypeEl.value || '').trim() || null : null,
|
||||
match,
|
||||
severity: severityEl ? String(severityEl.value || 'medium') : 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
};
|
||||
}
|
||||
|
||||
function clearRuleForm() {
|
||||
setField('alertsRuleName', '');
|
||||
setField('alertsRuleMode', '');
|
||||
setField('alertsRuleEventType', '');
|
||||
setField('alertsRuleMatchKey', '');
|
||||
setField('alertsRuleMatchValue', '');
|
||||
setField('alertsRuleSeverity', 'medium');
|
||||
setField('alertsRuleEditingId', '');
|
||||
}
|
||||
|
||||
function editRule(ruleId) {
|
||||
const rule = rules.find((r) => Number(r.id) === Number(ruleId));
|
||||
if (!rule) return;
|
||||
|
||||
const matchEntries = Object.entries(rule.match || {});
|
||||
const firstMatch = matchEntries.length ? matchEntries[0] : ['', ''];
|
||||
|
||||
setField('alertsRuleName', rule.name || '');
|
||||
setField('alertsRuleMode', rule.mode || '');
|
||||
setField('alertsRuleEventType', rule.event_type || '');
|
||||
setField('alertsRuleMatchKey', firstMatch[0] || '');
|
||||
setField('alertsRuleMatchValue', firstMatch[1] == null ? '' : String(firstMatch[1]));
|
||||
setField('alertsRuleSeverity', rule.severity || 'medium');
|
||||
setField('alertsRuleEditingId', String(rule.id));
|
||||
}
|
||||
|
||||
function toggleRule(ruleId, enabled) {
|
||||
fetch(`/alerts/rules/${ruleId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: Boolean(enabled) }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to update rule');
|
||||
}
|
||||
return loadRules();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Toggle Alert Rule', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRule(ruleId) {
|
||||
if (!confirm('Delete this alert rule?')) return;
|
||||
|
||||
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to delete rule');
|
||||
}
|
||||
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||
clearRuleForm();
|
||||
}
|
||||
return loadRules();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Delete Alert Rule', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEditingRuleId() {
|
||||
const el = document.getElementById('alertsRuleEditingId');
|
||||
if (!el || !el.value) return null;
|
||||
const parsed = Number(el.value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function setField(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.value = value;
|
||||
}
|
||||
|
||||
function enableTrackerAlerts() {
|
||||
@@ -106,17 +304,18 @@ const AlertCenter = (function() {
|
||||
}
|
||||
|
||||
function ensureTrackerRule(enabled) {
|
||||
loadRules();
|
||||
setTimeout(() => {
|
||||
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
||||
loadRules().then(() => {
|
||||
const existing = rules.find((r) => r.name === TRACKER_RULE_NAME);
|
||||
if (existing) {
|
||||
fetch(`/alerts/rules/${existing.id}`, {
|
||||
return fetch(`/alerts/rules/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
body: JSON.stringify({ enabled }),
|
||||
}).then(() => loadRules());
|
||||
} else if (enabled) {
|
||||
fetch('/alerts/rules', {
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -126,44 +325,49 @@ const AlertCenter = (function() {
|
||||
match: { is_tracker: true },
|
||||
severity: 'high',
|
||||
enabled: true,
|
||||
notify: { webhook: true }
|
||||
})
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
}
|
||||
}, 150);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function addBluetoothWatchlist(address, name) {
|
||||
if (!address) return;
|
||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (existing) return;
|
||||
|
||||
fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: address },
|
||||
match: { address: upper },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true }
|
||||
})
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
}
|
||||
|
||||
function removeBluetoothWatchlist(address) {
|
||||
if (!address) return;
|
||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (!existing) return;
|
||||
|
||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules());
|
||||
}
|
||||
|
||||
function isWatchlisted(address) {
|
||||
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
||||
if (!address) return false;
|
||||
const upper = String(address).toUpperCase();
|
||||
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -179,6 +383,12 @@ const AlertCenter = (function() {
|
||||
return {
|
||||
init,
|
||||
loadFeed,
|
||||
loadRules,
|
||||
saveRule,
|
||||
clearRuleForm,
|
||||
editRule,
|
||||
toggleRule,
|
||||
deleteRule,
|
||||
enableTrackerAlerts,
|
||||
disableTrackerAlerts,
|
||||
addBluetoothWatchlist,
|
||||
|
||||
@@ -36,12 +36,12 @@ let observerLocation = (function() {
|
||||
return ObserverLocation.getForModule('observerLocation');
|
||||
}
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
|
||||
|
||||
322
static/js/core/command-palette.js
Normal file
322
static/js/core/command-palette.js
Normal file
@@ -0,0 +1,322 @@
|
||||
const CommandPalette = (function() {
|
||||
'use strict';
|
||||
|
||||
let overlayEl = null;
|
||||
let inputEl = null;
|
||||
let listEl = null;
|
||||
let isOpen = false;
|
||||
let activeIndex = 0;
|
||||
let filteredItems = [];
|
||||
|
||||
const modeCommands = [
|
||||
{ mode: 'pager', label: 'Pager' },
|
||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||
{ mode: 'rtlamr', label: 'Meters' },
|
||||
{ mode: 'listening', label: 'Listening Post' },
|
||||
{ mode: 'subghz', label: 'SubGHz' },
|
||||
{ mode: 'aprs', label: 'APRS' },
|
||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||
{ mode: 'bt_locate', label: 'BT Locate' },
|
||||
{ mode: 'satellite', label: 'Satellite' },
|
||||
{ mode: 'sstv', label: 'ISS SSTV' },
|
||||
{ mode: 'weathersat', label: 'Weather Sat' },
|
||||
{ mode: 'sstv_general', label: 'HF SSTV' },
|
||||
{ mode: 'gps', label: 'GPS' },
|
||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||
{ mode: 'dmr', label: 'Digital Voice' },
|
||||
{ mode: 'websdr', label: 'WebSDR' },
|
||||
{ mode: 'analytics', label: 'Analytics' },
|
||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||
];
|
||||
|
||||
function init() {
|
||||
buildDOM();
|
||||
registerHotkeys();
|
||||
renderItems('');
|
||||
}
|
||||
|
||||
function buildDOM() {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.className = 'command-palette-overlay';
|
||||
overlayEl.id = 'commandPaletteOverlay';
|
||||
overlayEl.addEventListener('click', (event) => {
|
||||
if (event.target === overlayEl) close();
|
||||
});
|
||||
|
||||
const palette = document.createElement('div');
|
||||
palette.className = 'command-palette';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'command-palette-header';
|
||||
|
||||
inputEl = document.createElement('input');
|
||||
inputEl.className = 'command-palette-input';
|
||||
inputEl.type = 'text';
|
||||
inputEl.autocomplete = 'off';
|
||||
inputEl.placeholder = 'Search commands and modes...';
|
||||
inputEl.setAttribute('aria-label', 'Command Palette Search');
|
||||
inputEl.addEventListener('input', () => {
|
||||
renderItems(inputEl.value || '');
|
||||
});
|
||||
inputEl.addEventListener('keydown', onInputKeyDown);
|
||||
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'command-palette-hint';
|
||||
hint.textContent = 'Esc close';
|
||||
|
||||
header.appendChild(inputEl);
|
||||
header.appendChild(hint);
|
||||
|
||||
listEl = document.createElement('div');
|
||||
listEl.className = 'command-palette-list';
|
||||
listEl.id = 'commandPaletteList';
|
||||
|
||||
palette.appendChild(header);
|
||||
palette.appendChild(listEl);
|
||||
overlayEl.appendChild(palette);
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
|
||||
function registerHotkeys() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const cmdK = (event.key.toLowerCase() === 'k') && (event.ctrlKey || event.metaKey);
|
||||
if (cmdK) {
|
||||
event.preventDefault();
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onInputKeyDown(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, Math.max(filteredItems.length - 1, 0));
|
||||
renderSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, 0);
|
||||
renderSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const item = filteredItems[activeIndex];
|
||||
if (item && typeof item.run === 'function') {
|
||||
item.run();
|
||||
}
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function getCommands() {
|
||||
const commands = [
|
||||
{
|
||||
title: 'Open Settings',
|
||||
description: 'Open global settings panel',
|
||||
keyword: 'settings configure tools',
|
||||
shortcut: 'S',
|
||||
run: () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Settings: Alerts',
|
||||
description: 'Open alert rules and feed',
|
||||
keyword: 'settings alerts rule',
|
||||
run: () => openSettingsTab('alerts')
|
||||
},
|
||||
{
|
||||
title: 'Settings: Recording',
|
||||
description: 'Open recording manager',
|
||||
keyword: 'settings recording replay',
|
||||
run: () => openSettingsTab('recording')
|
||||
},
|
||||
{
|
||||
title: 'Settings: Location',
|
||||
description: 'Configure observer location',
|
||||
keyword: 'settings location gps lat lon',
|
||||
run: () => openSettingsTab('location')
|
||||
},
|
||||
{
|
||||
title: 'View Aircraft Dashboard',
|
||||
description: 'Open dedicated ADS-B dashboard page',
|
||||
keyword: 'aircraft adsb dashboard',
|
||||
run: () => { window.location.href = '/adsb/dashboard'; }
|
||||
},
|
||||
{
|
||||
title: 'View Vessel Dashboard',
|
||||
description: 'Open dedicated AIS dashboard page',
|
||||
keyword: 'vessel ais dashboard',
|
||||
run: () => { window.location.href = '/ais/dashboard'; }
|
||||
},
|
||||
{
|
||||
title: 'Kill All Running Processes',
|
||||
description: 'Stop all decoders and scans',
|
||||
keyword: 'kill stop processes emergency',
|
||||
run: () => {
|
||||
if (typeof killAll === 'function') {
|
||||
killAll();
|
||||
} else if (typeof fetch === 'function') {
|
||||
fetch('/killall', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Toggle Sidebar Width',
|
||||
description: 'Collapse or expand the left sidebar',
|
||||
keyword: 'sidebar collapse layout',
|
||||
run: () => {
|
||||
if (typeof toggleMainSidebarCollapse === 'function') {
|
||||
toggleMainSidebarCollapse();
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
for (const modeEntry of modeCommands) {
|
||||
commands.push({
|
||||
title: `Switch Mode: ${modeEntry.label}`,
|
||||
description: 'Navigate directly to mode',
|
||||
keyword: `mode ${modeEntry.mode} ${modeEntry.label.toLowerCase()}`,
|
||||
run: () => goToMode(modeEntry.mode),
|
||||
});
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function renderItems(query) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
const allItems = getCommands();
|
||||
|
||||
filteredItems = allItems.filter((item) => {
|
||||
if (!q) return true;
|
||||
const haystack = `${item.title} ${item.description || ''} ${item.keyword || ''}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}).slice(0, 80);
|
||||
|
||||
activeIndex = 0;
|
||||
|
||||
listEl.innerHTML = '';
|
||||
if (filteredItems.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'command-palette-empty';
|
||||
empty.textContent = 'No matching commands';
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredItems.forEach((item, idx) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'command-palette-item';
|
||||
row.dataset.index = String(idx);
|
||||
row.addEventListener('click', () => {
|
||||
item.run();
|
||||
close();
|
||||
});
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'title';
|
||||
title.textContent = item.title;
|
||||
meta.appendChild(title);
|
||||
|
||||
const desc = document.createElement('span');
|
||||
desc.className = 'desc';
|
||||
desc.textContent = item.description || '';
|
||||
meta.appendChild(desc);
|
||||
|
||||
row.appendChild(meta);
|
||||
|
||||
if (item.shortcut) {
|
||||
const kbd = document.createElement('span');
|
||||
kbd.className = 'kbd';
|
||||
kbd.textContent = item.shortcut;
|
||||
row.appendChild(kbd);
|
||||
}
|
||||
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const rows = listEl.querySelectorAll('.command-palette-item');
|
||||
rows.forEach((row) => {
|
||||
const idx = Number(row.dataset.index || 0);
|
||||
row.classList.toggle('active', idx === activeIndex);
|
||||
});
|
||||
|
||||
const activeRow = listEl.querySelector(`.command-palette-item[data-index="${activeIndex}"]`);
|
||||
if (activeRow) {
|
||||
activeRow.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function goToMode(mode) {
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||
welcome.style.display = 'none';
|
||||
}
|
||||
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode(mode, { updateUrl: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsTab(tab) {
|
||||
if (typeof showSettings === 'function') {
|
||||
showSettings();
|
||||
}
|
||||
if (typeof switchSettingsTab === 'function') {
|
||||
switchSettingsTab(tab);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlayEl) return;
|
||||
isOpen = true;
|
||||
overlayEl.classList.add('open');
|
||||
renderItems('');
|
||||
inputEl.value = '';
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlayEl) return;
|
||||
isOpen = false;
|
||||
overlayEl.classList.remove('open');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
CommandPalette.init();
|
||||
});
|
||||
373
static/js/core/first-run-setup.js
Normal file
373
static/js/core/first-run-setup.js
Normal file
@@ -0,0 +1,373 @@
|
||||
const FirstRunSetup = (function() {
|
||||
'use strict';
|
||||
|
||||
const COMPLETE_KEY = 'intercept.setup.complete.v1';
|
||||
const DEFAULT_MODE_KEY = 'intercept.default_mode';
|
||||
|
||||
let overlayEl = null;
|
||||
let depsStatusEl = null;
|
||||
let locationStatusEl = null;
|
||||
let notifyStatusEl = null;
|
||||
let modeStatusEl = null;
|
||||
let modeSelectEl = null;
|
||||
|
||||
let dependencyReady = null;
|
||||
|
||||
function init() {
|
||||
buildDOM();
|
||||
maybeShow();
|
||||
}
|
||||
|
||||
function maybeShow() {
|
||||
if (localStorage.getItem(COMPLETE_KEY) === 'true') return;
|
||||
|
||||
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||
open();
|
||||
refreshStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const waitTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
if (localStorage.getItem(COMPLETE_KEY) === 'true') {
|
||||
clearInterval(waitTimer);
|
||||
return;
|
||||
}
|
||||
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||
clearInterval(waitTimer);
|
||||
open();
|
||||
refreshStatuses();
|
||||
}
|
||||
if (attempts > 30) {
|
||||
clearInterval(waitTimer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function buildDOM() {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.id = 'firstRunSetupOverlay';
|
||||
overlayEl.className = 'setup-overlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'setup-modal';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'setup-header';
|
||||
|
||||
const headingWrap = document.createElement('div');
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'setup-title';
|
||||
title.textContent = 'Quick Setup';
|
||||
headingWrap.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'setup-subtitle';
|
||||
subtitle.textContent = 'Complete these checks once so all modes work reliably.';
|
||||
headingWrap.appendChild(subtitle);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'setup-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close setup assistant');
|
||||
closeBtn.addEventListener('click', close);
|
||||
|
||||
header.appendChild(headingWrap);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'setup-content';
|
||||
|
||||
const depsStep = createStep(
|
||||
'Dependencies',
|
||||
'Verify required tools are installed for enabled modes.',
|
||||
(statusEl, actionsEl) => {
|
||||
depsStatusEl = statusEl;
|
||||
|
||||
const checkBtn = buildButton('Recheck', () => checkDependencies());
|
||||
const openToolsBtn = buildButton('Open Tools', () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
if (typeof switchSettingsTab === 'function') switchSettingsTab('tools');
|
||||
});
|
||||
actionsEl.appendChild(checkBtn);
|
||||
actionsEl.appendChild(openToolsBtn);
|
||||
}
|
||||
);
|
||||
|
||||
const locationStep = createStep(
|
||||
'Observer Location',
|
||||
'Set latitude/longitude for pass prediction and mapping features.',
|
||||
(statusEl, actionsEl) => {
|
||||
locationStatusEl = statusEl;
|
||||
actionsEl.appendChild(buildButton('Open Location', () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
if (typeof switchSettingsTab === 'function') switchSettingsTab('location');
|
||||
}));
|
||||
actionsEl.appendChild(buildButton('Recheck', refreshStatuses));
|
||||
}
|
||||
);
|
||||
|
||||
const notifyStep = createStep(
|
||||
'Desktop Alerts',
|
||||
'Allow notifications so high-priority alerts are visible when the tab is hidden.',
|
||||
(statusEl, actionsEl) => {
|
||||
notifyStatusEl = statusEl;
|
||||
actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications));
|
||||
}
|
||||
);
|
||||
|
||||
const modeStep = createStep(
|
||||
'Default Start Mode',
|
||||
'Choose which mode should be selected by default.',
|
||||
(statusEl, actionsEl) => {
|
||||
modeStatusEl = statusEl;
|
||||
|
||||
modeSelectEl = document.createElement('select');
|
||||
modeSelectEl.className = 'setup-btn';
|
||||
const modes = [
|
||||
['pager', 'Pager'],
|
||||
['sensor', '433MHz'],
|
||||
['rtlamr', 'Meters'],
|
||||
['listening', 'Listening Post'],
|
||||
['wifi', 'WiFi'],
|
||||
['bluetooth', 'Bluetooth'],
|
||||
['bt_locate', 'BT Locate'],
|
||||
['aprs', 'APRS'],
|
||||
['satellite', 'Satellite'],
|
||||
['sstv', 'ISS SSTV'],
|
||||
['weathersat', 'Weather Sat'],
|
||||
['sstv_general', 'HF SSTV'],
|
||||
['analytics', 'Analytics'],
|
||||
];
|
||||
for (const [value, label] of modes) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
modeSelectEl.appendChild(opt);
|
||||
}
|
||||
|
||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||
if (savedDefaultMode) {
|
||||
modeSelectEl.value = savedDefaultMode;
|
||||
}
|
||||
|
||||
actionsEl.appendChild(modeSelectEl);
|
||||
actionsEl.appendChild(buildButton('Save', () => {
|
||||
const selected = modeSelectEl.value || 'pager';
|
||||
localStorage.setItem(DEFAULT_MODE_KEY, selected);
|
||||
refreshStatuses();
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info');
|
||||
}
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
content.appendChild(depsStep);
|
||||
content.appendChild(locationStep);
|
||||
content.appendChild(notifyStep);
|
||||
content.appendChild(modeStep);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'setup-footer';
|
||||
|
||||
const note = document.createElement('span');
|
||||
note.className = 'setup-footer-note';
|
||||
note.textContent = 'You can reopen these options anytime in Settings.';
|
||||
|
||||
const footerActions = document.createElement('div');
|
||||
footerActions.style.display = 'inline-flex';
|
||||
footerActions.style.gap = '8px';
|
||||
|
||||
const laterBtn = buildButton('Remind Me Later', close);
|
||||
const completeBtn = buildButton('Mark Setup Complete', completeSetup, true);
|
||||
completeBtn.id = 'setupCompleteBtn';
|
||||
|
||||
footerActions.appendChild(laterBtn);
|
||||
footerActions.appendChild(completeBtn);
|
||||
|
||||
footer.appendChild(note);
|
||||
footer.appendChild(footerActions);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(content);
|
||||
modal.appendChild(footer);
|
||||
|
||||
overlayEl.appendChild(modal);
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
|
||||
function createStep(title, description, initActions) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'setup-step';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'setup-step-header';
|
||||
|
||||
const titleEl = document.createElement('span');
|
||||
titleEl.className = 'setup-step-title';
|
||||
titleEl.textContent = title;
|
||||
|
||||
const statusEl = document.createElement('span');
|
||||
statusEl.className = 'setup-step-status';
|
||||
statusEl.textContent = 'Pending';
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(statusEl);
|
||||
|
||||
const descEl = document.createElement('p');
|
||||
descEl.className = 'setup-step-desc';
|
||||
descEl.textContent = description;
|
||||
|
||||
const actionsEl = document.createElement('div');
|
||||
actionsEl.className = 'setup-step-actions';
|
||||
|
||||
if (typeof initActions === 'function') {
|
||||
initActions(statusEl, actionsEl);
|
||||
}
|
||||
|
||||
root.appendChild(header);
|
||||
root.appendChild(descEl);
|
||||
root.appendChild(actionsEl);
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildButton(label, onClick, primary) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `setup-btn${primary ? ' primary' : ''}`;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function checkDependencies() {
|
||||
if (depsStatusEl) depsStatusEl.textContent = 'Checking...';
|
||||
try {
|
||||
const response = await fetch('/dependencies');
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') {
|
||||
dependencyReady = false;
|
||||
} else {
|
||||
const modes = Object.values(data.modes || {});
|
||||
dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready));
|
||||
}
|
||||
} catch (err) {
|
||||
dependencyReady = false;
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Dependency Check', err, {
|
||||
onRetry: checkDependencies,
|
||||
});
|
||||
}
|
||||
}
|
||||
refreshStatuses();
|
||||
}
|
||||
|
||||
function refreshStatuses() {
|
||||
const hasLocation = hasValidLocation();
|
||||
const notifications = notificationStatus();
|
||||
const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY));
|
||||
|
||||
setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set');
|
||||
setStatus(notifyStatusEl, notifications.ready, notifications.label);
|
||||
setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set');
|
||||
|
||||
if (dependencyReady === null) {
|
||||
checkDependencies();
|
||||
return;
|
||||
}
|
||||
setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools');
|
||||
|
||||
const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode);
|
||||
const completeBtn = document.getElementById('setupCompleteBtn');
|
||||
if (completeBtn) {
|
||||
completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, done, label) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('done', Boolean(done));
|
||||
el.textContent = String(label || (done ? 'Done' : 'Pending'));
|
||||
}
|
||||
|
||||
function hasValidLocation() {
|
||||
const rawLat = localStorage.getItem('observerLat');
|
||||
const rawLon = localStorage.getItem('observerLon');
|
||||
|
||||
if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lat = Number(rawLat);
|
||||
const lon = Number(rawLon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
function notificationStatus() {
|
||||
if (!('Notification' in window)) {
|
||||
return { ready: true, label: 'Unsupported (optional)' };
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return { ready: true, label: 'Enabled' };
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return { ready: false, label: 'Blocked in browser' };
|
||||
}
|
||||
|
||||
return { ready: false, label: 'Permission needed' };
|
||||
}
|
||||
|
||||
async function requestNotifications() {
|
||||
if (!('Notification' in window)) {
|
||||
refreshStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Notification.requestPermission();
|
||||
} catch (err) {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Notifications', err);
|
||||
}
|
||||
}
|
||||
refreshStatuses();
|
||||
}
|
||||
|
||||
function completeSetup() {
|
||||
localStorage.setItem(COMPLETE_KEY, 'true');
|
||||
close();
|
||||
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlayEl) return;
|
||||
overlayEl.classList.add('open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlayEl) return;
|
||||
overlayEl.classList.remove('open');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
open,
|
||||
close,
|
||||
refreshStatuses,
|
||||
completeSetup,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
FirstRunSetup.init();
|
||||
});
|
||||
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
|
||||
<div class="settings-feed-item">
|
||||
<div class="settings-feed-title">
|
||||
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.openReplay('${rec.id}')">Replay</button>
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||
@@ -109,6 +112,17 @@ const RecordingUI = (function() {
|
||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||
}
|
||||
|
||||
function openReplay(sessionId) {
|
||||
if (!sessionId) return;
|
||||
localStorage.setItem('analyticsReplaySession', sessionId);
|
||||
if (typeof hideSettings === 'function') hideSettings();
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('analytics', { updateUrl: true });
|
||||
return;
|
||||
}
|
||||
window.location.href = '/?mode=analytics';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
@@ -126,6 +140,7 @@ const RecordingUI = (function() {
|
||||
stop,
|
||||
stopById,
|
||||
download,
|
||||
openReplay,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
206
static/js/core/run-state.js
Normal file
206
static/js/core/run-state.js
Normal file
@@ -0,0 +1,206 @@
|
||||
const RunState = (function() {
|
||||
'use strict';
|
||||
|
||||
const REFRESH_MS = 5000;
|
||||
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz'];
|
||||
|
||||
const modeLabels = {
|
||||
pager: 'Pager',
|
||||
sensor: '433',
|
||||
wifi: 'WiFi',
|
||||
bluetooth: 'BT',
|
||||
adsb: 'ADS-B',
|
||||
ais: 'AIS',
|
||||
acars: 'ACARS',
|
||||
vdl2: 'VDL2',
|
||||
aprs: 'APRS',
|
||||
dsc: 'DSC',
|
||||
dmr: 'DMR',
|
||||
subghz: 'SubGHz',
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
let activeMode = null;
|
||||
let lastHealth = null;
|
||||
let lastErrorToastAt = 0;
|
||||
|
||||
function init() {
|
||||
const root = document.getElementById('runStateStrip');
|
||||
if (!root) return;
|
||||
|
||||
wireActions();
|
||||
wrapModeSwitch();
|
||||
activeMode = inferCurrentMode();
|
||||
renderHealth(null);
|
||||
refresh();
|
||||
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = window.setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function wireActions() {
|
||||
const refreshBtn = document.getElementById('runStateRefreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => refresh());
|
||||
}
|
||||
|
||||
const settingsBtn = document.getElementById('runStateSettingsBtn');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
if (typeof showSettings === 'function') {
|
||||
showSettings();
|
||||
if (typeof switchSettingsTab === 'function') {
|
||||
switchSettingsTab('tools');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wrapModeSwitch() {
|
||||
if (typeof window.switchMode !== 'function') return;
|
||||
if (window.switchMode.__runStateWrapped) return;
|
||||
|
||||
const original = window.switchMode;
|
||||
const wrapped = function(mode) {
|
||||
if (mode) {
|
||||
activeMode = String(mode);
|
||||
}
|
||||
const result = original.apply(this, arguments);
|
||||
markActiveChip();
|
||||
return result;
|
||||
};
|
||||
wrapped.__runStateWrapped = true;
|
||||
window.switchMode = wrapped;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
lastHealth = data;
|
||||
renderHealth(data);
|
||||
} catch (err) {
|
||||
renderHealth(null, err);
|
||||
const now = Date.now();
|
||||
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||
lastErrorToastAt = now;
|
||||
reportActionableError('Run State', err, { persistent: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealth(data, err) {
|
||||
const chipsContainer = document.getElementById('runStateChips');
|
||||
const summaryEl = document.getElementById('runStateSummary');
|
||||
if (!chipsContainer || !summaryEl) return;
|
||||
|
||||
chipsContainer.innerHTML = '';
|
||||
|
||||
if (!data || data.status !== 'healthy') {
|
||||
const offline = buildChip('API', false);
|
||||
offline.classList.add('active');
|
||||
chipsContainer.appendChild(offline);
|
||||
summaryEl.textContent = err ? `Health unavailable: ${extractMessage(err)}` : 'Health unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
const processes = data.processes || {};
|
||||
for (const mode of CHIP_MODES) {
|
||||
const isRunning = Boolean(processes[mode]);
|
||||
chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode));
|
||||
}
|
||||
|
||||
const counts = data.data || {};
|
||||
summaryEl.textContent = `Aircraft ${counts.aircraft_count || 0} | Vessels ${counts.vessel_count || 0} | WiFi ${counts.wifi_networks_count || 0} | BT ${counts.bt_devices_count || 0}`;
|
||||
markActiveChip();
|
||||
}
|
||||
|
||||
function buildChip(label, running, mode) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `run-state-chip${running ? ' running' : ''}`;
|
||||
if (mode) {
|
||||
chip.dataset.mode = mode;
|
||||
}
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot';
|
||||
chip.appendChild(dot);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
chip.appendChild(text);
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
function markActiveChip() {
|
||||
if (!activeMode) {
|
||||
activeMode = inferCurrentMode();
|
||||
}
|
||||
|
||||
document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => {
|
||||
chip.classList.remove('active');
|
||||
if (chip.dataset.mode && chip.dataset.mode === activeMode) {
|
||||
chip.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function inferCurrentMode() {
|
||||
const modeParam = new URLSearchParams(window.location.search).get('mode');
|
||||
if (modeParam) return modeParam;
|
||||
|
||||
const indicator = document.getElementById('activeModeIndicator');
|
||||
if (!indicator) return 'pager';
|
||||
|
||||
const text = indicator.textContent || '';
|
||||
const normalized = text.toLowerCase();
|
||||
if (normalized.includes('wifi')) return 'wifi';
|
||||
if (normalized.includes('bluetooth')) return 'bluetooth';
|
||||
if (normalized.includes('ads-b')) return 'adsb';
|
||||
if (normalized.includes('ais')) return 'ais';
|
||||
if (normalized.includes('acars')) return 'acars';
|
||||
if (normalized.includes('vdl2')) return 'vdl2';
|
||||
if (normalized.includes('aprs')) return 'aprs';
|
||||
if (normalized.includes('dsc')) return 'dsc';
|
||||
if (normalized.includes('subghz')) return 'subghz';
|
||||
if (normalized.includes('dmr')) return 'dmr';
|
||||
if (normalized.includes('433')) return 'sensor';
|
||||
return 'pager';
|
||||
}
|
||||
|
||||
function extractMessage(err) {
|
||||
if (!err) return 'Unknown error';
|
||||
if (typeof err === 'string') return err;
|
||||
if (err.message) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function getLastHealth() {
|
||||
return lastHealth;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
destroy,
|
||||
getLastHealth,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
RunState.init();
|
||||
});
|
||||
@@ -594,7 +594,7 @@ function loadObserverLocation() {
|
||||
}
|
||||
|
||||
// Sync dashboard-specific location keys for backward compatibility
|
||||
if (lat && lon) {
|
||||
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
||||
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||
if (!localStorage.getItem('observerLocation')) {
|
||||
localStorage.setItem('observerLocation', locationObj);
|
||||
|
||||
212
static/js/core/ui-feedback.js
Normal file
212
static/js/core/ui-feedback.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const AppFeedback = (function() {
|
||||
'use strict';
|
||||
|
||||
let stackEl = null;
|
||||
let nextToastId = 1;
|
||||
|
||||
function init() {
|
||||
ensureStack();
|
||||
installGlobalHandlers();
|
||||
}
|
||||
|
||||
function ensureStack() {
|
||||
if (stackEl && document.body.contains(stackEl)) return stackEl;
|
||||
|
||||
stackEl = document.getElementById('appToastStack');
|
||||
if (!stackEl) {
|
||||
stackEl = document.createElement('div');
|
||||
stackEl.id = 'appToastStack';
|
||||
stackEl.className = 'app-toast-stack';
|
||||
document.body.appendChild(stackEl);
|
||||
}
|
||||
return stackEl;
|
||||
}
|
||||
|
||||
function toast(options) {
|
||||
const opts = options || {};
|
||||
const type = normalizeType(opts.type);
|
||||
const id = nextToastId++;
|
||||
const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500;
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = `app-toast ${type}`;
|
||||
root.dataset.toastId = String(id);
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'app-toast-title';
|
||||
titleEl.textContent = String(opts.title || defaultTitle(type));
|
||||
root.appendChild(titleEl);
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'app-toast-msg';
|
||||
msgEl.textContent = String(opts.message || '');
|
||||
root.appendChild(msgEl);
|
||||
|
||||
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : [];
|
||||
if (actions.length > 0) {
|
||||
const actionsEl = document.createElement('div');
|
||||
actionsEl.className = 'app-toast-actions';
|
||||
for (const action of actions) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = String(action.label || 'Action');
|
||||
btn.addEventListener('click', () => {
|
||||
try {
|
||||
if (typeof action.onClick === 'function') {
|
||||
action.onClick();
|
||||
}
|
||||
} finally {
|
||||
removeToast(id);
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
}
|
||||
root.appendChild(actionsEl);
|
||||
}
|
||||
|
||||
ensureStack().appendChild(root);
|
||||
|
||||
if (durationMs > 0) {
|
||||
window.setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
if (!stackEl) return;
|
||||
const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`);
|
||||
if (!toastEl) return;
|
||||
toastEl.remove();
|
||||
}
|
||||
|
||||
function reportError(context, error, options) {
|
||||
const opts = options || {};
|
||||
const message = extractMessage(error);
|
||||
const actions = [];
|
||||
|
||||
if (isSettingsError(message)) {
|
||||
actions.push({
|
||||
label: 'Open Settings',
|
||||
onClick: () => {
|
||||
if (typeof showSettings === 'function') {
|
||||
showSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isNetworkError(message)) {
|
||||
actions.push({
|
||||
label: 'Retry',
|
||||
onClick: () => {
|
||||
if (typeof opts.onRetry === 'function') {
|
||||
opts.onRetry();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof opts.extraAction === 'function' && opts.extraActionLabel) {
|
||||
actions.push({
|
||||
label: String(opts.extraActionLabel),
|
||||
onClick: opts.extraAction,
|
||||
});
|
||||
}
|
||||
|
||||
return toast({
|
||||
type: 'error',
|
||||
title: context || 'Action Failed',
|
||||
message,
|
||||
actions,
|
||||
durationMs: opts.persistent ? 0 : 8500,
|
||||
});
|
||||
}
|
||||
|
||||
function installGlobalHandlers() {
|
||||
window.addEventListener('error', (event) => {
|
||||
const target = event && event.target;
|
||||
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = extractMessage(event && event.error) || String(event.message || 'Unknown error');
|
||||
if (shouldIgnore(message)) return;
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: 'Unhandled Error',
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const message = extractMessage(event && event.reason);
|
||||
if (shouldIgnore(message)) return;
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: 'Promise Rejection',
|
||||
message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeType(type) {
|
||||
const t = String(type || 'info').toLowerCase();
|
||||
if (t === 'error' || t === 'warning') return t;
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function defaultTitle(type) {
|
||||
if (type === 'error') return 'Error';
|
||||
if (type === 'warning') return 'Warning';
|
||||
return 'Notice';
|
||||
}
|
||||
|
||||
function extractMessage(error) {
|
||||
if (!error) return 'Unknown error';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error instanceof Error) return error.message || error.name;
|
||||
if (typeof error.message === 'string') return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function shouldIgnore(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('script error') || text.includes('resizeobserver loop limit exceeded');
|
||||
}
|
||||
|
||||
function isNetworkError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
|
||||
}
|
||||
|
||||
function isSettingsError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
toast,
|
||||
reportError,
|
||||
removeToast,
|
||||
};
|
||||
})();
|
||||
|
||||
window.showAppToast = function(title, message, type) {
|
||||
return AppFeedback.toast({
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
window.reportActionableError = function(context, error, options) {
|
||||
return AppFeedback.reportError(context, error, options);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
AppFeedback.init();
|
||||
});
|
||||
@@ -78,13 +78,14 @@ const Updater = {
|
||||
* Show update toast notification
|
||||
* @param {Object} data - Update data from server
|
||||
*/
|
||||
showUpdateToast(data) {
|
||||
// Remove existing toast if present
|
||||
this.hideToast();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'update-toast';
|
||||
toast.innerHTML = `
|
||||
showUpdateToast(data) {
|
||||
// Remove existing toast if present
|
||||
this.hideToast();
|
||||
const latestVersion = this._escape(data.latest_version || '');
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'update-toast';
|
||||
toast.innerHTML = `
|
||||
<div class="update-toast-indicator"></div>
|
||||
<div class="update-toast-content">
|
||||
<div class="update-toast-header">
|
||||
@@ -97,11 +98,11 @@ const Updater = {
|
||||
</span>
|
||||
<span class="update-toast-title">Update Available</span>
|
||||
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
||||
</div>
|
||||
<div class="update-toast-body">
|
||||
Version <strong>${data.latest_version}</strong> is ready
|
||||
</div>
|
||||
<div class="update-toast-actions">
|
||||
</div>
|
||||
<div class="update-toast-body">
|
||||
Version <strong>${latestVersion}</strong> is ready
|
||||
</div>
|
||||
<div class="update-toast-actions">
|
||||
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
||||
View Details
|
||||
</button>
|
||||
@@ -172,14 +173,17 @@ const Updater = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing modal if present
|
||||
this.hideModal();
|
||||
|
||||
const data = this._updateData;
|
||||
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'update-modal-overlay';
|
||||
// Remove existing modal if present
|
||||
this.hideModal();
|
||||
|
||||
const data = this._updateData;
|
||||
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||
const safeCurrentVersion = this._escape(data.current_version || '');
|
||||
const safeLatestVersion = this._escape(data.latest_version || '');
|
||||
const safeReleaseUrl = this._safeUrl(data.release_url || '');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'update-modal-overlay';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) this.hideModal();
|
||||
};
|
||||
@@ -201,21 +205,21 @@ const Updater = {
|
||||
</div>
|
||||
<div class="update-modal-body">
|
||||
<div class="update-version-info">
|
||||
<div class="update-version-current">
|
||||
<span class="update-version-label">Current</span>
|
||||
<span class="update-version-value">v${data.current_version}</span>
|
||||
</div>
|
||||
<div class="update-version-current">
|
||||
<span class="update-version-label">Current</span>
|
||||
<span class="update-version-value">v${safeCurrentVersion}</span>
|
||||
</div>
|
||||
<div class="update-version-arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-version-latest">
|
||||
<span class="update-version-label">Latest</span>
|
||||
<span class="update-version-value update-version-new">v${data.latest_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-version-latest">
|
||||
<span class="update-version-label">Latest</span>
|
||||
<span class="update-version-value update-version-new">v${safeLatestVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="update-section">
|
||||
<div class="update-section-title">Release Notes</div>
|
||||
@@ -249,11 +253,11 @@ const Updater = {
|
||||
</div>
|
||||
|
||||
<div class="update-result" id="updateResult" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="update-modal-footer">
|
||||
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
|
||||
View on GitHub
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
</div>
|
||||
<div class="update-modal-footer">
|
||||
<a href="${safeReleaseUrl || '#'}" target="_blank" class="update-modal-link" ${!safeReleaseUrl ? 'style="display:none"' : ''}>
|
||||
View on GitHub
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
@@ -357,14 +361,16 @@ const Updater = {
|
||||
/**
|
||||
* Show update result
|
||||
*/
|
||||
_showResult(resultEl, success, data, isManual = false) {
|
||||
if (!resultEl) return;
|
||||
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
if (success) {
|
||||
if (data.updated) {
|
||||
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
||||
_showResult(resultEl, success, data, isManual = false) {
|
||||
if (!resultEl) return;
|
||||
|
||||
resultEl.style.display = 'block';
|
||||
const safeMessage = this._escape(data.message || data.error || 'An error occurred during the update.');
|
||||
const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
|
||||
|
||||
if (success) {
|
||||
if (data.updated) {
|
||||
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
||||
|
||||
if (data.requirements_changed) {
|
||||
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
|
||||
@@ -380,22 +386,22 @@ const Updater = {
|
||||
</div>
|
||||
<div class="update-result-text">${message}</div>
|
||||
`;
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-info';
|
||||
resultEl.innerHTML = `
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-info';
|
||||
resultEl.innerHTML = `
|
||||
<div class="update-result-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
if (isManual) {
|
||||
resultEl.className = 'update-result update-result-warning';
|
||||
</div>
|
||||
<div class="update-result-text">${this._escape(data.message || 'Already up to date.')}</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
if (isManual) {
|
||||
resultEl.className = 'update-result update-result-warning';
|
||||
resultEl.innerHTML = `
|
||||
<div class="update-result-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -403,14 +409,14 @@ const Updater = {
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Manual update required</strong><br>
|
||||
${data.message || 'Please download the latest release from GitHub.'}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-error';
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Manual update required</strong><br>
|
||||
${safeMessage || 'Please download the latest release from GitHub.'}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-error';
|
||||
resultEl.innerHTML = `
|
||||
<div class="update-result-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -418,16 +424,16 @@ const Updater = {
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Update failed</strong><br>
|
||||
${data.message || data.error || 'An error occurred during the update.'}
|
||||
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Update failed</strong><br>
|
||||
${safeMessage}
|
||||
${safeDetails ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + safeDetails + '</code>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format release notes (basic markdown to HTML)
|
||||
@@ -461,11 +467,33 @@ const Updater = {
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Wrap list items
|
||||
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
||||
|
||||
return '<p>' + html + '</p>';
|
||||
},
|
||||
// Wrap list items
|
||||
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
||||
|
||||
return '<p>' + html + '</p>';
|
||||
},
|
||||
|
||||
_escape(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
_safeUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Manual trigger for settings panel
|
||||
|
||||
Reference in New Issue
Block a user