mirror of
https://github.com/smittix/intercept.git
synced 2026-05-21 07:14:49 -07:00
15762 lines
806 KiB
HTML
15762 lines
806 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>iNTERCEPT // See the Invisible</title>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<link rel="manifest" href="/static/manifest.json">
|
||
<meta name="theme-color" content="#0b1118">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||
<!-- Disclaimer gate - must accept before seeing welcome page -->
|
||
<script>
|
||
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
||
if (localStorage.getItem('disclaimerAccepted') !== 'true') {
|
||
document.write('<style id="disclaimer-gate">.welcome-overlay{display:none !important}</style>');
|
||
window._showDisclaimerOnLoad = true;
|
||
}
|
||
// If navigating with a mode param (e.g. /?mode=waterfall), hide welcome immediately
|
||
// to prevent flash of welcome screen before JS applies the mode
|
||
else if (new URLSearchParams(window.location.search).get('mode')) {
|
||
document.write('<style id="mode-gate">.welcome-overlay{display:none !important}</style>');
|
||
}
|
||
</script>
|
||
<script>
|
||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||
</script>
|
||
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||
<!-- Fonts - Conditional CDN/Local loading -->
|
||
{% if offline_settings.fonts_source == 'local' %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||
{% else %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||
{% endif %}
|
||
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
|
||
{% if offline_settings.assets_source == 'local' %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.js') }}"></script>
|
||
{% else %}
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||
{% endif %}
|
||
<!-- Chart.js for signal strength graphs - Conditional CDN/Local loading -->
|
||
{% if offline_settings.assets_source == 'local' %}
|
||
<script src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
|
||
{% else %}
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||
{% endif %}
|
||
<!-- Chart.js date adapter for time-scale axes -->
|
||
<script src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
|
||
<script>
|
||
window.INTERCEPT_MODE_STYLE_MAP = {
|
||
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
|
||
tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}",
|
||
spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
|
||
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
|
||
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
|
||
weathersat: "{{ url_for('static', filename='css/modes/weather-satellite.css') }}",
|
||
sstv_general: "{{ url_for('static', filename='css/modes/sstv-general.css') }}",
|
||
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
|
||
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
|
||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||
morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
|
||
};
|
||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
||
window.ensureModeStyles = function(mode) {
|
||
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
|
||
if (!href) return Promise.resolve();
|
||
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loaded') {
|
||
return Promise.resolve();
|
||
}
|
||
if (window.INTERCEPT_MODE_STYLE_PROMISES[href]) {
|
||
return window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||
}
|
||
const absHref = new URL(href, window.location.href).href;
|
||
const existing = Array.from(document.querySelectorAll('link[data-mode-style]'))
|
||
.find((link) => link.href === absHref);
|
||
if (existing && existing.sheet) {
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||
return Promise.resolve();
|
||
}
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
|
||
const link = existing || document.createElement('link');
|
||
if (!existing) {
|
||
link.rel = 'stylesheet';
|
||
link.href = href;
|
||
link.dataset.modeStyle = mode;
|
||
}
|
||
const promise = new Promise((resolve, reject) => {
|
||
const onLoad = () => {
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||
resolve();
|
||
};
|
||
const onError = () => {
|
||
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
|
||
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||
try {
|
||
link.remove();
|
||
} catch (_) {}
|
||
reject(new Error(`failed to load mode stylesheet: ${mode}`));
|
||
};
|
||
link.addEventListener('load', onLoad, { once: true });
|
||
link.addEventListener('error', onError, { once: true });
|
||
if (existing) {
|
||
// Existing links may have finished loading before listeners attached.
|
||
if (existing.sheet) onLoad();
|
||
} else {
|
||
document.head.appendChild(link);
|
||
}
|
||
});
|
||
window.INTERCEPT_MODE_STYLE_PROMISES[href] = promise;
|
||
return promise;
|
||
};
|
||
// Start loading a deep-linked mode stylesheet as early as possible.
|
||
(function preloadQueryModeStyles() {
|
||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
||
const mode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||
if (!mode) return;
|
||
window.ensureModeStyles(mode).catch(() => {});
|
||
})();
|
||
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC.
|
||
(function warmModeStylesInBackground() {
|
||
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {};
|
||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
||
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
|
||
if (!modes.length) return;
|
||
|
||
const warm = function () {
|
||
modes.forEach(function (mode, index) {
|
||
setTimeout(function () {
|
||
window.ensureModeStyles(mode).catch(() => {});
|
||
}, index * 40);
|
||
});
|
||
};
|
||
|
||
if (typeof window.requestIdleCallback === 'function') {
|
||
window.requestIdleCallback(warm, { timeout: 2000 });
|
||
} else {
|
||
setTimeout(warm, 600);
|
||
}
|
||
})();
|
||
</script>
|
||
</head>
|
||
|
||
<body data-mode="pager">
|
||
<!-- Welcome Page -->
|
||
<div class="welcome-overlay" id="welcomePage">
|
||
<!-- Spinning Globe Background -->
|
||
<div class="globe-background">
|
||
<svg class="globe-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Outer circle -->
|
||
<circle cx="200" cy="200" r="180" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||
<!-- Equator -->
|
||
<ellipse cx="200" cy="200" rx="180" ry="40" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||
<!-- Latitude lines -->
|
||
<ellipse cx="200" cy="140" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="260" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="90" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="310" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<!-- Prime meridian -->
|
||
<ellipse cx="200" cy="200" rx="40" ry="180" fill="none" stroke="currentColor" stroke-width="0.5" class="meridian meridian-1"/>
|
||
<!-- Additional meridians -->
|
||
<ellipse cx="200" cy="200" rx="100" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-2"/>
|
||
<ellipse cx="200" cy="200" rx="150" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-3"/>
|
||
<!-- Rotating meridian group -->
|
||
<g class="rotating-meridians">
|
||
<ellipse cx="200" cy="200" rx="70" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="200" rx="130" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="200" rx="170" ry="180" fill="none" stroke="currentColor" stroke-width="0.2"/>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
<div class="welcome-container">
|
||
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||
</svg>
|
||
</button>
|
||
<!-- Header Section -->
|
||
<div class="welcome-header">
|
||
<div class="welcome-logo">
|
||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none"
|
||
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
|
||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
|
||
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
|
||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none"
|
||
stroke-linecap="round" class="signal-wave signal-wave-3" />
|
||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none"
|
||
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
|
||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
|
||
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
|
||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none"
|
||
stroke-linecap="round" class="signal-wave signal-wave-3" />
|
||
<circle cx="50" cy="22" r="6" fill="#00ff88" class="logo-dot" />
|
||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
|
||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
</svg>
|
||
</div>
|
||
<div class="welcome-title-block">
|
||
<h1 class="welcome-title">iNTERCEPT</h1>
|
||
<p class="welcome-tagline">// See the Invisible</p>
|
||
<span class="welcome-version">v{{ version }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Grid -->
|
||
<div class="welcome-content">
|
||
<!-- Left: Changelog -->
|
||
<div class="welcome-changelog">
|
||
<h2>What's New</h2>
|
||
{% for release in changelog[:2] %}
|
||
<div class="changelog-release">
|
||
<div class="changelog-version-header">
|
||
<span class="changelog-version">v{{ release.version }}</span>
|
||
<span class="changelog-date">{{ release.date }}</span>
|
||
</div>
|
||
<ul class="changelog-list">
|
||
{% for item in release.highlights %}
|
||
<li>{{ item }}</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Right: Mode Selection -->
|
||
<div class="welcome-modes">
|
||
<h2>Select Mode</h2>
|
||
|
||
<!-- Signals -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span> Signals</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('pager')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>
|
||
<span class="mode-name">Pager</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sensor')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||
<span class="mode-name">433MHz</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('rtlamr')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>
|
||
<span class="mode-name">Meters</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('subghz')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg></span>
|
||
<span class="mode-name">SubGHz</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('waterfall')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
|
||
<span class="mode-name">Waterfall</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('morse')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
|
||
<span class="mode-name">Morse</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tracking -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span> Tracking</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<a href="/adsb/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>
|
||
<span class="mode-name">Aircraft</span>
|
||
</a>
|
||
<a href="/ais/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/></svg></span>
|
||
<span class="mode-name">Vessels</span>
|
||
</a>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||
<span class="mode-name">APRS</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('gps')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
|
||
<span class="mode-name">GPS</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Space -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('satellite')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
|
||
<span class="mode-name">Satellite</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
|
||
<span class="mode-name">ISS SSTV</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||
<span class="mode-name">Weather Sat</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv_general')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||
<span class="mode-name">HF SSTV</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('wefax')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||
<span class="mode-name">WeFax</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||
<span class="mode-name">Space Wx</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Wireless -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span> Wireless</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('wifi')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||
<span class="mode-name">WiFi</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('bluetooth')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||
<span class="mode-name">Bluetooth</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('bt_locate')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span>
|
||
<span class="mode-name">BT Locate</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('meshtastic')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
|
||
<span class="mode-name">Meshtastic</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intel -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span> Intel</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('tscm')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||
<span class="mode-name">TSCM</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('spystations')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||
<span class="mode-name">Spy Stations</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('websdr')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||
<span class="mode-name">WebSDR</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div class="welcome-footer">
|
||
<p>Signal Intelligence & Counter Surveillance Platform</p>
|
||
</div>
|
||
</div>
|
||
<div class="welcome-scanline"></div>
|
||
</div>
|
||
|
||
<!-- Disclaimer Modal -->
|
||
<div class="disclaimer-overlay" id="disclaimerModal" style="display: none;">
|
||
<div class="disclaimer-modal">
|
||
<div class="warning-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||
<h2>DISCLAIMER</h2>
|
||
<p>
|
||
<strong>iNTERCEPT</strong> is a signal intelligence tool designed for <strong>educational purposes
|
||
only</strong>.
|
||
</p>
|
||
<p>By using this software, you acknowledge and agree that:</p>
|
||
<ul>
|
||
<li>This tool is intended for use by <strong>cyber security professionals</strong> and researchers only
|
||
</li>
|
||
<li>You will only use this software in a <strong>controlled environment</strong> with proper
|
||
authorization</li>
|
||
<li>Intercepting communications without consent may be <strong>illegal</strong> in your jurisdiction
|
||
</li>
|
||
<li>You are solely responsible for ensuring compliance with all applicable laws and regulations</li>
|
||
<li>The developers assume no liability for misuse of this software</li>
|
||
</ul>
|
||
<p style="color: var(--accent-red); font-weight: bold;">
|
||
Only proceed if you understand and accept these terms.
|
||
</p>
|
||
<div style="display: flex; gap: 15px; justify-content: center; margin-top: 20px;">
|
||
<button class="accept-btn" onclick="acceptDisclaimer()">I UNDERSTAND & ACCEPT</button>
|
||
<button class="accept-btn" onclick="declineDisclaimer()"
|
||
style="background: transparent; border: 1px solid var(--accent-red); color: var(--accent-red);">DECLINE</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TSCM Device Details Modal -->
|
||
<div class="tscm-modal-overlay" id="tscmDeviceModal" style="display: none;"
|
||
onclick="if(event.target === this) closeTscmDeviceModal()">
|
||
<div class="tscm-modal">
|
||
<button class="tscm-modal-close" onclick="closeTscmDeviceModal()">×</button>
|
||
<div id="tscmDeviceModalContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rejection Page -->
|
||
<div class="disclaimer-overlay disclaimer-hidden" id="rejectionPage">
|
||
<div class="disclaimer-modal" style="max-width: 600px;">
|
||
<pre
|
||
style="color: var(--accent-red); font-size: 9px; line-height: 1.1; margin-bottom: 20px; text-align: center;">
|
||
█████╗ ██████╗ ██████╗███████╗███████╗███████╗
|
||
██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
|
||
███████║██║ ██║ █████╗ ███████╗███████╗
|
||
██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
|
||
██║ ██║╚██████╗╚██████╗███████╗███████║███████║
|
||
╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
|
||
██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
|
||
██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
|
||
██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
|
||
██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
|
||
██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
|
||
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
|
||
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
|
||
<p
|
||
style="font-family: var(--font-mono); font-size: 11px; color: #888; text-align: left; margin: 0;">
|
||
<span style="color: var(--accent-red);">root@intercepted:</span><span
|
||
style="color: var(--accent-cyan);">~#</span> sudo access --grant-permission<br>
|
||
<span style="color: #666;">[sudo] password for user: ********</span><br>
|
||
<span style="color: var(--accent-red);">Error:</span> User is not in the sudoers file.<br>
|
||
<span style="color: var(--accent-orange);">This incident will be reported.</span>
|
||
</p>
|
||
</div>
|
||
<p style="color: #666; font-size: 11px; text-align: center;">
|
||
"In a world of locked doors, the man with the key is king.<br>
|
||
And you, my friend, just threw away the key."
|
||
</p>
|
||
<button class="accept-btn" onclick="location.reload()"
|
||
style="margin-top: 20px; background: transparent; border: 1px solid var(--accent-cyan); color: var(--accent-cyan);">
|
||
TRY AGAIN
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<header>
|
||
<!-- Hamburger Menu Button (Mobile) -->
|
||
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</button>
|
||
<a href="https://smittix.github.io/intercept" target="_blank" rel="noopener noreferrer" class="logo">
|
||
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Signal brackets - left side -->
|
||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
|
||
opacity="0.5" />
|
||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||
opacity="0.7" />
|
||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
|
||
<!-- Signal brackets - right side -->
|
||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
|
||
opacity="0.5" />
|
||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||
opacity="0.7" />
|
||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
|
||
<!-- The 'i' letter -->
|
||
<!-- dot of i -->
|
||
<circle cx="50" cy="22" r="6" fill="#00ff88" />
|
||
<!-- stem of i with styled terminals -->
|
||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
|
||
<!-- top terminal bar -->
|
||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
<!-- bottom terminal bar -->
|
||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
</svg>
|
||
</a>
|
||
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span> <span class="version-badge">v{{ version
|
||
}}</span></h1>
|
||
<p class="subtitle">Signal Intelligence & Counter Surveillance Platform <span class="active-mode-indicator"
|
||
id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span></p>
|
||
|
||
</header>
|
||
|
||
<div id="runStateStrip" class="run-state-strip" aria-live="polite">
|
||
<div class="run-state-left">
|
||
<span class="run-state-label">Run State</span>
|
||
<div id="runStateChips"></div>
|
||
</div>
|
||
<div class="run-state-right">
|
||
<span id="runStateSummary" class="run-state-value">Loading...</span>
|
||
<button id="runStateRefreshBtn" class="run-state-btn" type="button">Refresh</button>
|
||
<button id="runStateSettingsBtn" class="run-state-btn" type="button">Tools</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode Navigation Bar -->
|
||
{% set is_index_page = true %}
|
||
{% set active_mode = 'pager' %}
|
||
{% include 'partials/nav.html' with context %}
|
||
|
||
<!-- Mobile Drawer Overlay -->
|
||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||
|
||
<div class="container">
|
||
<div class="main-content">
|
||
<div class="sidebar mobile-drawer" id="mainSidebar">
|
||
<button class="sidebar-collapse-btn" id="sidebarCollapseBtn" onclick="toggleMainSidebarCollapse()" title="Collapse sidebar">
|
||
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></span>
|
||
Collapse Sidebar
|
||
</button>
|
||
<!-- Agent Selector -->
|
||
<div class="section" id="agentSection">
|
||
<h3>Signal Source</h3>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Agent</label>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<select id="agentSelect" style="flex: 1;">
|
||
<option value="local">Local (This Device)</option>
|
||
</select>
|
||
<span id="agentStatusDot" class="agent-status-dot online" title="Agent status"></span>
|
||
</div>
|
||
</div>
|
||
<div id="agentInfo" class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
|
||
<span id="agentStatusText">Local</span>
|
||
<span id="agentLatencyText" style="margin-left: 6px; color: var(--accent-cyan);"></span>
|
||
</div>
|
||
<!-- Agent health panel (shows all agents when expanded) -->
|
||
<details style="margin-top: 8px;">
|
||
<summary style="font-size: 10px; color: #888; cursor: pointer;">All Agents Health</summary>
|
||
<div id="agentHealthPanel" style="margin-top: 6px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; max-height: 120px; overflow-y: auto;">
|
||
<div style="color: var(--text-muted); font-size: 11px;">Loading...</div>
|
||
</div>
|
||
</details>
|
||
<!-- Multi-agent mode toggle -->
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label class="inline-checkbox" style="display: flex; align-items: center; gap: 8px;">
|
||
<input type="checkbox" id="showAllAgents" onchange="toggleMultiAgentMode()">
|
||
<span style="font-size: 11px;">Show All Agents Combined</span>
|
||
</label>
|
||
</div>
|
||
<a href="/controller/manage" class="preset-btn" style="display: block; text-align: center; text-decoration: none; margin-top: 8px; font-size: 11px;">
|
||
Manage Agents
|
||
</a>
|
||
</div>
|
||
|
||
<div class="section" id="rtlDeviceSection">
|
||
<h3>SDR Device</h3>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
|
||
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
|
||
<option value="rtlsdr">RTL-SDR</option>
|
||
<option value="sdrplay">SDRplay</option>
|
||
<option value="limesdr">LimeSDR</option>
|
||
<option value="hackrf">HackRF</option>
|
||
<option value="airspy">Airspy</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Device</label>
|
||
<select id="deviceSelect">
|
||
{% if devices %}
|
||
{% for device in devices %}
|
||
<option value="{{ device.index }}"
|
||
data-sdr-type="{{ device.sdr_type | default('rtlsdr') }}">{{ device.index }}: {{ device.name }}{% if device.serial and device.serial != 'N/A' and device.serial != 'Unknown' %} (SN: {{ device.serial }}){% endif %}</option>
|
||
{% endfor %}
|
||
{% else %}
|
||
<option value="0">No devices found</option>
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
<div id="deviceCapabilities" class="info-text"
|
||
style="font-size: 11px; margin-bottom: 8px; padding: 6px; background: #0a0a1a; border-radius: 4px;">
|
||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||
<span style="color: #888;">Freq:</span><span id="capFreqRange">24-1766 MHz</span>
|
||
<span style="color: #888;">Gain:</span><span id="capGainRange">0-50 dB</span>
|
||
</div>
|
||
</div>
|
||
<!-- Bias-T Power Toggle - Prominent Location -->
|
||
<div
|
||
style="display: flex; align-items: center; gap: 10px; padding: 8px; margin-bottom: 8px; background: linear-gradient(90deg, rgba(255,100,0,0.2), rgba(255,100,0,0.05)); border: 1px solid var(--accent-orange); border-radius: 4px;">
|
||
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()"
|
||
style="width: 18px; height: 18px; accent-color: var(--accent-orange);">
|
||
<div>
|
||
<div style="color: var(--accent-orange); font-weight: bold; font-size: 12px;">Bias-T Power
|
||
</div>
|
||
<div style="color: #888; font-size: 9px;">Powers external LNA/preamp</div>
|
||
</div>
|
||
</div>
|
||
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
|
||
Refresh Devices
|
||
</button>
|
||
|
||
<!-- SDR Device Status -->
|
||
<div id="sdrStatusPanel" style="margin-top: 10px; border: 1px solid var(--border-color); border-radius: 4px;">
|
||
<div id="sdrStatusList" style="max-height: 150px; overflow-y: auto;"></div>
|
||
<div style="padding: 6px 8px; background: var(--bg-tertiary); border-top: 1px solid var(--border-color); font-size: 10px; color: #666;">
|
||
Auto-refreshes every 5s
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Remote SDR (rtl_tcp) -->
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label class="inline-checkbox">
|
||
<input type="checkbox" id="useRemoteSDR" onchange="toggleRemoteSDR()">
|
||
Use Remote SDR (rtl_tcp)
|
||
</label>
|
||
</div>
|
||
<div id="remoteSDRConfig" style="display: none; margin-bottom: 10px;">
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888;">Host</label>
|
||
<input type="text" id="rtlTcpHost" placeholder="192.168.1.100" style="width: 100%;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888;">Port</label>
|
||
<input type="number" id="rtlTcpPort" value="1234" min="1" max="65535" style="width: 100%;">
|
||
</div>
|
||
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
|
||
Connect to rtl_tcp server on remote machine.<br>
|
||
Start server with: <code style="color: #00d4ff;">rtl_tcp -a 0.0.0.0</code>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toolStatusPager" class="info-text tool-status-section"
|
||
style="display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
|
||
<span>rtl_fm:</span><span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK'
|
||
if tools.rtl_fm else 'Missing' }}</span>
|
||
<span>multimon-ng:</span><span
|
||
class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon
|
||
else 'Missing' }}</span>
|
||
</div>
|
||
<div id="toolStatusSensor" class="info-text tool-status-section"
|
||
style="display: none; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
|
||
<span>rtl_433:</span><span class="tool-status {{ 'ok' if tools.rtl_433 else 'missing' }}">{{
|
||
'OK' if tools.rtl_433 else 'Missing' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{% include 'partials/modes/pager.html' %}
|
||
|
||
{% include 'partials/modes/sensor.html' %}
|
||
|
||
{% include 'partials/modes/rtlamr.html' %}
|
||
|
||
{% include 'partials/modes/wifi.html' %}
|
||
|
||
{% include 'partials/modes/bluetooth.html' %}
|
||
|
||
{% include 'partials/modes/aprs.html' %}
|
||
|
||
{% include 'partials/modes/satellite.html' %}
|
||
|
||
{% include 'partials/modes/sstv.html' %}
|
||
|
||
{% include 'partials/modes/weather-satellite.html' %}
|
||
|
||
{% include 'partials/modes/sstv-general.html' %}
|
||
|
||
{% include 'partials/modes/gps.html' %}
|
||
|
||
{% include 'partials/modes/wefax.html' %}
|
||
|
||
{% include 'partials/modes/morse.html' %}
|
||
|
||
{% include 'partials/modes/space-weather.html' %}
|
||
|
||
{% include 'partials/modes/tscm.html' %}
|
||
|
||
{% include 'partials/modes/ais.html' %}
|
||
|
||
{% include 'partials/modes/spy-stations.html' %}
|
||
|
||
{% include 'partials/modes/meshtastic.html' %}
|
||
|
||
{% include 'partials/modes/websdr.html' %}
|
||
|
||
{% include 'partials/modes/subghz.html' %}
|
||
|
||
{% include 'partials/modes/bt_locate.html' %}
|
||
{% include 'partials/modes/waterfall.html' %}
|
||
|
||
|
||
|
||
<button class="preset-btn" onclick="killAll()"
|
||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||
Kill All Processes
|
||
</button>
|
||
</div>
|
||
|
||
<div class="output-panel">
|
||
<button class="sidebar-expand-handle" id="sidebarExpandHandle" onclick="toggleMainSidebarCollapse()" title="Expand sidebar">
|
||
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||
</button>
|
||
<div class="output-header">
|
||
<h3 id="outputTitle">Pager Decoder</h3>
|
||
<div class="header-controls">
|
||
<div class="stats" id="pagerStats">
|
||
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
|
||
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
|
||
<div title="FLEX Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="flexCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="sensorStats" style="display: none;">
|
||
<div title="Unique Sensors"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> <span id="sensorCount">0</span></div>
|
||
<div title="Device Types"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span> <span id="deviceCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="wifiStats" style="display: none;">
|
||
<div title="Access Points"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> <span id="apCount">0</span></div>
|
||
<div title="Connected Clients"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span> <span id="clientCount">0</span></div>
|
||
<div title="Captured Handshakes" style="color: var(--accent-green);"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg></span> <span id="handshakeCount">0</span></div>
|
||
<div style="color: var(--accent-orange); cursor: pointer;" onclick="showDroneDetails()"
|
||
title="Click: Drone details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M3 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M9 9l-4 -1"/><path d="M15 9l4 -1"/><path d="M9 15l-4 1"/><path d="M15 15l4 1"/></svg></span> <span id="droneCount">0</span></div>
|
||
<div style="color: var(--accent-red); cursor: pointer;" onclick="showRogueApDetails()"
|
||
title="Click: Rogue AP details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span> <span id="rogueApCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="satelliteStats" style="display: none;">
|
||
<div title="Upcoming Passes"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> <span id="passCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Layout Container -->
|
||
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
||
<!-- Status Bar -->
|
||
<div class="wifi-status-bar">
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Networks:</span>
|
||
<span class="wifi-status-value" id="wifiNetworkCount">0</span>
|
||
</div>
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Clients:</span>
|
||
<span class="wifi-status-value" id="wifiClientCount">0</span>
|
||
</div>
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Hidden:</span>
|
||
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
|
||
</div>
|
||
<div class="wifi-status-item" id="wifiScanStatus">
|
||
<span class="wifi-status-indicator idle"></span>
|
||
<span>Ready</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content: 3-column layout -->
|
||
<div class="wifi-main-content">
|
||
<!-- LEFT: Networks Table -->
|
||
<div class="wifi-networks-panel">
|
||
<div class="wifi-networks-header">
|
||
<h5>Discovered Networks</h5>
|
||
<div class="wifi-network-filters" id="wifiNetworkFilters">
|
||
<button class="wifi-filter-btn active" data-filter="all">All</button>
|
||
<button class="wifi-filter-btn" data-filter="2.4">2.4G</button>
|
||
<button class="wifi-filter-btn" data-filter="5">5G</button>
|
||
<button class="wifi-filter-btn" data-filter="open">Open</button>
|
||
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-networks-table-wrapper">
|
||
<table class="wifi-networks-table" id="wifiNetworkTable">
|
||
<thead>
|
||
<tr>
|
||
<th class="sortable" data-sort="essid">SSID</th>
|
||
<th class="sortable" data-sort="bssid">BSSID</th>
|
||
<th class="sortable" data-sort="channel">Ch</th>
|
||
<th class="sortable" data-sort="rssi">Signal</th>
|
||
<th class="sortable" data-sort="security">Security</th>
|
||
<th class="sortable" data-sort="clients">Clients</th>
|
||
<th class="col-agent sortable" data-sort="agent">Source</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="wifiNetworkTableBody">
|
||
<tr class="wifi-network-placeholder">
|
||
<td colspan="7">
|
||
<div class="placeholder-text">Start scanning to discover networks</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CENTER: Proximity Radar -->
|
||
<div class="wifi-radar-panel">
|
||
<h5>Proximity Radar</h5>
|
||
<div id="wifiProximityRadar" class="wifi-radar-container"></div>
|
||
<div class="wifi-zone-summary">
|
||
<div class="wifi-zone near">
|
||
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
|
||
<span class="wifi-zone-label">Near</span>
|
||
</div>
|
||
<div class="wifi-zone mid">
|
||
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
|
||
<span class="wifi-zone-label">Mid</span>
|
||
</div>
|
||
<div class="wifi-zone far">
|
||
<span class="wifi-zone-count" id="wifiZoneFar">0</span>
|
||
<span class="wifi-zone-label">Far</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Channel Analysis + Security -->
|
||
<div class="wifi-analysis-panel">
|
||
<div class="wifi-channel-section">
|
||
<h5>Channel Analysis</h5>
|
||
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
|
||
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
|
||
<button class="channel-band-tab" data-band="5">5 GHz</button>
|
||
</div>
|
||
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
|
||
</div>
|
||
<div class="wifi-security-section">
|
||
<h5>Security Overview</h5>
|
||
<div class="wifi-security-stats">
|
||
<div class="wifi-security-item wpa3">
|
||
<span class="wifi-security-dot"></span>
|
||
<span>WPA3</span>
|
||
<span class="wifi-security-count" id="wpa3Count">0</span>
|
||
</div>
|
||
<div class="wifi-security-item wpa2">
|
||
<span class="wifi-security-dot"></span>
|
||
<span>WPA2</span>
|
||
<span class="wifi-security-count" id="wpa2Count">0</span>
|
||
</div>
|
||
<div class="wifi-security-item wep">
|
||
<span class="wifi-security-dot"></span>
|
||
<span>WEP</span>
|
||
<span class="wifi-security-count" id="wepCount">0</span>
|
||
</div>
|
||
<div class="wifi-security-item open">
|
||
<span class="wifi-security-dot"></span>
|
||
<span>Open</span>
|
||
<span class="wifi-security-count" id="openCount">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail Drawer (slides up on network selection) -->
|
||
<div class="wifi-detail-drawer" id="wifiDetailDrawer">
|
||
<div class="wifi-detail-header">
|
||
<div class="wifi-detail-title">
|
||
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
|
||
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
|
||
</div>
|
||
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">×</button>
|
||
</div>
|
||
<div class="wifi-detail-content" id="wifiDetailContent">
|
||
<div class="wifi-detail-grid">
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Signal</span>
|
||
<span class="value" id="wifiDetailRssi">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Channel</span>
|
||
<span class="value" id="wifiDetailChannel">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Band</span>
|
||
<span class="value" id="wifiDetailBand">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Security</span>
|
||
<span class="value" id="wifiDetailSecurity">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Cipher</span>
|
||
<span class="value" id="wifiDetailCipher">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Vendor</span>
|
||
<span class="value" id="wifiDetailVendor">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Clients</span>
|
||
<span class="value" id="wifiDetailClients">--</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">First Seen</span>
|
||
<span class="value" id="wifiDetailFirstSeen">--</span>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
|
||
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
|
||
<div class="wifi-client-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Layout Container (visualizations left, device cards right) -->
|
||
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
|
||
<!-- Left: Bluetooth Visualizations -->
|
||
<div class="bt-visuals-column" id="btVisuals">
|
||
<!-- Device Detail Panel (always visible) -->
|
||
<div class="bt-detail-panel" id="btDetailPanel">
|
||
<div class="bt-detail-header">
|
||
<h5>Device Details</h5>
|
||
</div>
|
||
<div class="bt-detail-body">
|
||
<!-- Placeholder shown when no device selected -->
|
||
<div class="bt-detail-placeholder" id="btDetailPlaceholder">
|
||
<span>Select a device to view details</span>
|
||
</div>
|
||
<!-- Content shown when device is selected -->
|
||
<div class="bt-detail-content" id="btDetailContent" style="display: none;">
|
||
<div class="bt-detail-top-row">
|
||
<div class="bt-detail-identity">
|
||
<div class="bt-detail-name" id="btDetailName">Device Name</div>
|
||
<div class="bt-detail-address">
|
||
<span id="btDetailAddress">00:00:00:00:00:00</span>
|
||
<span class="bt-mac-cluster-badge" id="btDetailMacCluster" style="display:none;"></span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-rssi-display">
|
||
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
|
||
<span class="bt-detail-rssi-unit">dBm</span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-badges" id="btDetailBadges"></div>
|
||
<div class="bt-detail-tracker-analysis" id="btDetailTrackerAnalysis" style="display: none;"></div>
|
||
<div class="bt-detail-grid">
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Manufacturer</span>
|
||
<span class="bt-detail-stat-value" id="btDetailMfr">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Type</span>
|
||
<span class="bt-detail-stat-value" id="btDetailAddrType">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Seen</span>
|
||
<span class="bt-detail-stat-value" id="btDetailSeen">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Range</span>
|
||
<span class="bt-detail-stat-value" id="btDetailRange">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Min/Max</span>
|
||
<span class="bt-detail-stat-value" id="btDetailRssiRange">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">First Seen</span>
|
||
<span class="bt-detail-stat-value" id="btDetailFirstSeen">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Last Seen</span>
|
||
<span class="bt-detail-stat-value" id="btDetailLastSeen">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Mfr ID</span>
|
||
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">TX Power</span>
|
||
<span class="bt-detail-stat-value" id="btDetailTxPower">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Seen Rate</span>
|
||
<span class="bt-detail-stat-value" id="btDetailSeenRate">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Stability</span>
|
||
<span class="bt-detail-stat-value" id="btDetailStability">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Distance</span>
|
||
<span class="bt-detail-stat-value" id="btDetailDistance">--</span>
|
||
</div>
|
||
</div>
|
||
<!-- Service Data Inspector (collapsible) -->
|
||
<div class="bt-detail-service-inspector" id="btDetailServiceInspector" style="display:none;">
|
||
<div class="bt-inspector-toggle" onclick="BluetoothMode.toggleServiceInspector()">
|
||
<span class="bt-inspector-arrow" id="btInspectorArrow">▸</span> Raw Data
|
||
</div>
|
||
<div class="bt-inspector-content" id="btInspectorContent" style="display:none;">
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-bottom-row">
|
||
<div class="bt-detail-irk" id="btDetailIrk" style="display: none;">
|
||
<span class="bt-irk-badge">IRK</span>
|
||
<span class="bt-detail-irk-value" id="btDetailIrkValue" style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);margin-left:6px;word-break:break-all;"></span>
|
||
</div>
|
||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||
</div>
|
||
<button class="bt-detail-btn" id="btDetailWatchBtn" onclick="BluetoothMode.toggleWatchlist()">Watchlist</button>
|
||
<button class="bt-detail-btn" id="btDetailCopyBtn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||
<button class="bt-detail-btn bt-locate-btn" id="btDetailLocateBtn" onclick="BluetoothMode.locateDevice()">Locate</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Main area: Side panels + Radar -->
|
||
<div class="bt-main-area">
|
||
<!-- Left side panels -->
|
||
<div class="bt-side-panels">
|
||
<div class="wifi-visual-panel bt-side-panel bt-tracker-panel">
|
||
<h5>Tracker Detection</h5>
|
||
<div id="btTrackerList" class="bt-tracker-list">
|
||
<div class="app-collection-state is-empty">Monitoring for AirTags, Tiles...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Proximity Radar -->
|
||
<div class="wifi-visual-panel bt-radar-panel">
|
||
<h5>Proximity Radar</h5>
|
||
<div id="btProximityRadar" style="display: flex; justify-content: center; padding: 8px 0;"></div>
|
||
<div id="btRadarControls" style="display: flex; gap: 6px; justify-content: center; margin-top: 8px; flex-wrap: wrap;">
|
||
<button data-filter="newOnly" class="bt-radar-filter-btn">New Only</button>
|
||
<button data-filter="strongest" class="bt-radar-filter-btn">Strongest</button>
|
||
<button data-filter="unapproved" class="bt-radar-filter-btn">Unapproved</button>
|
||
<button id="btRadarPauseBtn">Pause</button>
|
||
</div>
|
||
<div id="btZoneSummary" class="bt-zone-summary">
|
||
<div class="bt-zone-card immediate">
|
||
<span id="btZoneImmediate" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Immediate</div>
|
||
</div>
|
||
<div class="bt-zone-card near">
|
||
<span id="btZoneNear" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Near</div>
|
||
</div>
|
||
<div class="bt-zone-card far">
|
||
<span id="btZoneFar" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Far</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Right: Bluetooth Device Cards -->
|
||
<div class="wifi-device-list bt-device-list" id="btDeviceListPanel">
|
||
<div class="wifi-device-list-header">
|
||
<h5>Bluetooth Devices</h5>
|
||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||
</div>
|
||
<div class="bt-list-summary" id="btListSummary">
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Total</span>
|
||
<span class="bt-summary-value" id="btSummaryTotal">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">New</span>
|
||
<span class="bt-summary-value" id="btSummaryNew">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Trackers</span>
|
||
<span class="bt-summary-value" id="btSummaryTrackers">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Strongest</span>
|
||
<span class="bt-summary-value" id="btSummaryStrongest">--</span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-list-signal-strip">
|
||
<div class="bt-list-signal-title">Signal Distribution</div>
|
||
<div class="bt-signal-dist bt-signal-dist-compact" id="btSignalDist">
|
||
<div class="signal-range"><span>Strong</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
|
||
</div><span id="btSignalStrongCount">0</span>
|
||
</div>
|
||
<div class="signal-range"><span>Medium</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
|
||
</div><span id="btSignalMediumCount">0</span>
|
||
</div>
|
||
<div class="signal-range"><span>Weak</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
|
||
</div><span id="btSignalWeakCount">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bt-device-toolbar">
|
||
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
|
||
</div>
|
||
<div class="bt-device-filters" id="btDeviceFilters">
|
||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||
</div>
|
||
<div class="wifi-device-list-content" id="btDeviceListContent">
|
||
<div class="app-collection-state is-empty">Start scanning to discover Bluetooth devices</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Device Detail Modal -->
|
||
<div id="btDeviceModal" class="bt-modal-overlay" style="display: none;">
|
||
<div class="bt-modal">
|
||
<div class="bt-modal-header">
|
||
<h4 id="btModalTitle">Device Details</h4>
|
||
<button class="bt-modal-close" onclick="BluetoothMode.closeModal()">×</button>
|
||
</div>
|
||
<div class="bt-modal-body" id="btModalBody">
|
||
<!-- Populated by JavaScript -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- APRS Visualizations -->
|
||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px; overflow: hidden; min-height: 0;">
|
||
<!-- APRS Function Bar -->
|
||
<div class="aprs-strip">
|
||
<div class="aprs-strip-inner">
|
||
<!-- Stats -->
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripFreq">--</span>
|
||
<span class="strip-label">MHz</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripStations">0</span>
|
||
<span class="strip-label">STATIONS</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripPackets">0</span>
|
||
<span class="strip-label">PACKETS</span>
|
||
</div>
|
||
<div class="strip-stat signal-stat" id="aprsStripSignalStat">
|
||
<span class="strip-value" id="aprsStripSignal">--</span>
|
||
<span class="strip-label">SIGNAL</span>
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<!-- Controls -->
|
||
<div class="strip-control">
|
||
<select id="aprsStripRegion" class="strip-select">
|
||
<option value="north_america">N. America (144.390)</option>
|
||
<option value="europe">Europe (144.800)</option>
|
||
<option value="uk">UK (144.800)</option>
|
||
<option value="australia">Australia (145.175)</option>
|
||
<option value="new_zealand">New Zealand (144.575)</option>
|
||
<option value="argentina">Argentina (144.930)</option>
|
||
<option value="brazil">Brazil (145.570)</option>
|
||
<option value="japan">Japan (144.640)</option>
|
||
<option value="china">China (144.640)</option>
|
||
<option value="iss">ISS (145.825)</option>
|
||
<option value="sonate2">SONATE-2 (145.825)</option>
|
||
<option value="custom">Custom Frequency</option>
|
||
</select>
|
||
</div>
|
||
<div class="strip-control" id="aprsStripCustomFreqControl" style="display: none;">
|
||
<span class="strip-input-label">FREQ (MHz)</span>
|
||
<input type="number" id="aprsStripCustomFreq" class="strip-input" placeholder="144.390" step="0.001" min="144" max="146">
|
||
</div>
|
||
<div class="strip-control">
|
||
<span class="strip-input-label">GAIN</span>
|
||
<input type="number" id="aprsStripGain" class="strip-input" value="40" min="0" max="50">
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<!-- Tool Status Indicators -->
|
||
<div class="strip-tools">
|
||
<span class="strip-tool" id="aprsStripDirewolf" title="direwolf">DW</span>
|
||
<span class="strip-tool" id="aprsStripMultimon" title="multimon-ng">MM</span>
|
||
</div>
|
||
<span id="aprsGpsIndicator" class="gps-indicator" style="display: none;"
|
||
title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||
<div class="strip-divider"></div>
|
||
<!-- Actions -->
|
||
<button type="button" class="strip-btn primary" id="aprsStripStartBtn"
|
||
onclick="startAprs()">
|
||
▶ START
|
||
</button>
|
||
<button type="button" class="strip-btn stop" id="aprsStripStopBtn" onclick="stopAprs()"
|
||
style="display: none;">
|
||
◼ STOP
|
||
</button>
|
||
<!-- Status -->
|
||
<div class="strip-status">
|
||
<div class="status-dot inactive" id="aprsStripDot"></div>
|
||
<span id="aprsStripStatus">STANDBY</span>
|
||
</div>
|
||
<div class="strip-time" id="aprsStripTime">--:--:-- UTC</div>
|
||
</div>
|
||
</div>
|
||
<!-- Top row: Map and Station List side by side -->
|
||
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
||
<!-- Map Panel (larger) -->
|
||
<div class="wifi-visual-panel"
|
||
style="flex: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;">
|
||
<h5
|
||
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
|
||
APRS STATION MAP</h5>
|
||
<div class="aircraft-map-container" style="flex: 1; display: flex; flex-direction: column;">
|
||
<div class="map-header">
|
||
<span id="aprsMapTime">--:--:--</span>
|
||
<span id="aprsMapStatus">STANDBY</span>
|
||
</div>
|
||
<div id="aprsMap" style="flex: 1; min-height: 350px;"></div>
|
||
<div class="map-footer">
|
||
<span>STATIONS: <span id="aprsStationCount">0</span></span>
|
||
<span>PACKETS: <span id="aprsPacketCount">0</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Station List Panel -->
|
||
<div class="wifi-visual-panel"
|
||
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||
<h5
|
||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
|
||
STATION LIST</h5>
|
||
<div id="aprsFilterBarContainer" style="flex-shrink: 0;"></div>
|
||
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px; min-height: 0;">
|
||
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
|
||
No stations received yet
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom row: Packet Log -->
|
||
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px; flex-shrink: 0;">
|
||
<h5
|
||
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
||
PACKET LOG</h5>
|
||
<div id="aprsPacketLog"
|
||
style="flex: 1; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-muted);">Waiting for packets...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GPS Receiver Dashboard -->
|
||
<div id="gpsVisuals" class="gps-visuals-container" style="display: none;">
|
||
<div class="gps-visuals-top">
|
||
<!-- Sky View Globe -->
|
||
<div class="gps-skyview-panel">
|
||
<h4>Satellite Globe View</h4>
|
||
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
|
||
<div id="gpsSkyGlobe" class="gps-sky-globe" aria-label="GPS satellite globe"></div>
|
||
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky fallback globe"></canvas>
|
||
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
|
||
</div>
|
||
<div class="gps-sky-hint">Drag to orbit globe | Scroll to zoom | Hover satellites for details</div>
|
||
<div class="gps-legend">
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffffff;"></span> Observer</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff8800;"></span> Galileo</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff4466;"></span> BeiDou</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffdd00;"></span> SBAS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#cc66ff;"></span> QZSS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (bright)</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:rgba(0,212,255,0.45);"></span> Unused (dim)</div>
|
||
</div>
|
||
</div>
|
||
<!-- Position Info -->
|
||
<div class="gps-position-panel">
|
||
<h4>Position</h4>
|
||
<div class="gps-pos-big">
|
||
<div id="gpsVisPosLat">---</div>
|
||
<div id="gpsVisPosLon">---</div>
|
||
</div>
|
||
<div style="margin-top: 4px;">
|
||
<span class="gps-fix-badge no-fix" id="gpsVisFixBadge">NO FIX</span>
|
||
</div>
|
||
<div style="margin-top: 12px;">
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Altitude</span>
|
||
<span class="gps-pos-value" id="gpsVisPosAlt">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Speed</span>
|
||
<span class="gps-pos-value" id="gpsVisPosSpeed">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Heading</span>
|
||
<span class="gps-pos-value" id="gpsVisPosHeading">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Climb</span>
|
||
<span class="gps-pos-value" id="gpsVisPosClimb">---</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border-color);">
|
||
<div class="gps-pos-label">GPS TIME</div>
|
||
<div class="gps-pos-value" id="gpsVisTime" style="font-size: 14px; color: var(--accent-cyan);">---</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal Strength Bars -->
|
||
<div class="gps-signal-panel">
|
||
<h4>Signal Strength (SNR dB-Hz)</h4>
|
||
<div class="gps-signal-bars" id="gpsSignalBars"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Dashboard (Embedded) -->
|
||
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
||
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
|
||
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
|
||
allowfullscreen>
|
||
</iframe>
|
||
</div>
|
||
|
||
<!-- TSCM Dashboard -->
|
||
<div id="tscmVisuals" class="tscm-dashboard" style="display: none; padding: 16px;">
|
||
<!-- Legal Disclaimer Banner -->
|
||
<div class="tscm-legal-banner"
|
||
style="margin-bottom: 12px; padding: 8px 12px; background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; font-size: 10px; color: #ffffff;">
|
||
<strong>TSCM Screening Tool:</strong> This system identifies wireless and RF anomalies.
|
||
Findings are indicators, NOT confirmed surveillance devices.
|
||
No content is intercepted or decoded. Professional verification required.
|
||
</div>
|
||
|
||
<!-- Active Meeting Banner (hidden by default) -->
|
||
<div id="tscmMeetingBanner" class="tscm-meeting-banner" style="display: none;">
|
||
<div class="meeting-indicator">
|
||
<span class="meeting-pulse"></span>
|
||
<span class="meeting-text">MEETING WINDOW ACTIVE</span>
|
||
</div>
|
||
<div class="meeting-info">
|
||
<span id="tscmMeetingBannerName"></span>
|
||
<span id="tscmMeetingBannerTime"></span>
|
||
</div>
|
||
<div class="meeting-actions">
|
||
<button class="preset-btn" onclick="tscmShowMeetingSummary()" style="font-size: 10px; padding: 6px 8px;">
|
||
Summary
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capabilities Summary Bar -->
|
||
<div id="tscmCapabilitiesBar" class="tscm-capabilities-bar" style="display: none;">
|
||
<div class="cap-item" id="capWifi" title="WiFi Capability">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||
<span class="cap-status" id="capWifiStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capBt" title="Bluetooth Capability">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||
<span class="cap-status" id="capBtStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capRf" title="RF/SDR Capability">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>
|
||
<span class="cap-status" id="capRfStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capRoot" title="Privilege Level">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||
<span class="cap-status" id="capRootStatus">--</span>
|
||
</div>
|
||
<div class="cap-limitations" id="capLimitations" onclick="tscmShowCapabilities()"
|
||
style="cursor: pointer;">
|
||
<span class="cap-warn icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||
<span id="capLimitationCount">0</span> limitations
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Baseline Health Indicator -->
|
||
<div id="tscmBaselineHealth" class="tscm-baseline-health" style="display: none;">
|
||
<span class="health-label">Baseline:</span>
|
||
<span class="health-name" id="baselineHealthName">--</span>
|
||
<span class="health-badge" id="baselineHealthBadge">--</span>
|
||
<span class="health-age" id="baselineHealthAge"></span>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="tscm-filter-bar">
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterProtocol">Protocol</label>
|
||
<select id="tscmFilterProtocol">
|
||
<option value="all">All</option>
|
||
<option value="wifi">WiFi</option>
|
||
<option value="bluetooth">Bluetooth</option>
|
||
<option value="rf">RF</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterRisk">Risk</label>
|
||
<select id="tscmFilterRisk">
|
||
<option value="all">All</option>
|
||
<option value="high_interest">High Interest</option>
|
||
<option value="review">Needs Review</option>
|
||
<option value="informational">Informational</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterStatus">Status</label>
|
||
<select id="tscmFilterStatus">
|
||
<option value="all">All</option>
|
||
<option value="new">New</option>
|
||
<option value="baseline">Baseline</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterKnown">Known</label>
|
||
<select id="tscmFilterKnown">
|
||
<option value="all">All</option>
|
||
<option value="known">Known</option>
|
||
<option value="unknown">Unknown</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-status" id="tscmFilterStatusText">Filters: none</div>
|
||
</div>
|
||
|
||
<!-- Risk Summary Banner (new scoring model) - clickable cards -->
|
||
<div class="tscm-threat-banner">
|
||
<div class="threat-card critical clickable" id="tscmHighInterestCard"
|
||
onclick="showDevicesByCategory('high_interest')"
|
||
title="Click to view high interest devices">
|
||
<span class="count" id="tscmHighInterestCount">0</span>
|
||
<span class="label">High Interest</span>
|
||
</div>
|
||
<div class="threat-card high clickable" id="tscmNeedsReviewCard"
|
||
onclick="showDevicesByCategory('review')" title="Click to view devices needing review">
|
||
<span class="count" id="tscmNeedsReviewCount">0</span>
|
||
<span class="label">Needs Review</span>
|
||
</div>
|
||
<div class="threat-card low clickable" id="tscmInformationalCard"
|
||
onclick="showDevicesByCategory('informational')"
|
||
title="Click to view informational devices">
|
||
<span class="count" id="tscmInformationalCount">0</span>
|
||
<span class="label">Informational</span>
|
||
</div>
|
||
<div class="threat-card medium clickable" id="tscmCorrelationsCard"
|
||
onclick="showDevicesByCategory('correlations')" title="Click to view correlations">
|
||
<span class="count" id="tscmCorrelationsCount">0</span>
|
||
<span class="label">Correlations</span>
|
||
</div>
|
||
<div class="threat-card medium clickable" id="tscmIdentityCard"
|
||
onclick="showDevicesByCategory('identity')" title="Click to view identity clusters">
|
||
<span class="count" id="tscmIdentityCount">0</span>
|
||
<span class="label">Identity Clusters</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sweep Summary (shown after sweep completes) -->
|
||
<div id="tscmSweepSummary" style="display: none; margin-bottom: 16px;"></div>
|
||
|
||
<!-- Signal Activity Timeline -->
|
||
<div id="tscmTimelineContainer" style="margin-bottom: 16px;"></div>
|
||
|
||
<!-- Cross-Protocol Correlations (shown when correlations found) -->
|
||
<div id="tscmCorrelationsContainer" style="display: none;"></div>
|
||
|
||
<!-- Main Content Grid -->
|
||
<div class="tscm-main-grid">
|
||
<!-- WiFi Panel -->
|
||
<div class="tscm-panel" id="tscmWifiPanel">
|
||
<div class="tscm-panel-header">
|
||
WiFi Networks
|
||
<span class="badge" id="tscmWifiCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmWifiList">
|
||
<div class="tscm-empty">Start a sweep to scan for WiFi networks</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Clients Panel -->
|
||
<div class="tscm-panel" id="tscmWifiClientPanel">
|
||
<div class="tscm-panel-header">
|
||
WiFi Clients
|
||
<span class="badge" id="tscmWifiClientCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmWifiClientList">
|
||
<div class="tscm-empty">Start a sweep to scan for WiFi clients</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Panel -->
|
||
<div class="tscm-panel" id="tscmBtPanel">
|
||
<div class="tscm-panel-header">
|
||
Bluetooth Devices
|
||
<span class="badge" id="tscmBtCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmBtList">
|
||
<div class="tscm-empty">Start a sweep to scan for Bluetooth devices</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RF Signals Panel -->
|
||
<div class="tscm-panel" id="tscmRfPanel">
|
||
<div class="tscm-panel-header">
|
||
RF Signals
|
||
<span class="badge" id="tscmRfCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmRfList">
|
||
<div class="tscm-empty">Enable RF scanning with an SDR device</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Threats Panel -->
|
||
<div class="tscm-panel" id="tscmThreatPanel">
|
||
<div class="tscm-panel-header">
|
||
Detected Threats
|
||
<span class="badge" id="tscmThreatCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmThreatList">
|
||
<div class="tscm-empty">
|
||
<div class="tscm-empty-primary">No anomalies detected</div>
|
||
<div class="tscm-empty-secondary">Start a sweep to scan for signals of interest</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Device Timelines Overview -->
|
||
<div class="tscm-panel" id="tscmDeviceTimelinesPanel" style="margin-top: 12px;">
|
||
<div class="tscm-panel-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
Device Timelines
|
||
<button class="preset-btn" onclick="loadDeviceTimelines()" style="font-size: 9px; padding: 3px 8px;">Refresh</button>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmDeviceTimelinesList">
|
||
<div class="tscm-empty">Run a sweep to see device timelines</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SubGHz Transceiver Dashboard -->
|
||
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
|
||
|
||
<!-- Stats Strip -->
|
||
<div class="subghz-stats-strip">
|
||
<div class="subghz-strip-group">
|
||
<span class="subghz-strip-device-badge" id="subghzStripDevice">
|
||
<span class="subghz-strip-device-dot" id="subghzStripDeviceDot"></span>
|
||
HackRF
|
||
</span>
|
||
<div class="subghz-strip-status">
|
||
<span class="subghz-strip-dot" id="subghzStripDot"></span>
|
||
<span class="subghz-strip-status-text" id="subghzStripStatus">Idle</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-cyan" id="subghzStripFreq">--</span>
|
||
<span class="subghz-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value" id="subghzStripMode">--</span>
|
||
<span class="subghz-strip-label">MODE</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-green" id="subghzStripSignals">0</span>
|
||
<span class="subghz-strip-label">SIGNALS</span>
|
||
</div>
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-orange" id="subghzStripCaptures">0</span>
|
||
<span class="subghz-strip-label">CAPTURES</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<span class="subghz-strip-timer" id="subghzStripTimer"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Console (collapsible) -->
|
||
<div class="subghz-signal-console" id="subghzConsole" style="display: none;">
|
||
<div class="subghz-console-header" onclick="SubGhz.toggleConsole()">
|
||
<div class="subghz-phase-strip">
|
||
<span class="subghz-phase-step" id="subghzPhaseTuning">TUNING</span>
|
||
<span class="subghz-phase-arrow">▸</span>
|
||
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
|
||
<span class="subghz-phase-arrow">▸</span>
|
||
<span class="subghz-phase-step" id="subghzPhaseDecoding">DECODING</span>
|
||
</div>
|
||
<div class="subghz-burst-indicator" id="subghzBurstIndicator" title="Live burst detector">
|
||
<span class="subghz-burst-dot"></span>
|
||
<span class="subghz-burst-text" id="subghzBurstText">NO BURST</span>
|
||
</div>
|
||
<button class="subghz-console-toggle" id="subghzConsoleToggleBtn">▼</button>
|
||
</div>
|
||
<div class="subghz-console-body" id="subghzConsoleBody">
|
||
<div class="subghz-console-log" id="subghzConsoleLog"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Hub (idle state — 2x2 Flipper-style cards) -->
|
||
<div class="subghz-action-hub" id="subghzActionHub">
|
||
<div class="subghz-hub-header">
|
||
<div class="subghz-hub-header-title">HackRF One</div>
|
||
<div class="subghz-hub-header-sub">SubGHz Transceiver — 1 MHz - 6 GHz</div>
|
||
</div>
|
||
<div class="subghz-hub-grid">
|
||
<div class="subghz-hub-card subghz-hub-card--green" onclick="SubGhz.hubAction('rx')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M1 12h4m14 0h4"/><path d="M5.6 5.6l2.85 2.85m7.1 7.1l2.85 2.85M5.6 18.4l2.85-2.85m7.1-7.1l2.85-2.85"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Read RAW</div>
|
||
<div class="subghz-hub-desc">Capture raw IQ via hackrf_transfer</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--red" onclick="SubGhz.hubAction('txselect')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M5 19h14"/><path d="M12 5v11"/><path d="M8 9l4-4 4 4"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Transmit</div>
|
||
<div class="subghz-hub-desc">Replay a saved capture</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--orange" onclick="SubGhz.hubAction('sweep')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M3 20h18"/><path d="M3 17l3-7 3 4 3-9 3 6 3-3 3 9"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Freq Analyzer</div>
|
||
<div class="subghz-hub-desc">Wideband sweep via hackrf_sweep</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--purple" onclick="SubGhz.hubAction('saved')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Saved</div>
|
||
<div class="subghz-hub-desc">Signal library & replay</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operation Panels (one visible at a time, replaces hub) -->
|
||
|
||
<!-- RX (Raw Capture) Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelRx" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Read RAW — Signal Capture</span>
|
||
<div class="subghz-op-panel-actions">
|
||
<button class="subghz-btn start" id="subghzRxStartBtnPanel" onclick="SubGhz.startRx()">Start</button>
|
||
<button class="subghz-btn stop" id="subghzRxStopBtnPanel" onclick="SubGhz.stopRx()" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-display">
|
||
<div class="subghz-rx-recording" id="subghzRxRecording" style="display: none;">
|
||
<span class="subghz-rx-rec-dot"></span>
|
||
<span>RECORDING</span>
|
||
</div>
|
||
<div class="subghz-rx-info-grid">
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">FREQUENCY</span>
|
||
<span class="subghz-rx-info-value accent-cyan" id="subghzRxFreq">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">LNA GAIN</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxLna">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">VGA GAIN</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxVga">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">SAMPLE RATE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxSampleRate">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">FILE SIZE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxFileSize">0 KB</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">DATA RATE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxRate">0 KB/s</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-level-wrapper">
|
||
<span class="subghz-rx-level-label">SIGNAL</span>
|
||
<span class="subghz-rx-burst-pill" id="subghzRxBurstPill">IDLE</span>
|
||
<div class="subghz-rx-level-bar">
|
||
<div class="subghz-rx-level-fill" id="subghzRxLevel" style="width: 0%;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-hint" id="subghzRxHint">
|
||
<span class="subghz-rx-hint-label">ANALYSIS</span>
|
||
<span class="subghz-rx-hint-text" id="subghzRxHintText">No modulation hint yet</span>
|
||
<span class="subghz-rx-hint-confidence" id="subghzRxHintConfidence">--</span>
|
||
</div>
|
||
<div class="subghz-rx-scope-wrap">
|
||
<span class="subghz-rx-scope-label">WAVEFORM</span>
|
||
<div class="subghz-rx-scope">
|
||
<canvas id="subghzRxScope"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-scope-wrap">
|
||
<div class="subghz-rx-waterfall-header">
|
||
<span class="subghz-rx-scope-label">WATERFALL</span>
|
||
<div class="subghz-rx-waterfall-controls">
|
||
<div class="subghz-wf-control">
|
||
<span>FLOOR</span>
|
||
<input type="range" id="subghzWfFloor" min="0" max="200" value="20" oninput="SubGhz.setWaterfallFloor(this.value)">
|
||
<span class="subghz-wf-value" id="subghzWfFloorVal">20</span>
|
||
</div>
|
||
<div class="subghz-wf-control">
|
||
<span>RANGE</span>
|
||
<input type="range" id="subghzWfRange" min="16" max="255" value="180" oninput="SubGhz.setWaterfallRange(this.value)">
|
||
<span class="subghz-wf-value" id="subghzWfRangeVal">180</span>
|
||
</div>
|
||
<button class="subghz-wf-pause-btn" id="subghzWfPauseBtn" onclick="SubGhz.toggleWaterfall()">PAUSE</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-waterfall">
|
||
<canvas id="subghzRxWaterfall"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sweep Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelSweep" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Frequency Analyzer</span>
|
||
<div class="subghz-op-panel-actions">
|
||
<button class="subghz-btn start" id="subghzSweepStartBtnPanel" onclick="SubGhz.startSweep()">Start</button>
|
||
<button class="subghz-btn stop" id="subghzSweepStopBtnPanel" onclick="SubGhz.stopSweep()" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-sweep-layout">
|
||
<div class="subghz-sweep-chart-wrapper" id="subghzSweepChartWrapper">
|
||
<canvas id="subghzSweepCanvas"></canvas>
|
||
</div>
|
||
<div class="subghz-sweep-peaks-sidebar" id="subghzSweepPeaksSidebar">
|
||
<div class="subghz-sweep-peaks-title">PEAKS</div>
|
||
<div class="subghz-peak-list" id="subghzSweepPeakList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TX Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelTx" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Transmit</span>
|
||
</div>
|
||
<div class="subghz-tx-display" id="subghzTxDisplay">
|
||
<div class="subghz-tx-pulse-ring">
|
||
<div class="subghz-tx-pulse-dot"></div>
|
||
</div>
|
||
<div class="subghz-tx-label" id="subghzTxStateLabel">READY</div>
|
||
<div class="subghz-tx-info-grid">
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">FREQUENCY</span>
|
||
<span class="subghz-tx-info-value accent-red" id="subghzTxFreqDisplay">--</span>
|
||
</div>
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">TX GAIN</span>
|
||
<span class="subghz-tx-info-value" id="subghzTxGainDisplay">--</span>
|
||
</div>
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">ELAPSED</span>
|
||
<span class="subghz-tx-info-value" id="subghzTxElapsed">0s</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-btn-row" style="max-width: 420px; margin: 16px auto 0;">
|
||
<button class="subghz-btn" id="subghzTxChooseCaptureBtn" onclick="SubGhz.showPanel('saved')">Choose Capture</button>
|
||
<button class="subghz-btn stop" id="subghzTxStopBtn" onclick="SubGhz.stopTx()">Stop Transmission</button>
|
||
<button class="subghz-btn start" id="subghzTxReplayLastBtn" onclick="SubGhz.replayLastTx()" style="display: none;">Replay Last</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Saved Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelSaved" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Saved Captures</span>
|
||
<div class="subghz-op-panel-actions subghz-saved-actions">
|
||
<span class="subghz-saved-selection-count" id="subghzSavedSelectionCount" style="display: none;">0 selected</span>
|
||
<button class="subghz-btn" id="subghzSavedSelectBtn" onclick="SubGhz.toggleCaptureSelectMode()">Select</button>
|
||
<button class="subghz-btn" id="subghzSavedSelectAllBtn" onclick="SubGhz.selectAllCaptures()" style="display: none;">Select All</button>
|
||
<button class="subghz-btn stop" id="subghzSavedDeleteSelectedBtn" onclick="SubGhz.deleteSelectedCaptures()" style="display: none;" disabled>Delete Selected</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-captures-list subghz-captures-list-main" id="subghzCapturesList" style="flex: 1; min-height: 0; max-height: none; overflow-y: auto;">
|
||
<div class="subghz-empty" id="subghzCapturesEmpty">No captures yet</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TX Confirmation Modal -->
|
||
<div id="subghzTxModalOverlay" class="subghz-tx-modal-overlay">
|
||
<div class="subghz-tx-modal">
|
||
<h3>Confirm Transmission</h3>
|
||
<p>You are about to transmit a radio signal on:</p>
|
||
<p class="tx-freq" id="subghzTxModalFreq">--- MHz</p>
|
||
<p class="tx-duration">Capture duration: <span id="subghzTxModalDuration">--</span></p>
|
||
<div class="subghz-tx-segment-box">
|
||
<label class="subghz-tx-segment-toggle">
|
||
<input type="checkbox" id="subghzTxSegmentEnabled" onchange="SubGhz.syncTxSegmentSelection()">
|
||
Transmit selected segment only
|
||
</label>
|
||
<div class="subghz-tx-segment-grid">
|
||
<label>Start (s)</label>
|
||
<input type="number" id="subghzTxSegmentStart" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('start')">
|
||
<label>End (s)</label>
|
||
<input type="number" id="subghzTxSegmentEnd" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('end')">
|
||
</div>
|
||
<p class="subghz-tx-segment-summary" id="subghzTxSegmentSummary">Full capture</p>
|
||
</div>
|
||
<div class="subghz-tx-burst-assist" id="subghzTxBurstAssist" style="display: none;">
|
||
<div class="subghz-tx-burst-title">Detected Bursts</div>
|
||
<div class="subghz-tx-burst-timeline" id="subghzTxBurstTimeline"></div>
|
||
<div class="subghz-tx-burst-range" id="subghzTxBurstRange">Drag on timeline to select TX segment</div>
|
||
<div class="subghz-tx-burst-list" id="subghzTxBurstList"></div>
|
||
</div>
|
||
<p>Ensure you have proper authorization to transmit on this frequency.</p>
|
||
<div class="subghz-tx-modal-actions">
|
||
<button class="subghz-tx-cancel-btn" onclick="SubGhz.cancelTx()">Cancel</button>
|
||
<button class="subghz-tx-trim-btn" id="subghzTxTrimBtn" onclick="SubGhz.trimCaptureSelection()">Trim + Save</button>
|
||
<button class="subghz-tx-confirm-btn" onclick="SubGhz.confirmTx()">Transmit</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- BT Locate SAR Dashboard -->
|
||
<div id="btLocateVisuals" class="btl-visuals-container" style="display: none; flex-direction: column; gap: 8px; flex: 1; min-height: 0; overflow: hidden; padding: 8px;">
|
||
<!-- Proximity HUD -->
|
||
<div class="btl-hud" id="btLocateHud" style="display: none;">
|
||
<div class="btl-hud-top">
|
||
<div class="btl-hud-band" id="btLocateBand">---</div>
|
||
<div class="btl-hud-metrics">
|
||
<div class="btl-hud-metric btl-hud-metric-lg">
|
||
<span class="btl-hud-value" id="btLocateDistance">--</span>
|
||
<span class="btl-hud-unit">m</span>
|
||
<span class="btl-hud-label">Est. Distance</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateRssi">--</span>
|
||
<span class="btl-hud-unit">dBm</span>
|
||
<span class="btl-hud-label">RSSI</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateRssiEma">--</span>
|
||
<span class="btl-hud-unit">dBm</span>
|
||
<span class="btl-hud-label">RSSI avg</span>
|
||
</div>
|
||
<div class="btl-hud-separator"></div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateDetectionCount">0</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">Detections</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateGpsCount">0</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">GPS pts</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateSessionTime">0:00</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">Duration</span>
|
||
</div>
|
||
</div>
|
||
<div class="btl-hud-controls">
|
||
<label class="btl-hud-audio-toggle">
|
||
<input type="checkbox" id="btLocateAudioEnable" onchange="BtLocate.toggleAudio()">
|
||
<span>Audio</span>
|
||
</label>
|
||
<div class="btl-hud-export-row">
|
||
<select id="btLocateExportFormat" class="btl-hud-export-format">
|
||
<option value="csv">CSV</option>
|
||
<option value="gpx">GPX</option>
|
||
<option value="kml">KML</option>
|
||
</select>
|
||
<button class="btl-hud-clear-btn" onclick="BtLocate.exportTrail()">Export</button>
|
||
</div>
|
||
<button class="btl-hud-clear-btn" onclick="BtLocate.clearTrail()">Clear Trail</button>
|
||
</div>
|
||
</div>
|
||
<div class="btl-hud-bottom">
|
||
<div class="btl-hud-info">
|
||
<span class="btl-hud-info-item" id="btLocateTargetInfo">--</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateEnvInfo">--</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateConfidenceInfo">Confidence: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateBestSignal">Best: --</span>
|
||
</div>
|
||
<div id="btLocateDiag" class="btl-hud-diag"></div>
|
||
</div>
|
||
</div>
|
||
<div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;">
|
||
<div id="btLocateMap" style="position: absolute; inset: 0;"></div>
|
||
<div id="btLocateCrosshairOverlay" class="btl-crosshair-overlay" aria-hidden="true">
|
||
<div class="btl-crosshair-line btl-crosshair-vertical"></div>
|
||
<div class="btl-crosshair-line btl-crosshair-horizontal"></div>
|
||
</div>
|
||
<div class="btl-map-overlay-controls">
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
|
||
<span>Heatmap</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateMovementEnable" onchange="BtLocate.toggleMovement()">
|
||
<span>Movement</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateFollowEnable" onchange="BtLocate.toggleFollow()">
|
||
<span>Auto follow</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateSmoothEnable" onchange="BtLocate.toggleSmoothing()">
|
||
<span>Smooth path</span>
|
||
</label>
|
||
</div>
|
||
<div class="btl-map-heat-legend" id="btLocateHeatLegend">
|
||
<span class="btl-map-heat-label">Signal Heat</span>
|
||
<div class="btl-map-heat-bar"></div>
|
||
<div class="btl-map-heat-scale">
|
||
<span>Weak</span>
|
||
<span>Strong</span>
|
||
</div>
|
||
</div>
|
||
<div class="btl-map-track-stats" id="btLocateTrackStats">Track: 0 m | 0 pts</div>
|
||
</div>
|
||
<div class="btl-rssi-chart-container">
|
||
<span class="btl-chart-label">RSSI History</span>
|
||
<canvas id="btLocateRssiChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WebSDR Dashboard -->
|
||
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||
<!-- Audio Control Bar (hidden until connected) -->
|
||
<div id="kiwiAudioControls" class="radio-module-box" style="display: none; padding: 8px 12px; flex-shrink: 0;">
|
||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||
<!-- Live indicator -->
|
||
<div style="display: flex; align-items: center; gap: 5px;">
|
||
<div id="kiwiLiveIndicator" style="width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green); animation: pulse 1.5s infinite;"></div>
|
||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">LIVE</span>
|
||
</div>
|
||
<!-- Receiver name -->
|
||
<span id="kiwiBarReceiverName" style="font-size: 11px; color: var(--accent-cyan); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
|
||
<!-- Frequency input -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<input type="number" id="kiwiBarFrequency" step="1" style="width: 80px; font-size: 12px; font-family: var(--font-mono); padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||
<span style="font-size: 10px; color: var(--text-muted);">kHz</span>
|
||
</div>
|
||
<!-- Mode selector -->
|
||
<select id="kiwiBarMode" style="font-size: 11px; padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB</option>
|
||
<option value="lsb">LSB</option>
|
||
<option value="cw">CW</option>
|
||
</select>
|
||
<!-- Tune button -->
|
||
<button class="preset-btn" onclick="tuneFromBar()" style="font-size: 10px; padding: 3px 10px;">Tune</button>
|
||
<!-- Volume -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
|
||
<input type="range" id="kiwiBarVolume" min="0" max="100" value="80" style="width: 60px;" oninput="setKiwiVolume(this.value)">
|
||
</div>
|
||
<!-- S-meter mini -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<div style="width: 50px; height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
|
||
<div id="kiwiBarSmeter" style="height: 100%; width: 0%; background: linear-gradient(to right, var(--accent-green), var(--accent-orange)); transition: width 0.2s; border-radius: 3px;"></div>
|
||
</div>
|
||
<span id="kiwiBarSmeterValue" style="font-size: 9px; color: var(--text-muted); font-family: var(--font-mono); min-width: 20px;">S0</span>
|
||
</div>
|
||
<!-- Disconnect -->
|
||
<button class="stop-btn" onclick="disconnectFromReceiver()" style="font-size: 10px; padding: 3px 10px; margin-left: auto;">Disconnect</button>
|
||
</div>
|
||
</div>
|
||
<!-- Map and receiver list side by side -->
|
||
<div style="display: grid; grid-template-columns: 3fr 1fr; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||
<!-- Map -->
|
||
<div class="radio-module-box" style="padding: 0; overflow: hidden; position: relative; min-height: 0;">
|
||
<div id="websdrMap" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;"></div>
|
||
</div>
|
||
<!-- Receiver List -->
|
||
<div style="display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; overflow: hidden;">
|
||
<div class="radio-module-box" style="padding: 10px; flex: 1; overflow-y: auto; min-height: 0;">
|
||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||
<span>RECEIVERS</span>
|
||
<span id="websdrReceiverCount" style="color: var(--accent-cyan);">0 found</span>
|
||
</div>
|
||
<div id="websdrReceiverList" style="font-size: 11px;">
|
||
<div style="color: var(--text-muted); text-align: center; padding: 20px;">Click "Find Receivers" to search</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Spy Stations Dashboard -->
|
||
<div id="spyStationsVisuals" class="spy-stations-container" style="display: none;">
|
||
<div class="spy-stations-header">
|
||
<div class="spy-stations-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 20px; height: 20px;">
|
||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||
<circle cx="12" cy="12" r="2"/>
|
||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
|
||
</svg>
|
||
Number Stations & Diplomatic Networks
|
||
</div>
|
||
<div class="spy-stations-count">
|
||
<span id="spyStationsVisibleCount">0</span> stations
|
||
</div>
|
||
</div>
|
||
<div class="spy-stations-grid" id="spyStationsGrid">
|
||
<!-- Station cards populated by JavaScript -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Meshtastic Messages Dashboard -->
|
||
<div id="meshtasticVisuals" class="mesh-visuals-container" style="display: none;">
|
||
<!-- Compact Status Strip -->
|
||
<div class="mesh-stats-strip">
|
||
<div class="mesh-strip-group">
|
||
<button class="mesh-strip-sidebar-toggle" onclick="Meshtastic.toggleSidebar()" title="Toggle sidebar">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/>
|
||
</svg>
|
||
</button>
|
||
<div class="mesh-strip-status">
|
||
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
|
||
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
|
||
</div>
|
||
<select id="meshStripConnType" class="mesh-strip-select" title="Connection Type" onchange="Meshtastic.onConnectionTypeChange()" style="width: 70px;">
|
||
<option value="serial">Serial</option>
|
||
<option value="tcp">TCP</option>
|
||
</select>
|
||
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
|
||
<option value="">Auto-detect</option>
|
||
</select>
|
||
<input type="text" id="meshStripHostname" class="mesh-strip-input" placeholder="IP address" title="Hostname/IP for TCP" style="display: none; width: 120px;">
|
||
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
|
||
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
|
||
</div>
|
||
<div class="mesh-strip-divider"></div>
|
||
<div class="mesh-strip-group">
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value" id="meshStripNodeName">--</span>
|
||
<span class="mesh-strip-label">NODE</span>
|
||
</div>
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value mesh-strip-id" id="meshStripNodeId">--</span>
|
||
<span class="mesh-strip-label">ID</span>
|
||
</div>
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value" id="meshStripModel">--</span>
|
||
<span class="mesh-strip-label">MODEL</span>
|
||
</div>
|
||
</div>
|
||
<div class="mesh-strip-divider"></div>
|
||
<div class="mesh-strip-group">
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value accent-cyan" id="meshStripMsgCount">0</span>
|
||
<span class="mesh-strip-label">MSGS</span>
|
||
</div>
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value accent-green" id="meshStripNodeCount">0</span>
|
||
<span class="mesh-strip-label">NODES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Row (Messages + Map side by side) -->
|
||
<div class="mesh-main-row">
|
||
<!-- Messages Section -->
|
||
<div class="mesh-messages-section">
|
||
<div class="mesh-messages-header">
|
||
<div class="mesh-messages-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||
</svg>
|
||
Messages
|
||
</div>
|
||
<div class="mesh-messages-filter">
|
||
<select id="meshVisualsFilter" onchange="Meshtastic.applyFilter()">
|
||
<option value="">All Channels</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mesh-messages-list" id="meshMessagesGrid">
|
||
<div class="mesh-messages-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||
</svg>
|
||
<p>Connect to a Meshtastic device to see messages</p>
|
||
</div>
|
||
</div>
|
||
<!-- Message Compose Box -->
|
||
<div class="mesh-compose" id="meshCompose" style="display: none;">
|
||
<div class="mesh-compose-header">
|
||
<select id="meshComposeChannel" class="mesh-compose-channel" title="Channel to send on">
|
||
<option value="0">CH 0</option>
|
||
</select>
|
||
<input type="text" id="meshComposeTo" placeholder="^all (broadcast)" class="mesh-compose-to" title="Destination node ID or ^all for broadcast">
|
||
</div>
|
||
<div class="mesh-compose-body">
|
||
<input type="text" id="meshComposeText" placeholder="Type a message..." maxlength="237" class="mesh-compose-input" oninput="Meshtastic.updateCharCount()" onkeydown="Meshtastic.handleComposeKeydown(event)">
|
||
<button onclick="Meshtastic.sendMessage()" class="mesh-compose-send" title="Send message">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="mesh-compose-hint">
|
||
<span id="meshComposeCount">0</span>/237
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Node Map -->
|
||
<div class="mesh-map-section">
|
||
<div class="mesh-map-header">
|
||
<div class="mesh-map-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||
</svg>
|
||
Node Map
|
||
</div>
|
||
<div class="mesh-map-stats">
|
||
<span>NODES: <span id="meshMapNodeCount">0</span></span>
|
||
<span>WITH GPS: <span id="meshMapGpsCount">0</span></span>
|
||
</div>
|
||
</div>
|
||
<div id="meshMap" class="mesh-map"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSTV Decoder Dashboard -->
|
||
<div id="sstvVisuals" class="sstv-visuals-container" style="display: none;">
|
||
<!-- Status Strip -->
|
||
<div class="sstv-stats-strip">
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-status">
|
||
<span class="sstv-strip-dot idle" id="sstvStripDot"></span>
|
||
<span class="sstv-strip-status-text" id="sstvStripStatus">Idle</span>
|
||
</div>
|
||
<button class="sstv-strip-btn start" id="sstvStartBtn" onclick="SSTV.start()">Start</button>
|
||
<button class="sstv-strip-btn stop" id="sstvStopBtn" onclick="SSTV.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-stat">
|
||
<span class="sstv-strip-value accent-cyan">145.800</span>
|
||
<span class="sstv-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="sstv-strip-stat">
|
||
<span class="sstv-strip-value" id="sstvStripImageCount">0</span>
|
||
<span class="sstv-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<!-- Location Controls -->
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-location">
|
||
<span class="sstv-strip-label" style="margin-right: 6px;">LOC</span>
|
||
<input type="number" id="sstvObsLat" class="sstv-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||
<input type="number" id="sstvObsLon" class="sstv-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||
<button class="sstv-strip-btn gps" onclick="SSTV.useGPS(this)" title="Use GPS location">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>
|
||
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
|
||
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
|
||
</svg>
|
||
GPS
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<!-- TLE Update -->
|
||
<div class="sstv-strip-group">
|
||
<button class="sstv-strip-btn update-tle" onclick="SSTV.updateTLE(this)" title="Update satellite orbital data from CelesTrak">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||
<path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||
<path d="M16 21h5v-5"/>
|
||
</svg>
|
||
Update TLE
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ISS Tracking Map + Countdown Row -->
|
||
<div class="sstv-top-row">
|
||
<!-- ISS Map -->
|
||
<div class="sstv-map-row">
|
||
<div class="sstv-map-container">
|
||
<div id="sstvIssMap" class="sstv-iss-map"></div>
|
||
<div class="sstv-map-overlay">
|
||
<div class="sstv-map-info">
|
||
<span class="sstv-map-label">ISS</span>
|
||
<span class="sstv-map-coords"><span id="sstvIssLat">--.-</span>°, <span id="sstvIssLon">--.-</span>°</span>
|
||
<span class="sstv-map-alt">Alt: <span id="sstvIssAlt">---</span> km</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Next Pass Countdown Panel -->
|
||
<div class="sstv-countdown-panel">
|
||
<div class="sstv-countdown-header">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<polyline points="12 6 12 12 16 14"/>
|
||
</svg>
|
||
<span>Next Pass</span>
|
||
</div>
|
||
<div class="sstv-countdown-body">
|
||
<div class="sstv-countdown-timer" id="sstvCountdownTimer">
|
||
<span class="sstv-countdown-value" id="sstvCountdownValue">--:--:--</span>
|
||
<span class="sstv-countdown-label" id="sstvCountdownLabel">Set location</span>
|
||
</div>
|
||
<div class="sstv-countdown-details">
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Start</span>
|
||
<span class="sstv-detail-value" id="sstvPassStart">--:--</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Max El</span>
|
||
<span class="sstv-detail-value" id="sstvPassMaxEl">--°</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Duration</span>
|
||
<span class="sstv-detail-value" id="sstvPassDuration">-- min</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Direction</span>
|
||
<span class="sstv-detail-value" id="sstvPassDirection">--</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-countdown-status" id="sstvCountdownStatus">
|
||
<span class="sstv-status-dot"></span>
|
||
<span>Waiting for pass data...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Scope -->
|
||
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="sstvScopeToneLabel" style="color: #444;">QUIET</span>
|
||
<span id="sstvScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sstvScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Row (Live + Gallery) -->
|
||
<div class="sstv-main-row">
|
||
<!-- Live Decode Section -->
|
||
<div class="sstv-live-section">
|
||
<div class="sstv-live-header">
|
||
<div class="sstv-live-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
Live Decode
|
||
</div>
|
||
</div>
|
||
<div class="sstv-live-content" id="sstvLiveContent">
|
||
<div class="sstv-idle-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||
</svg>
|
||
<h4>ISS SSTV Decoder</h4>
|
||
<p>Click Start to listen for SSTV transmissions on 145.800 MHz</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gallery Section -->
|
||
<div class="sstv-gallery-section">
|
||
<div class="sstv-gallery-header">
|
||
<div class="sstv-gallery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Decoded Images
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:4px;">
|
||
<span class="sstv-gallery-count" id="sstvImageCount">0</span>
|
||
<button class="sstv-gallery-clear-btn" onclick="SSTV.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-gallery-grid" id="sstvGallery">
|
||
<div class="sstv-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
<p>No images decoded yet</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
|
||
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
|
||
<!-- Stats strip -->
|
||
<div class="wxsat-stats-strip">
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-status">
|
||
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
|
||
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
|
||
</div>
|
||
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
|
||
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
|
||
<span class="wxsat-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
|
||
<span class="wxsat-strip-label">MODE</span>
|
||
</div>
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
|
||
<span class="wxsat-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-location">
|
||
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
|
||
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
|
||
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
|
||
<span class="wxsat-toggle-label">AUTO</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Countdown + Timeline -->
|
||
<div class="wxsat-countdown-bar">
|
||
<div class="wxsat-countdown-next">
|
||
<div class="wxsat-countdown-boxes" id="wxsatCountdownBoxes">
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdDays">--</span><span class="wxsat-cd-unit">DAYS</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdHours">--</span><span class="wxsat-cd-unit">HRS</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdMins">--</span><span class="wxsat-cd-unit">MIN</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdSecs">--</span><span class="wxsat-cd-unit">SEC</span></div>
|
||
</div>
|
||
<div class="wxsat-countdown-info" id="wxsatCountdownInfo">
|
||
<span class="wxsat-countdown-sat" id="wxsatCountdownSat">--</span>
|
||
<span class="wxsat-countdown-detail" id="wxsatCountdownDetail">No passes predicted</span>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-timeline" id="wxsatTimeline">
|
||
<div class="wxsat-timeline-track" id="wxsatTimelineTrack"></div>
|
||
<div class="wxsat-timeline-cursor" id="wxsatTimelineCursor"></div>
|
||
<div class="wxsat-timeline-labels">
|
||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capture progress -->
|
||
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
||
<div class="wxsat-capture-info">
|
||
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
|
||
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
|
||
</div>
|
||
<div class="wxsat-progress-bar">
|
||
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Decoder Console -->
|
||
<div class="wxsat-signal-console" id="wxsatSignalConsole">
|
||
<div class="wxsat-console-header">
|
||
<div class="wxsat-console-title-group">
|
||
<span class="wxsat-console-title">DECODER CONSOLE</span>
|
||
<div class="wxsat-phase-indicator" id="wxsatPhaseIndicator">
|
||
<span class="wxsat-phase-step" data-phase="tuning">TUNING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="listening">LISTENING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="signal_detected">SIGNAL</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="decoding">DECODING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
|
||
</div>
|
||
</div>
|
||
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
|
||
onclick="WeatherSat.toggleConsole()" title="Toggle console">▼</button>
|
||
</div>
|
||
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
||
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
||
<div class="wxsat-console-entry wxsat-log-info">Waiting for capture...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main content: 3-column layout -->
|
||
<div class="wxsat-content">
|
||
<!-- Left: Pass predictions -->
|
||
<div class="wxsat-passes-panel">
|
||
<div class="wxsat-passes-header">
|
||
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
||
</div>
|
||
<div class="wxsat-passes-list" id="wxsatPassesList">
|
||
<div class="wxsat-gallery-empty">
|
||
<p>Set location to see pass predictions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Center: Polar plot + Ground track map -->
|
||
<div class="wxsat-center-panel">
|
||
<div class="wxsat-polar-container">
|
||
<div class="wxsat-panel-header">
|
||
<span class="wxsat-panel-title">Polar Plot</span>
|
||
<span class="wxsat-panel-subtitle" id="wxsatPolarSat">--</span>
|
||
</div>
|
||
<canvas id="wxsatPolarCanvas" width="300" height="300"></canvas>
|
||
</div>
|
||
<div class="wxsat-map-container">
|
||
<div class="wxsat-panel-header">
|
||
<span class="wxsat-panel-title">Global Projection</span>
|
||
<span class="wxsat-panel-subtitle" id="wxsatMapInfo">--</span>
|
||
</div>
|
||
<div id="wxsatGroundMap" class="wxsat-ground-map"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Image gallery -->
|
||
<div class="wxsat-gallery-panel">
|
||
<div class="wxsat-gallery-header">
|
||
<span class="wxsat-gallery-title">Decoded Images</span>
|
||
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
|
||
<button class="wxsat-gallery-clear-btn" onclick="WeatherSat.deleteAllImages()" title="Delete all images">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="wxsat-gallery-grid" id="wxsatGallery">
|
||
<div class="wxsat-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<path d="M2 12h20"/>
|
||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||
</svg>
|
||
<p>No images decoded yet</p>
|
||
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSTV General Decoder Dashboard -->
|
||
<div id="sstvGeneralVisuals" class="sstv-general-visuals-container" style="display: none;">
|
||
<!-- Status Strip -->
|
||
<div class="sstv-general-stats-strip">
|
||
<div class="sstv-general-strip-group">
|
||
<div class="sstv-general-strip-status">
|
||
<span class="sstv-general-strip-dot idle" id="sstvGeneralStripDot"></span>
|
||
<span class="sstv-general-strip-status-text" id="sstvGeneralStripStatus">Idle</span>
|
||
</div>
|
||
<button class="sstv-general-strip-btn start" id="sstvGeneralStartBtn" onclick="SSTVGeneral.start()">Start</button>
|
||
<button class="sstv-general-strip-btn stop" id="sstvGeneralStopBtn" onclick="SSTVGeneral.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="sstv-general-strip-divider"></div>
|
||
<div class="sstv-general-strip-group">
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value accent-cyan" id="sstvGeneralStripFreq">14.230</span>
|
||
<span class="sstv-general-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value" id="sstvGeneralStripMod">FM</span>
|
||
<span class="sstv-general-strip-label">MOD</span>
|
||
</div>
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value" id="sstvGeneralStripImageCount">0</span>
|
||
<span class="sstv-general-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Scope -->
|
||
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="sstvGeneralScopeToneLabel" style="color: #444;">QUIET</span>
|
||
<span id="sstvGeneralScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sstvGeneralScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Row (Live + Gallery) -->
|
||
<div class="sstv-general-main-row">
|
||
<!-- Live Decode Section -->
|
||
<div class="sstv-general-live-section">
|
||
<div class="sstv-general-live-header">
|
||
<div class="sstv-general-live-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
Live Decode
|
||
</div>
|
||
</div>
|
||
<div class="sstv-general-live-content" id="sstvGeneralLiveContent">
|
||
<div class="sstv-general-idle-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||
</svg>
|
||
<h4>SSTV Decoder</h4>
|
||
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gallery Section -->
|
||
<div class="sstv-general-gallery-section">
|
||
<div class="sstv-general-gallery-header">
|
||
<div class="sstv-general-gallery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Decoded Images
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:4px;">
|
||
<span class="sstv-general-gallery-count" id="sstvGeneralImageCount">0</span>
|
||
<button class="sstv-general-gallery-clear-btn" onclick="SSTVGeneral.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-general-gallery-grid" id="sstvGeneralGallery">
|
||
<div class="sstv-general-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
<p>No images decoded yet</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WeFax Decoder Dashboard -->
|
||
<div id="wefaxVisuals" class="wefax-visuals-container" style="display: none;">
|
||
<!-- Stats Strip -->
|
||
<div class="wefax-stats-strip">
|
||
<div class="wefax-strip-group">
|
||
<div class="wefax-strip-status">
|
||
<span class="wefax-strip-dot idle" id="wefaxStripDot"></span>
|
||
<span class="wefax-strip-status-text" id="wefaxStripStatus">Idle</span>
|
||
</div>
|
||
<button class="wefax-strip-btn start" id="wefaxStartBtn" onclick="WeFax.start()">Start</button>
|
||
<button class="wefax-strip-btn stop" id="wefaxStopBtn" onclick="WeFax.stop()" style="display: none;">Stop</button>
|
||
<label class="wefax-schedule-toggle" title="Auto-capture broadcasts">
|
||
<input type="checkbox" id="wefaxStripAutoSchedule"
|
||
onchange="WeFax.toggleScheduler(this)">
|
||
<span>Auto</span>
|
||
</label>
|
||
</div>
|
||
<div class="wefax-strip-divider"></div>
|
||
<div class="wefax-strip-group">
|
||
<div class="wefax-strip-stat">
|
||
<span class="wefax-strip-value accent-amber" id="wefaxStripFreq">---</span>
|
||
<span class="wefax-strip-label">KHZ</span>
|
||
</div>
|
||
<div class="wefax-strip-stat">
|
||
<span class="wefax-strip-value" id="wefaxStripLines">0</span>
|
||
<span class="wefax-strip-label">LINES</span>
|
||
</div>
|
||
<div class="wefax-strip-stat">
|
||
<span class="wefax-strip-value" id="wefaxStripImageCount">0</span>
|
||
<span class="wefax-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Countdown + Timeline -->
|
||
<div class="wefax-countdown-bar" id="wefaxCountdownBar" style="display: none;">
|
||
<div class="wefax-countdown-next">
|
||
<div class="wefax-countdown-boxes" id="wefaxCountdownBoxes">
|
||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdHours">--</span><span class="wefax-cd-unit">HRS</span></div>
|
||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdMins">--</span><span class="wefax-cd-unit">MIN</span></div>
|
||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdSecs">--</span><span class="wefax-cd-unit">SEC</span></div>
|
||
</div>
|
||
<div class="wefax-countdown-info" id="wefaxCountdownInfo">
|
||
<span class="wefax-countdown-content" id="wefaxCountdownContent">--</span>
|
||
<span class="wefax-countdown-detail" id="wefaxCountdownDetail">Select a station</span>
|
||
</div>
|
||
</div>
|
||
<div class="wefax-timeline" id="wefaxTimeline">
|
||
<div class="wefax-timeline-track" id="wefaxTimelineTrack"></div>
|
||
<div class="wefax-timeline-cursor" id="wefaxTimelineCursor"></div>
|
||
<div class="wefax-timeline-labels">
|
||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Audio Waveform Scope -->
|
||
<div id="wefaxScopePanel" style="display: none;">
|
||
<div style="background: #0a0a0a; border: 1px solid #2e2a1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="wefaxScopeRmsLabel" style="color: #ffaa00; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="wefaxScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="wefaxScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="wefaxScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schedule Timeline -->
|
||
<div class="wefax-schedule-panel">
|
||
<div class="wefax-schedule-header">
|
||
<span class="wefax-schedule-title">Broadcast Schedule</span>
|
||
<span id="wefaxStatusText" style="font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></span>
|
||
</div>
|
||
<div id="wefaxScheduleTimeline">
|
||
<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content: Live Preview + Gallery -->
|
||
<div class="wefax-main-row">
|
||
<div class="wefax-live-section">
|
||
<div class="wefax-live-header">
|
||
<div class="wefax-live-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
</svg>
|
||
Live Decode
|
||
</div>
|
||
</div>
|
||
<div class="wefax-live-content" id="wefaxLiveContent">
|
||
<div class="wefax-idle-state" id="wefaxIdleState">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||
</svg>
|
||
<h4>WeFax Decoder</h4>
|
||
<p>Select a station and click Start to decode weather fax transmissions</p>
|
||
</div>
|
||
<img id="wefaxLivePreview" class="wefax-live-preview" style="display: none;" alt="WeFax decode in progress">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wefax-gallery-section">
|
||
<div class="wefax-gallery-header">
|
||
<div class="wefax-gallery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Decoded Images
|
||
</div>
|
||
<div class="wefax-gallery-controls">
|
||
<span class="wefax-gallery-count" id="wefaxImageCount">0</span>
|
||
<button class="wefax-gallery-clear-btn" onclick="WeFax.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="wefax-gallery-grid" id="wefaxGallery">
|
||
<div class="wefax-gallery-empty">No images decoded yet</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Space Weather Dashboard -->
|
||
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||
<!-- Header metrics strip -->
|
||
<div class="sw-header-strip">
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
|
||
<span class="sw-header-label">SFI</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripKp">--</span>
|
||
<span class="sw-header-label">Kp</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripA">--</span>
|
||
<span class="sw-header-label">A-Index</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripSsn">--</span>
|
||
<span class="sw-header-label">SSN</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripWind">--</span>
|
||
<span class="sw-header-label">Wind</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripBz">--</span>
|
||
<span class="sw-header-label">Bz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NOAA G/S/R Scales -->
|
||
<div class="sw-scales-row">
|
||
<div class="sw-scale-card" id="swScaleG">
|
||
<div class="sw-scale-label">Geomagnetic</div>
|
||
<div class="sw-scale-value sw-scale-0">G0</div>
|
||
<div class="sw-scale-desc">Quiet</div>
|
||
</div>
|
||
<div class="sw-scale-card" id="swScaleS">
|
||
<div class="sw-scale-label">Solar Radiation</div>
|
||
<div class="sw-scale-value sw-scale-0">S0</div>
|
||
<div class="sw-scale-desc">None</div>
|
||
</div>
|
||
<div class="sw-scale-card" id="swScaleR">
|
||
<div class="sw-scale-label">Radio Blackouts</div>
|
||
<div class="sw-scale-value sw-scale-0">R0</div>
|
||
<div class="sw-scale-desc">None</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HF Band Conditions -->
|
||
<div class="sw-band-panel">
|
||
<div class="sw-band-title">HF Band Conditions</div>
|
||
<div class="sw-band-grid" id="swBandGrid">
|
||
<div class="sw-loading">Loading band data</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts row -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Kp Index (3-hourly)</div>
|
||
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Solar Wind</div>
|
||
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
|
||
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Flare Probability</div>
|
||
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Solar imagery gallery -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">Solar Imagery (SDO)</div>
|
||
<div class="sw-image-tabs">
|
||
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193Å</button>
|
||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304Å</button>
|
||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_magnetogram" onclick="SpaceWeather.selectSolarImage('sdo_magnetogram')">Magnetogram</button>
|
||
</div>
|
||
<div class="sw-image-frame" id="swSolarImageFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">Aurora Forecast (North)</div>
|
||
<div class="sw-image-frame" id="swAuroraFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- D-RAP absorption map -->
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">D-Region Absorption (D-RAP)</div>
|
||
<div class="sw-drap-freqs">
|
||
<button class="sw-drap-freq-btn active" data-key="drap_global" onclick="SpaceWeather.selectDrapFreq('drap_global')">Global</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_5" onclick="SpaceWeather.selectDrapFreq('drap_5')">5 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_10" onclick="SpaceWeather.selectDrapFreq('drap_10')">10 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_15" onclick="SpaceWeather.selectDrapFreq('drap_15')">15 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_20" onclick="SpaceWeather.selectDrapFreq('drap_20')">20 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_25" onclick="SpaceWeather.selectDrapFreq('drap_25')">25 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_30" onclick="SpaceWeather.selectDrapFreq('drap_30')">30 MHz</button>
|
||
</div>
|
||
<div class="sw-image-frame" id="swDrapImageFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alerts & Active Regions -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-alerts-panel">
|
||
<div class="sw-chart-title">Active Alerts</div>
|
||
<div id="swAlertsList"><div class="sw-loading">Loading</div></div>
|
||
</div>
|
||
<div class="sw-regions-panel">
|
||
<div class="sw-chart-title">Active Sunspot Regions</div>
|
||
<table class="sw-regions-table">
|
||
<thead>
|
||
<tr><th>Region</th><th>Date</th><th>Loc</th><th>Lo</th><th>Area</th></tr>
|
||
</thead>
|
||
<tbody id="swRegionsBody">
|
||
<tr><td colspan="5" class="sw-loading">Loading</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Waterfall Visuals -->
|
||
<div id="waterfallVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
|
||
<div class="wf-container">
|
||
<div class="wf-headline">
|
||
<div class="wf-headline-left">
|
||
<span class="wf-headline-tag">SPECTRUM RECEIVER</span>
|
||
<span class="wf-headline-sub">Local SDR</span>
|
||
</div>
|
||
<div class="wf-headline-right">
|
||
<span class="wf-range-text" id="wfRangeDisplay">98.8000 - 101.2000 MHz</span>
|
||
<span class="wf-tune-text" id="wfTuneDisplay">Tune 100.0000 MHz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-monitor-strip">
|
||
<div class="wf-rx-vfo">
|
||
<div class="wf-rx-vfo-top">
|
||
<span class="wf-rx-vfo-name">VFO-A</span>
|
||
<span class="wf-rx-vfo-status" id="wfVisualStatus">IDLE</span>
|
||
</div>
|
||
<div class="wf-rx-vfo-readout">
|
||
<span id="wfRxFreqReadout">100.0000</span>
|
||
<span class="wf-rx-vfo-unit">MHz</span>
|
||
</div>
|
||
<div class="wf-rx-vfo-bottom">
|
||
<span id="wfRxModeReadout">WFM</span>
|
||
<span id="wfRxStepReadout">STEP 100 kHz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-modebank" id="wfModeBank">
|
||
<button class="wf-mode-btn is-active" data-mode="wfm">WFM</button>
|
||
<button class="wf-mode-btn" data-mode="fm">NFM</button>
|
||
<button class="wf-mode-btn" data-mode="am">AM</button>
|
||
<button class="wf-mode-btn" data-mode="usb">USB</button>
|
||
<button class="wf-mode-btn" data-mode="lsb">LSB</button>
|
||
<select id="wfMonitorMode" class="wf-monitor-select wf-monitor-select-hidden">
|
||
<option value="wfm" selected>WFM</option>
|
||
<option value="fm">NFM</option>
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB</option>
|
||
<option value="lsb">LSB</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="wf-rx-levels">
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Squelch</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorSquelch" min="0" max="100" value="0">
|
||
<span id="wfMonitorSquelchValue" class="wf-monitor-value">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Gain</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorGain" min="0" max="60" value="40">
|
||
<span id="wfMonitorGainValue" class="wf-monitor-value">40</span>
|
||
</div>
|
||
</div>
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Volume</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorVolume" min="0" max="100" value="82">
|
||
<span id="wfMonitorVolumeValue" class="wf-monitor-value">82</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-meter-wrap">
|
||
<span class="wf-monitor-label">S-Meter</span>
|
||
<div class="wf-rx-smeter">
|
||
<div class="wf-rx-smeter-fill" id="wfSmeterBar"></div>
|
||
</div>
|
||
<div class="wf-rx-smeter-text" id="wfSmeterText">S0</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-actions">
|
||
<div class="wf-rx-action-row">
|
||
<button class="wf-monitor-btn" id="wfMonitorBtn" onclick="Waterfall.toggleMonitor()">Monitor</button>
|
||
<button class="wf-monitor-btn wf-monitor-btn-secondary" id="wfMuteBtn" onclick="Waterfall.toggleMute()">Mute</button>
|
||
<button class="wf-monitor-btn wf-monitor-btn-unlock" id="wfAudioUnlockBtn" onclick="Waterfall.unlockAudio()" style="display:none;">Unlock Audio</button>
|
||
</div>
|
||
<div class="wf-monitor-state" id="wfMonitorState">No audio monitor</div>
|
||
</div>
|
||
<audio id="wfAudioPlayer" autoplay playsinline></audio>
|
||
</div>
|
||
|
||
<!-- Frequency control bar -->
|
||
<div class="wf-freq-bar">
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-10)" title="Step down ×10">«</button>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-1)" title="Step down">‹</button>
|
||
<div class="wf-freq-display-wrap">
|
||
<span class="wf-freq-bar-label">CENTER</span>
|
||
<input type="text" id="wfFreqCenterDisplay" class="wf-freq-center-input" value="100.0000" inputmode="decimal" autocomplete="off" spellcheck="false">
|
||
<span class="wf-freq-bar-unit">MHz</span>
|
||
</div>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(1)" title="Step up">›</button>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(10)" title="Step up ×10">»</button>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">ZOOM</span>
|
||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomOut && Waterfall.zoomOut()" title="Zoom out (wider span)">-</button>
|
||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomIn && Waterfall.zoomIn()" title="Zoom in (narrower span)">+</button>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">STEP</span>
|
||
<select id="wfStepSize" class="wf-step-select">
|
||
<option value="0.001">1 kHz</option>
|
||
<option value="0.005">5 kHz</option>
|
||
<option value="0.01">10 kHz</option>
|
||
<option value="0.025">25 kHz</option>
|
||
<option value="0.05">50 kHz</option>
|
||
<option value="0.1" selected>100 kHz</option>
|
||
<option value="0.5">500 kHz</option>
|
||
<option value="1">1 MHz</option>
|
||
<option value="5">5 MHz</option>
|
||
</select>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">SPAN</span>
|
||
<span id="wfSpanDisplay" class="wf-span-display">2.4 MHz</span>
|
||
</div>
|
||
|
||
<!-- Spectrum canvas -->
|
||
<div class="wf-spectrum-canvas-wrap">
|
||
<canvas id="wfSpectrumCanvas"></canvas>
|
||
<div class="wf-center-line"></div>
|
||
<div class="wf-tune-line" id="wfTuneLineSpec"></div>
|
||
</div>
|
||
|
||
<div class="wf-band-strip" id="wfBandStrip"></div>
|
||
|
||
<!-- Drag handle to resize spectrum vs waterfall -->
|
||
<div class="wf-resize-handle" id="wfResizeHandle">
|
||
<div class="wf-resize-grip"></div>
|
||
</div>
|
||
|
||
<!-- Waterfall canvas -->
|
||
<div class="wf-waterfall-canvas-wrap">
|
||
<canvas id="wfWaterfallCanvas"></canvas>
|
||
<div class="wf-tooltip" id="wfTooltip"></div>
|
||
<div class="wf-center-line"></div>
|
||
<div class="wf-tune-line" id="wfTuneLineWf"></div>
|
||
</div>
|
||
|
||
<div class="wf-freq-axis" id="wfFreqAxis"></div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||
<div class="recon-panel collapsed" id="reconPanel">
|
||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||
<h4><span id="reconCollapseIcon">▶</span> Device Intelligence</h4>
|
||
<div class="recon-stats">
|
||
<div>TRACKED: <span id="trackedCount">0</span></div>
|
||
<div>NEW: <span id="newDeviceCount">0</span></div>
|
||
<div>ANOMALIES: <span id="anomalyCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="recon-content" id="reconContent">
|
||
<div style="color: #444; text-align: center; padding: 20px; font-size: 11px;">
|
||
Device intelligence data will appear here as signals are intercepted.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
|
||
<div id="filterBarContainer" style="display: none;"></div>
|
||
|
||
<!-- Pager Signal Scope -->
|
||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="scopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="pagerScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode-specific Timeline Containers -->
|
||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||
|
||
<!-- Sensor Signal Scope -->
|
||
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||
<span id="sensorScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sensorScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||
|
||
<!-- Morse Signal Scope -->
|
||
<div id="morseScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>CW Tone Detection</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>TONE: <span id="morseScopeToneLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span></span>
|
||
<span>THRESH: <span id="morseScopeThreshLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span></span>
|
||
<span id="morseScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="morseScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="morseDiagLog" style="display: none; margin-bottom: 8px; max-height: 60px; overflow-y: auto;
|
||
background: #080812; border: 1px solid #1a1a2e; border-radius: 4px; padding: 4px 8px;
|
||
font-family: var(--font-mono); font-size: 10px; color: #556677; line-height: 1.6;">
|
||
</div>
|
||
|
||
<!-- Morse Decoded Output -->
|
||
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Decoded Text</span>
|
||
<div style="display: flex; gap: 6px;">
|
||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportTxt()">TXT</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportCsv()">CSV</button>
|
||
<button class="btn btn-sm btn-ghost" id="morseCopyBtn" onclick="MorseMode.copyToClipboard()">Copy</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.clearText()">Clear</button>
|
||
</div>
|
||
</div>
|
||
<div id="morseDecodedText" class="morse-decoded-panel"></div>
|
||
<div id="morseRawPanel" class="morse-raw-panel" style="display: none;">
|
||
<div class="morse-raw-label">Raw Elements</div>
|
||
<div id="morseRawText" class="morse-raw-text"></div>
|
||
</div>
|
||
<div id="morseMetricsPanel" class="morse-metrics-panel">
|
||
<span id="morseMetricState">STATE idle</span>
|
||
<span id="morseMetricTone">TONE -- Hz</span>
|
||
<span id="morseMetricLevel">LEVEL --</span>
|
||
<span id="morseMetricThreshold">THRESH --</span>
|
||
<span id="morseMetricNoise">NOISE --</span>
|
||
<span id="morseMetricStopMs">STOP -- ms</span>
|
||
</div>
|
||
<div class="morse-status-bar">
|
||
<span class="status-item" id="morseStatusBarState">IDLE</span>
|
||
<span class="status-item" id="morseStatusBarWpm">-- WPM</span>
|
||
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
|
||
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="output-content signal-feed" id="output">
|
||
<div class="placeholder signal-empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>
|
||
</svg>
|
||
<p>Configure settings and click "Start Decoding" to begin.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar">
|
||
<div class="status-indicator">
|
||
<div class="status-dot" id="statusDot"></div>
|
||
<span id="statusText">Idle</span>
|
||
</div>
|
||
<div class="status-controls">
|
||
<div class="control-group">
|
||
<span class="control-group-label">Mode</span>
|
||
<button id="reconBtn" class="recon-toggle" onclick="toggleRecon()">RECON</button>
|
||
<button id="muteBtn" class="control-btn" onclick="toggleMute()"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span> MUTE</button>
|
||
<button id="autoScrollBtn" class="control-btn active" onclick="toggleAutoScroll()"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></span> AUTO</button>
|
||
</div>
|
||
<div class="control-group">
|
||
<span class="control-group-label">Export</span>
|
||
<button class="control-btn" onclick="exportCSV()">CSV</button>
|
||
<button class="control-btn" onclick="exportJSON()">JSON</button>
|
||
<button class="control-btn" onclick="exportDeviceDB()"
|
||
title="Export Device Intelligence">INTEL</button>
|
||
</div>
|
||
<button class="clear-btn" onclick="clearMessages()">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intercept JS Modules -->
|
||
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-guess.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/activity-timeline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/bluetooth-adapter.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
|
||
<!-- Bluetooth v2 components -->
|
||
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/consumption-sparkline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/meter-aggregator.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}?v={{ version }}&r=btlocate1"></script>
|
||
<!-- WiFi v2 components -->
|
||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"></script>
|
||
|
||
<script>
|
||
// ============================================
|
||
// ACTIVITY TIMELINE MANAGEMENT
|
||
// ============================================
|
||
const modeTimelines = {};
|
||
|
||
/**
|
||
* Initialize timeline for a specific mode
|
||
*/
|
||
function initializeModeTimeline(mode) {
|
||
// Skip if already initialized
|
||
if (modeTimelines[mode]) return;
|
||
|
||
const configs = {
|
||
'pager': {
|
||
container: 'pagerTimelineContainer',
|
||
config: {
|
||
title: 'Pager Activity',
|
||
mode: 'pager',
|
||
visualMode: 'enriched',
|
||
collapsed: false,
|
||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||
defaultWindow: '15m'
|
||
}
|
||
},
|
||
'sensor': {
|
||
container: 'sensorTimelineContainer',
|
||
config: {
|
||
title: 'Sensor Activity',
|
||
mode: 'sensor',
|
||
visualMode: 'enriched',
|
||
collapsed: false,
|
||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||
defaultWindow: '15m'
|
||
}
|
||
},
|
||
'tscm': {
|
||
container: 'tscmTimelineContainer',
|
||
config: typeof RFTimelineAdapter !== 'undefined' ? RFTimelineAdapter.getTscmConfig() : {
|
||
title: 'Signal Activity Timeline',
|
||
mode: 'tscm',
|
||
visualMode: 'enriched',
|
||
collapsed: true
|
||
}
|
||
},
|
||
'bluetooth': {
|
||
container: 'bluetoothTimelineContainer',
|
||
config: typeof BluetoothTimelineAdapter !== 'undefined' ? BluetoothTimelineAdapter.getBluetoothConfig() : {
|
||
title: 'Device Activity',
|
||
mode: 'bluetooth',
|
||
visualMode: 'enriched',
|
||
collapsed: false
|
||
}
|
||
},
|
||
'wifi': {
|
||
container: 'wifiTimelineContainer',
|
||
config: typeof WiFiTimelineAdapter !== 'undefined' ? WiFiTimelineAdapter.getWiFiConfig() : {
|
||
title: 'Network Activity',
|
||
mode: 'wifi',
|
||
visualMode: 'enriched',
|
||
collapsed: false
|
||
}
|
||
}
|
||
};
|
||
|
||
const modeConfig = configs[mode];
|
||
if (!modeConfig) return;
|
||
|
||
const container = document.getElementById(modeConfig.container);
|
||
if (!container) return;
|
||
|
||
// Create timeline using new ActivityTimeline
|
||
// For TSCM mode, use SignalTimeline.create() to ensure backward compatibility
|
||
// with SignalTimeline.addEvent() calls used in TSCM event handlers
|
||
if (mode === 'tscm' && typeof SignalTimeline !== 'undefined') {
|
||
SignalTimeline.create(modeConfig.container, modeConfig.config);
|
||
modeTimelines[mode] = { addEvent: (e) => SignalTimeline.addEvent(e.id, e.strength, e.duration, e.label) };
|
||
} else if (typeof ActivityTimeline !== 'undefined') {
|
||
modeTimelines[mode] = ActivityTimeline.create(modeConfig.container, modeConfig.config);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add event to a mode's timeline
|
||
*/
|
||
function addTimelineEvent(mode, eventData) {
|
||
const timeline = modeTimelines[mode];
|
||
if (timeline) {
|
||
timeline.addEvent(eventData);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get timeline instance for a mode
|
||
*/
|
||
function getTimeline(mode) {
|
||
return modeTimelines[mode] || null;
|
||
}
|
||
|
||
// Selected mode from welcome screen
|
||
const savedDefaultMode = localStorage.getItem('intercept.default_mode');
|
||
let selectedStartMode = savedDefaultMode === 'listening' ? 'waterfall' : (savedDefaultMode || 'pager');
|
||
if (savedDefaultMode === 'listening') {
|
||
localStorage.setItem('intercept.default_mode', 'waterfall');
|
||
}
|
||
|
||
// Mode selection from welcome page
|
||
function selectMode(mode) {
|
||
selectedStartMode = mode;
|
||
const welcome = document.getElementById('welcomePage');
|
||
welcome.classList.add('fade-out');
|
||
|
||
// After fade out, hide welcome and switch to mode
|
||
setTimeout(() => {
|
||
welcome.style.display = 'none';
|
||
switchMode(mode, { updateUrl: true });
|
||
}, 400);
|
||
}
|
||
|
||
// Disclaimer handling - show on page load if not accepted
|
||
function showDisclaimer() {
|
||
document.getElementById('disclaimerModal').style.display = 'flex';
|
||
}
|
||
|
||
// Mode from query string (e.g., /?mode=wifi)
|
||
let pendingStartMode = null;
|
||
const modeCatalog = {
|
||
pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' },
|
||
sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' },
|
||
rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals' },
|
||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
|
||
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
|
||
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
|
||
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
|
||
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
|
||
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
|
||
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
|
||
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
|
||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
||
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
|
||
};
|
||
const validModes = new Set(Object.keys(modeCatalog));
|
||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||
|
||
function getModeFromQuery() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const requestedMode = params.get('mode');
|
||
const mode = requestedMode === 'listening' ? 'waterfall' : requestedMode;
|
||
if (!mode || !validModes.has(mode)) return null;
|
||
return mode;
|
||
}
|
||
|
||
function applyModeFromQuery() {
|
||
const mode = getModeFromQuery();
|
||
if (!mode) return;
|
||
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||
if (accepted) {
|
||
const welcome = document.getElementById('welcomePage');
|
||
if (welcome) welcome.style.display = 'none';
|
||
// Remove mode-gate style injected to prevent welcome flash
|
||
const modeGate = document.getElementById('mode-gate');
|
||
if (modeGate) modeGate.remove();
|
||
switchMode(mode, { updateUrl: false });
|
||
updateModeUrl(mode, true);
|
||
} else {
|
||
pendingStartMode = mode;
|
||
}
|
||
}
|
||
|
||
function applySettingsFromQuery() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get('settings') === '1') {
|
||
// Remove settings param from URL to avoid reopening on refresh
|
||
params.delete('settings');
|
||
const newUrl = params.toString()
|
||
? window.location.pathname + '?' + params.toString()
|
||
: window.location.pathname;
|
||
window.history.replaceState({}, '', newUrl);
|
||
// Open settings modal after a brief delay to ensure page is ready
|
||
setTimeout(() => {
|
||
if (typeof showSettings === 'function') {
|
||
showSettings();
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
function acceptDisclaimer() {
|
||
localStorage.setItem('disclaimerAccepted', 'true');
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
|
||
// After fade out, hide disclaimer and show welcome page
|
||
setTimeout(() => {
|
||
document.getElementById('disclaimerModal').style.display = 'none';
|
||
// Remove the gate CSS that was hiding welcome page
|
||
const gateStyle = document.getElementById('disclaimer-gate');
|
||
if (gateStyle) gateStyle.remove();
|
||
// Ensure welcome page is visible
|
||
const welcome = document.getElementById('welcomePage');
|
||
if (welcome) welcome.style.display = '';
|
||
if (pendingStartMode) {
|
||
// Bypass welcome and jump to requested mode
|
||
welcome.style.display = 'none';
|
||
switchMode(pendingStartMode, { updateUrl: true });
|
||
pendingStartMode = null;
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function declineDisclaimer() {
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||
}
|
||
|
||
// Show disclaimer on page load if not yet accepted
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
if (window._showDisclaimerOnLoad) {
|
||
showDisclaimer();
|
||
}
|
||
});
|
||
|
||
let eventSource = null;
|
||
let isRunning = false;
|
||
let isSensorRunning = false;
|
||
let isWifiRunning = false;
|
||
let isBtRunning = false;
|
||
let currentMode = 'pager';
|
||
let msgCount = 0;
|
||
let pocsagCount = 0;
|
||
let flexCount = 0;
|
||
let sensorCount = 0;
|
||
let filteredCount = 0; // Count of filtered messages
|
||
let deviceList = {{ devices | tojson | safe }};
|
||
|
||
// Pager message filter settings
|
||
let pagerFilters = {
|
||
hideToneOnly: true,
|
||
keywords: []
|
||
};
|
||
|
||
// UTC Clock Update
|
||
function updateHeaderClock() {
|
||
const now = new Date();
|
||
const utc = now.toISOString().substring(11, 19);
|
||
document.getElementById('headerUtcTime').textContent = utc;
|
||
}
|
||
|
||
function setActiveModeIndicator(label) {
|
||
const indicator = document.getElementById('activeModeIndicator');
|
||
if (!indicator) return;
|
||
|
||
indicator.textContent = '';
|
||
const dot = document.createElement('span');
|
||
dot.className = 'pulse-dot';
|
||
indicator.appendChild(dot);
|
||
indicator.appendChild(document.createTextNode(String(label || '')));
|
||
}
|
||
|
||
function applyKeyboardAccessibility(root = document) {
|
||
const interactive = root.querySelectorAll('[onclick]:not(button):not(a):not(input):not(select):not(textarea)');
|
||
interactive.forEach((el) => {
|
||
if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
|
||
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
|
||
el.setAttribute('data-keyboard-activate', 'true');
|
||
});
|
||
}
|
||
|
||
if (!window._keyboardActivationBound) {
|
||
window._keyboardActivationBound = true;
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
const target = event.target && event.target.closest ? event.target.closest('[data-keyboard-activate="true"]') : null;
|
||
if (!target) return;
|
||
event.preventDefault();
|
||
target.click();
|
||
});
|
||
}
|
||
|
||
// Update clock every second
|
||
setInterval(updateHeaderClock, 1000);
|
||
updateHeaderClock(); // Initial call
|
||
applyKeyboardAccessibility();
|
||
|
||
// Pager message filter functions
|
||
function loadPagerFilters() {
|
||
const saved = localStorage.getItem('pagerFilters');
|
||
if (saved) {
|
||
try {
|
||
pagerFilters = JSON.parse(saved);
|
||
} catch (e) {
|
||
console.warn('Failed to load pager filters:', e);
|
||
}
|
||
}
|
||
// Update UI
|
||
document.getElementById('filterToneOnly').checked = pagerFilters.hideToneOnly;
|
||
document.getElementById('filterKeywords').value = pagerFilters.keywords.join(', ');
|
||
}
|
||
|
||
function savePagerFilters() {
|
||
pagerFilters.hideToneOnly = document.getElementById('filterToneOnly').checked;
|
||
const keywordsInput = document.getElementById('filterKeywords').value;
|
||
pagerFilters.keywords = keywordsInput
|
||
.split(',')
|
||
.map(k => k.trim().toLowerCase())
|
||
.filter(k => k.length > 0);
|
||
localStorage.setItem('pagerFilters', JSON.stringify(pagerFilters));
|
||
}
|
||
|
||
function shouldFilterMessage(msg) {
|
||
// Check for Tone Only filter
|
||
if (pagerFilters.hideToneOnly) {
|
||
if (msg.message === '[Tone Only]' || msg.msg_type === 'Tone') {
|
||
return true;
|
||
}
|
||
}
|
||
// Check keyword filters
|
||
if (pagerFilters.keywords.length > 0) {
|
||
const msgLower = (msg.message || '').toLowerCase();
|
||
for (const keyword of pagerFilters.keywords) {
|
||
if (msgLower.includes(keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Sync header stats with output panel stats
|
||
function syncHeaderStats() {
|
||
// Pager stats
|
||
const headerMsgCount = document.getElementById('headerMsgCount');
|
||
const headerPocsagCount = document.getElementById('headerPocsagCount');
|
||
const headerFlexCount = document.getElementById('headerFlexCount');
|
||
if (headerMsgCount) headerMsgCount.textContent = msgCount;
|
||
if (headerPocsagCount) headerPocsagCount.textContent = pocsagCount;
|
||
if (headerFlexCount) headerFlexCount.textContent = flexCount;
|
||
|
||
// Sensor stats
|
||
const headerSensorCount = document.getElementById('headerSensorCount');
|
||
const headerDeviceTypeCount = document.getElementById('headerDeviceTypeCount');
|
||
if (headerSensorCount) headerSensorCount.textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||
if (headerDeviceTypeCount) headerDeviceTypeCount.textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||
|
||
// WiFi stats
|
||
const headerApCount = document.getElementById('headerApCount');
|
||
const headerClientCount = document.getElementById('headerClientCount');
|
||
const headerHandshakeCount = document.getElementById('headerHandshakeCount');
|
||
const headerDroneCount = document.getElementById('headerDroneCount');
|
||
if (headerApCount) headerApCount.textContent = document.getElementById('apCount')?.textContent || '0';
|
||
if (headerClientCount) headerClientCount.textContent = document.getElementById('clientCount')?.textContent || '0';
|
||
if (headerHandshakeCount) headerHandshakeCount.textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||
if (headerDroneCount) headerDroneCount.textContent = document.getElementById('droneCount')?.textContent || '0';
|
||
|
||
// Satellite stats
|
||
const headerPassCount = document.getElementById('headerPassCount');
|
||
if (headerPassCount) headerPassCount.textContent = document.getElementById('passCount')?.textContent || '0';
|
||
}
|
||
// Sync stats periodically
|
||
setInterval(syncHeaderStats, 500);
|
||
|
||
// Update relative timestamps on signal cards every 30 seconds
|
||
setInterval(function() {
|
||
const output = document.getElementById('output');
|
||
if (output && typeof SignalCards !== 'undefined') {
|
||
SignalCards.updateTimestamps(output);
|
||
}
|
||
}, 30000);
|
||
|
||
// Observer location for distance calculations (load from localStorage or default to London)
|
||
let observerLocation = (function () {
|
||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||
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) { }
|
||
}
|
||
return { lat: 51.5074, lon: -0.1278 };
|
||
})();
|
||
|
||
// GPS Dongle state
|
||
let gpsConnected = false;
|
||
let gpsEventSource = null;
|
||
let gpsLastPosition = null;
|
||
|
||
// Satellite state
|
||
let satellitePasses = [];
|
||
let selectedPass = null;
|
||
let selectedPassIndex = 0;
|
||
let countdownInterval = null;
|
||
|
||
// Start satellite countdown timer
|
||
function startCountdownTimer() {
|
||
if (countdownInterval) clearInterval(countdownInterval);
|
||
countdownInterval = setInterval(updateSatelliteCountdown, 1000);
|
||
}
|
||
|
||
// Update satellite countdown display
|
||
function updateSatelliteCountdown() {
|
||
// Update both main and popout countdowns
|
||
updateCountdownDisplay('');
|
||
updateCountdownDisplay('Popout');
|
||
}
|
||
|
||
// Helper to update countdown elements by suffix
|
||
function updateCountdownDisplay(suffix) {
|
||
const container = document.getElementById('satelliteCountdown' + suffix);
|
||
if (!container) return;
|
||
|
||
// Use the globally selected pass
|
||
if (!selectedPass || satellitePasses.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const now = new Date();
|
||
const startTime = parsePassTime(selectedPass.startTime);
|
||
const endTime = new Date(startTime.getTime() + selectedPass.duration * 60000);
|
||
|
||
container.style.display = 'block';
|
||
document.getElementById('countdownSatName' + suffix).textContent = selectedPass.satellite;
|
||
|
||
if (now >= startTime && now <= endTime) {
|
||
// Currently visible
|
||
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));
|
||
const mins = Math.floor(remaining / 60);
|
||
const secs = remaining % 60;
|
||
|
||
document.getElementById('countdownToPass' + suffix).textContent = 'VISIBLE';
|
||
document.getElementById('countdownToPass' + suffix).classList.add('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = 'Now overhead';
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Remaining';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'SATELLITE CURRENTLY VISIBLE';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status visible';
|
||
|
||
} else if (startTime > now) {
|
||
// Upcoming pass
|
||
const secsToPass = Math.max(0, Math.floor((startTime - now) / 1000));
|
||
const hours = Math.floor(secsToPass / 3600);
|
||
const mins = Math.floor((secsToPass % 3600) / 60);
|
||
const secs = secsToPass % 60;
|
||
|
||
let countdownStr;
|
||
if (hours > 0) {
|
||
countdownStr = `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
} else {
|
||
countdownStr = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
document.getElementById('countdownToPass' + suffix).textContent = countdownStr;
|
||
document.getElementById('countdownToPass' + suffix).classList.remove('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
if (secsToPass < 300) {
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'PASS STARTING SOON';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status upcoming';
|
||
} else {
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'Selected pass';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
|
||
}
|
||
|
||
} else {
|
||
// Pass already happened
|
||
document.getElementById('countdownToPass' + suffix).textContent = 'PASSED';
|
||
document.getElementById('countdownToPass' + suffix).classList.remove('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'Pass has ended';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
|
||
}
|
||
}
|
||
|
||
// Parse pass time string to Date object
|
||
function parsePassTime(timeStr) {
|
||
// Expected format: "2025-12-21 14:32 UTC"
|
||
// Remove "UTC" suffix and parse as ISO-like format
|
||
const cleanTime = timeStr.replace(' UTC', '').replace(' ', 'T') + ':00Z';
|
||
const parsed = new Date(cleanTime);
|
||
|
||
// Fallback if that doesn't work
|
||
if (isNaN(parsed.getTime())) {
|
||
// Try parsing as-is
|
||
return new Date(timeStr.replace(' UTC', ''));
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
const SIDEBAR_COLLAPSE_KEY = 'mainSidebarCollapsed';
|
||
|
||
function setMainSidebarCollapsed(collapsed) {
|
||
const mainContent = document.querySelector('.main-content');
|
||
const collapseBtn = document.getElementById('sidebarCollapseBtn');
|
||
if (!mainContent) return;
|
||
|
||
mainContent.classList.toggle('sidebar-collapsed', collapsed);
|
||
if (collapseBtn) {
|
||
collapseBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||
}
|
||
localStorage.setItem(SIDEBAR_COLLAPSE_KEY, collapsed ? 'true' : 'false');
|
||
}
|
||
|
||
function toggleMainSidebarCollapse(forceState = null) {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (!mainContent || window.innerWidth < 1024) return;
|
||
const collapsed = mainContent.classList.contains('sidebar-collapsed');
|
||
const nextState = forceState === null ? !collapsed : !!forceState;
|
||
setMainSidebarCollapsed(nextState);
|
||
}
|
||
|
||
function applySidebarCollapsePreference() {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (!mainContent) return;
|
||
if (window.innerWidth < 1024) {
|
||
mainContent.classList.remove('sidebar-collapsed');
|
||
return;
|
||
}
|
||
const savedCollapsed = localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true';
|
||
setMainSidebarCollapsed(savedCollapsed);
|
||
}
|
||
|
||
window.addEventListener('resize', applySidebarCollapsePreference);
|
||
|
||
// Make sections collapsible
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||
h3.addEventListener('click', function () {
|
||
this.parentElement.classList.toggle('collapsed');
|
||
});
|
||
});
|
||
|
||
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
|
||
document.querySelectorAll('.sidebar .section').forEach((section) => {
|
||
if (section.querySelector('h3')) {
|
||
section.classList.add('collapsed');
|
||
} else {
|
||
section.classList.remove('collapsed');
|
||
}
|
||
});
|
||
|
||
applySidebarCollapsePreference();
|
||
|
||
// Load bias-T setting from localStorage
|
||
loadBiasTSetting();
|
||
|
||
// Initialize device list from server-provided data
|
||
// This ensures currentDeviceList is populated on page load (fixes #99)
|
||
if (typeof deviceList !== 'undefined' && deviceList.length > 0) {
|
||
currentDeviceList = deviceList;
|
||
const firstType = deviceList[0].sdr_type || 'rtlsdr';
|
||
const sdrTypeSelect = document.getElementById('sdrTypeSelect');
|
||
if (sdrTypeSelect) {
|
||
sdrTypeSelect.value = firstType;
|
||
}
|
||
// Defer onSDRTypeChanged to ensure DOM is ready
|
||
setTimeout(onSDRTypeChanged, 0);
|
||
}
|
||
|
||
// Initialize observer location input fields from saved location
|
||
const obsLatInput = document.getElementById('obsLat');
|
||
const obsLonInput = document.getElementById('obsLon');
|
||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||
|
||
// Auto-connect to gpsd if available
|
||
autoConnectGps();
|
||
|
||
// Load pager message filters
|
||
loadPagerFilters();
|
||
|
||
// Initialize dropdown nav active state
|
||
updateDropdownActiveState();
|
||
|
||
// Start SDR device status polling
|
||
startSdrStatusPolling();
|
||
|
||
// Apply mode from URL query (e.g., /?mode=wifi)
|
||
applyModeFromQuery();
|
||
|
||
// Check for settings=1 query param (from dashboard settings button)
|
||
applySettingsFromQuery();
|
||
});
|
||
|
||
// Toggle section collapse
|
||
function toggleSection(el) {
|
||
el.closest('.section').classList.toggle('collapsed');
|
||
}
|
||
|
||
// Dropdown navigation
|
||
function toggleNavDropdown(group) {
|
||
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${group}"]`);
|
||
const isOpen = dropdown.classList.contains('open');
|
||
|
||
// Close all dropdowns first
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
|
||
|
||
// Open this one if it was closed
|
||
if (!isOpen) {
|
||
dropdown.classList.add('open');
|
||
}
|
||
}
|
||
|
||
function closeAllDropdowns() {
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
|
||
}
|
||
|
||
function updateDropdownActiveState() {
|
||
// Remove has-active from all dropdowns
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('has-active'));
|
||
|
||
// Add has-active to the dropdown containing the current mode
|
||
const activeGroup = modeCatalog[currentMode] ? modeCatalog[currentMode].group : null;
|
||
if (activeGroup) {
|
||
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${activeGroup}"]`);
|
||
if (dropdown) dropdown.classList.add('has-active');
|
||
}
|
||
}
|
||
|
||
// Close dropdowns when clicking outside
|
||
document.addEventListener('click', function (e) {
|
||
if (!e.target.closest('.mode-nav-dropdown')) {
|
||
closeAllDropdowns();
|
||
}
|
||
});
|
||
|
||
function updateModeUrl(mode, replace = false) {
|
||
if (!validModes.has(mode)) return;
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('mode', mode);
|
||
if (replace) {
|
||
window.history.replaceState({ mode }, '', url);
|
||
} else {
|
||
window.history.pushState({ mode }, '', url);
|
||
}
|
||
}
|
||
|
||
const LOCAL_STOP_TIMEOUT_MS = 2200;
|
||
const REMOTE_STOP_TIMEOUT_MS = 8000;
|
||
const DASHBOARD_NAV_PATHS = new Set([
|
||
'/adsb/dashboard',
|
||
'/ais/dashboard',
|
||
'/satellite/dashboard',
|
||
]);
|
||
|
||
function getActiveScanSummary() {
|
||
return {
|
||
pager: Boolean(isRunning),
|
||
sensor: Boolean(isSensorRunning),
|
||
morse: Boolean(
|
||
typeof MorseMode !== 'undefined'
|
||
&& typeof MorseMode.isActive === 'function'
|
||
&& MorseMode.isActive()
|
||
),
|
||
wifi: Boolean(
|
||
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
|
||
),
|
||
bluetooth: Boolean(
|
||
((typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) || isBtRunning)
|
||
),
|
||
aprs: Boolean(typeof isAprsRunning !== 'undefined' && isAprsRunning),
|
||
tscm: Boolean(typeof isTscmRunning !== 'undefined' && isTscmRunning),
|
||
};
|
||
}
|
||
|
||
function stopActiveLocalScansForNavigation() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
if (isAgentMode) return;
|
||
|
||
if (isRunning && typeof stopDecoding === 'function') {
|
||
Promise.resolve(stopDecoding()).catch(() => { });
|
||
}
|
||
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
|
||
Promise.resolve(stopSensorDecoding()).catch(() => { });
|
||
}
|
||
const morseActive = typeof MorseMode !== 'undefined'
|
||
&& typeof MorseMode.isActive === 'function'
|
||
&& MorseMode.isActive();
|
||
if (morseActive && typeof MorseMode.stop === 'function') {
|
||
Promise.resolve(MorseMode.stop()).catch(() => { });
|
||
}
|
||
|
||
const wifiScanActive = (
|
||
typeof WiFiMode !== 'undefined'
|
||
&& typeof WiFiMode.isScanning === 'function'
|
||
&& WiFiMode.isScanning()
|
||
) || isWifiRunning;
|
||
if (wifiScanActive && typeof stopWifiScan === 'function') {
|
||
Promise.resolve(stopWifiScan()).catch(() => { });
|
||
}
|
||
|
||
const btScanActive = (
|
||
typeof BluetoothMode !== 'undefined'
|
||
&& typeof BluetoothMode.isScanning === 'function'
|
||
&& BluetoothMode.isScanning()
|
||
) || isBtRunning;
|
||
if (btScanActive && typeof stopBtScan === 'function') {
|
||
Promise.resolve(stopBtScan()).catch(() => { });
|
||
}
|
||
|
||
if (typeof isAprsRunning !== 'undefined' && isAprsRunning && typeof stopAprs === 'function') {
|
||
Promise.resolve(stopAprs()).catch(() => { });
|
||
}
|
||
if (typeof isTscmRunning !== 'undefined' && isTscmRunning && typeof stopTscmSweep === 'function') {
|
||
Promise.resolve(stopTscmSweep()).catch(() => { });
|
||
}
|
||
}
|
||
|
||
if (!window._dashboardNavigationStopHookBound) {
|
||
window._dashboardNavigationStopHookBound = true;
|
||
document.addEventListener('click', (event) => {
|
||
if (event.defaultPrevented || event.button !== 0) return;
|
||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||
|
||
const link = event.target && event.target.closest
|
||
? event.target.closest('a[href]')
|
||
: null;
|
||
if (!link || link.target === '_blank') return;
|
||
|
||
try {
|
||
const href = new URL(link.href, window.location.href);
|
||
if (href.origin !== window.location.origin) return;
|
||
if (!DASHBOARD_NAV_PATHS.has(href.pathname)) return;
|
||
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||
window.InterceptNavPerf.markStart({
|
||
targetPath: href.pathname,
|
||
trigger: 'index-link',
|
||
sourceMode: currentMode,
|
||
activeScans: getActiveScanSummary(),
|
||
});
|
||
}
|
||
stopActiveLocalScansForNavigation();
|
||
} catch (_) {
|
||
// Ignore malformed hrefs.
|
||
}
|
||
});
|
||
}
|
||
|
||
function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||
const started = performance.now();
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
...(controller ? { signal: controller.signal } : {}),
|
||
})
|
||
.then((response) => response.json().catch(() => ({ status: response.ok ? 'ok' : 'error' })))
|
||
.catch((err) => {
|
||
if (err && err.name === 'AbortError') {
|
||
console.warn(`[Stop] ${url} timed out after ${timeoutMs}ms`);
|
||
return { status: 'timeout', timed_out: true };
|
||
}
|
||
console.warn(`[Stop] ${url} failed: ${err?.message || err}`);
|
||
return { status: 'error', message: err?.message || String(err) };
|
||
})
|
||
.finally(() => {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
const elapsedMs = Math.round(performance.now() - started);
|
||
console.debug(`[Stop] ${url} finished in ${elapsedMs}ms`);
|
||
});
|
||
}
|
||
|
||
async function awaitStopAction(name, action, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||
const started = performance.now();
|
||
try {
|
||
const result = action();
|
||
const promise = (result && typeof result.then === 'function')
|
||
? result
|
||
: Promise.resolve(result);
|
||
await Promise.race([
|
||
promise,
|
||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||
]);
|
||
} catch (err) {
|
||
console.warn(`[ModeSwitch] stop ${name} failed: ${err?.message || err}`);
|
||
} finally {
|
||
const elapsedMs = Math.round(performance.now() - started);
|
||
console.debug(`[ModeSwitch] stop ${name} finished in ${elapsedMs}ms`);
|
||
}
|
||
}
|
||
|
||
// Mode switching
|
||
async function switchMode(mode, options = {}) {
|
||
const { updateUrl = true } = options;
|
||
const switchStartMs = performance.now();
|
||
const previousMode = currentMode;
|
||
if (mode === 'listening') mode = 'waterfall';
|
||
if (!validModes.has(mode)) mode = 'pager';
|
||
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
|
||
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
|
||
console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`);
|
||
})
|
||
: Promise.resolve();
|
||
// Only stop local scans if in local mode (not agent mode)
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const stopPhaseStartMs = performance.now();
|
||
let stopTaskCount = 0;
|
||
if (!isAgentMode) {
|
||
const stopTasks = [];
|
||
|
||
if (isRunning) {
|
||
stopTasks.push(awaitStopAction('pager', () => stopDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isSensorRunning) {
|
||
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const morseActive = typeof MorseMode !== 'undefined'
|
||
&& typeof MorseMode.isActive === 'function'
|
||
&& MorseMode.isActive();
|
||
if (morseActive && typeof MorseMode.stop === 'function') {
|
||
stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const wifiScanActive = (
|
||
typeof WiFiMode !== 'undefined'
|
||
&& typeof WiFiMode.isScanning === 'function'
|
||
&& WiFiMode.isScanning()
|
||
) || isWifiRunning;
|
||
if (wifiScanActive) {
|
||
stopTasks.push(awaitStopAction('wifi', () => stopWifiScan(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const btScanActive = (typeof BluetoothMode !== 'undefined' &&
|
||
typeof BluetoothMode.isScanning === 'function' &&
|
||
BluetoothMode.isScanning()) || isBtRunning;
|
||
const isBtModeTransition =
|
||
(currentMode === 'bluetooth' && mode === 'bt_locate') ||
|
||
(currentMode === 'bt_locate' && mode === 'bluetooth');
|
||
if (btScanActive && !isBtModeTransition && typeof stopBtScan === 'function') {
|
||
stopTasks.push(awaitStopAction('bluetooth', () => stopBtScan(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isAprsRunning) {
|
||
stopTasks.push(awaitStopAction('aprs', () => stopAprs(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isTscmRunning) {
|
||
stopTasks.push(awaitStopAction('tscm', () => stopTscmSweep(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
|
||
if (stopTasks.length) {
|
||
await Promise.allSettled(stopTasks);
|
||
}
|
||
stopTaskCount = stopTasks.length;
|
||
}
|
||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||
await styleReadyPromise;
|
||
|
||
// Clean up SubGHz SSE connection when leaving the mode
|
||
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
||
SubGhz.destroy();
|
||
}
|
||
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
|
||
MorseMode.destroy();
|
||
}
|
||
|
||
currentMode = mode;
|
||
document.body.setAttribute('data-mode', mode);
|
||
if (updateUrl) {
|
||
updateModeUrl(mode);
|
||
}
|
||
|
||
// Sync mode state with current agent/local after switching
|
||
if (isAgentMode && typeof syncAgentModeStates === 'function') {
|
||
// Re-sync with agent to update this mode's UI state
|
||
syncAgentModeStates(currentAgent);
|
||
} else if (!isAgentMode && typeof syncLocalModeStates === 'function') {
|
||
// Sync with local status
|
||
syncLocalModeStates();
|
||
}
|
||
|
||
// Close dropdowns and update active state
|
||
closeAllDropdowns();
|
||
updateDropdownActiveState();
|
||
|
||
// Remove active from all nav buttons, then add to the correct one
|
||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||
});
|
||
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||
});
|
||
document.getElementById('pagerMode')?.classList.toggle('active', mode === 'pager');
|
||
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
|
||
document.getElementById('wefaxMode')?.classList.toggle('active', mode === 'wefax');
|
||
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
|
||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
|
||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
||
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
||
|
||
|
||
const pagerStats = document.getElementById('pagerStats');
|
||
const sensorStats = document.getElementById('sensorStats');
|
||
const satelliteStats = document.getElementById('satelliteStats');
|
||
const wifiStats = document.getElementById('wifiStats');
|
||
if (pagerStats) pagerStats.style.display = mode === 'pager' ? 'flex' : 'none';
|
||
if (sensorStats) sensorStats.style.display = mode === 'sensor' ? 'flex' : 'none';
|
||
if (satelliteStats) satelliteStats.style.display = mode === 'satellite' ? 'flex' : 'none';
|
||
if (wifiStats) wifiStats.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||
|
||
// Update header stats groups
|
||
document.getElementById('headerPagerStats')?.classList.toggle('active', mode === 'pager');
|
||
document.getElementById('headerSensorStats')?.classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('headerSatelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('headerWifiStats')?.classList.toggle('active', mode === 'wifi');
|
||
|
||
// Show/hide dashboard buttons in nav bar
|
||
const satelliteDashboardBtn = document.getElementById('satelliteDashboardBtn');
|
||
if (satelliteDashboardBtn) satelliteDashboardBtn.style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||
|
||
// Update active mode indicator
|
||
const modeMeta = modeCatalog[mode] || {};
|
||
setActiveModeIndicator(modeMeta.indicator || mode.toUpperCase());
|
||
const wifiLayoutContainer = document.getElementById('wifiLayoutContainer');
|
||
const btLayoutContainer = document.getElementById('btLayoutContainer');
|
||
const satelliteVisuals = document.getElementById('satelliteVisuals');
|
||
const aprsVisuals = document.getElementById('aprsVisuals');
|
||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||
const sstvVisuals = document.getElementById('sstvVisuals');
|
||
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
|
||
const gpsVisuals = document.getElementById('gpsVisuals');
|
||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
||
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
||
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
|
||
if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none';
|
||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||
|
||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||
BtLocate.setActiveMode(mode === 'bt_locate');
|
||
}
|
||
|
||
// Hide sidebar by default for Meshtastic mode, show for others
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (mainContent) {
|
||
if (mode === 'meshtastic') {
|
||
mainContent.classList.add('mesh-sidebar-hidden');
|
||
} else {
|
||
mainContent.classList.remove('mesh-sidebar-hidden');
|
||
}
|
||
}
|
||
|
||
// Show/hide mode-specific timeline containers
|
||
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
|
||
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
|
||
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
|
||
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none';
|
||
const morseScopePanel = document.getElementById('morseScopePanel');
|
||
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
||
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
||
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
|
||
const morseDiagLog = document.getElementById('morseDiagLog');
|
||
if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none';
|
||
|
||
// Update output panel title based on mode
|
||
const outputTitle = document.getElementById('outputTitle');
|
||
if (outputTitle) outputTitle.textContent = modeMeta.outputTitle || 'Signal Monitor';
|
||
|
||
// Initialize mode-specific timelines
|
||
initializeModeTimeline(mode);
|
||
|
||
// Initialize TSCM mode when selected
|
||
if (mode === 'tscm') {
|
||
loadTscmBaselines();
|
||
refreshTscmDevices();
|
||
}
|
||
|
||
// Initialize/destroy Space Weather mode
|
||
if (mode !== 'spaceweather') {
|
||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||
}
|
||
|
||
// Suspend Weather Satellite background timers/streams when leaving the mode
|
||
if (mode !== 'weathersat') {
|
||
if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
|
||
}
|
||
|
||
// Suspend WeFax background streams when leaving the mode
|
||
if (mode !== 'wefax') {
|
||
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
|
||
}
|
||
|
||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||
const reconBtn = document.getElementById('reconBtn');
|
||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||
const reconPanel = document.getElementById('reconPanel');
|
||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
|
||
if (reconPanel) reconPanel.style.display = 'none';
|
||
if (reconBtn) reconBtn.style.display = 'none';
|
||
if (intelBtn) intelBtn.style.display = 'none';
|
||
} else {
|
||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||
// Restore panel visibility based on reconEnabled state
|
||
if (reconEnabled && reconPanel) {
|
||
reconPanel.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// Show agent selector for modes that support remote agents
|
||
const agentSection = document.getElementById('agentSection');
|
||
const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
|
||
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||
|
||
// Show RTL-SDR device section for modes that use it
|
||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||
if (rtlDeviceSection) {
|
||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse') ? 'block' : 'none';
|
||
// Save original sidebar position of SDR device section (once)
|
||
if (!rtlDeviceSection._origParent) {
|
||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling;
|
||
}
|
||
// For morse mode, move SDR device section inside the morse panel after the title
|
||
const morsePanel = document.getElementById('morseMode');
|
||
if (mode === 'morse' && morsePanel) {
|
||
const firstSection = morsePanel.querySelector('.section');
|
||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||
} else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
|
||
// Restore to original sidebar position when leaving morse mode
|
||
if (rtlDeviceSection._origNext) {
|
||
rtlDeviceSection._origNext.before(rtlDeviceSection);
|
||
} else {
|
||
rtlDeviceSection._origParent.appendChild(rtlDeviceSection);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Toggle mode-specific tool status displays
|
||
const toolStatusPager = document.getElementById('toolStatusPager');
|
||
const toolStatusSensor = document.getElementById('toolStatusSensor');
|
||
if (toolStatusPager) toolStatusPager.style.display = (mode === 'pager') ? 'grid' : 'none';
|
||
if (toolStatusSensor) toolStatusSensor.style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||
|
||
// Hide output console for modes with their own visualizations
|
||
const outputEl = document.getElementById('output');
|
||
const statusBar = document.querySelector('.status-bar');
|
||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'block';
|
||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'flex';
|
||
|
||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||
if (mode !== 'meshtastic') {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (mainContent) {
|
||
mainContent.classList.remove('mesh-sidebar-hidden');
|
||
}
|
||
}
|
||
|
||
// Load interfaces and initialize visualizations when switching modes
|
||
if (mode === 'wifi') {
|
||
refreshWifiInterfaces();
|
||
initRadar();
|
||
initWatchList();
|
||
// Initialize v2 WiFi components
|
||
if (typeof WiFiMode !== 'undefined') {
|
||
WiFiMode.init();
|
||
}
|
||
} else if (mode === 'bluetooth') {
|
||
refreshBtInterfaces();
|
||
initBtRadar();
|
||
} else if (mode === 'aprs') {
|
||
checkAprsTools();
|
||
initAprsMap();
|
||
// Fix map sizing on mobile after container becomes visible
|
||
setTimeout(() => {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
}, 100);
|
||
} else if (mode === 'satellite') {
|
||
initPolarPlot();
|
||
initSatelliteList();
|
||
} else if (mode === 'spystations') {
|
||
SpyStations.init();
|
||
} else if (mode === 'meshtastic') {
|
||
Meshtastic.init();
|
||
// Fix map sizing after container becomes visible
|
||
setTimeout(() => {
|
||
Meshtastic.invalidateMap();
|
||
}, 100);
|
||
} else if (mode === 'sstv') {
|
||
SSTV.init();
|
||
setTimeout(() => {
|
||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||
}, 120);
|
||
} else if (mode === 'weathersat') {
|
||
WeatherSat.init();
|
||
setTimeout(() => {
|
||
WeatherSat.invalidateMap();
|
||
}, 100);
|
||
} else if (mode === 'sstv_general') {
|
||
SSTVGeneral.init();
|
||
} else if (mode === 'gps') {
|
||
GPS.init();
|
||
} else if (mode === 'websdr') {
|
||
if (typeof initWebSDR === 'function') initWebSDR();
|
||
} else if (mode === 'subghz') {
|
||
SubGhz.init();
|
||
} else if (mode === 'bt_locate') {
|
||
BtLocate.init();
|
||
setTimeout(() => {
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||
}, 100);
|
||
setTimeout(() => {
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||
}, 320);
|
||
} else if (mode === 'wefax') {
|
||
WeFax.init();
|
||
} else if (mode === 'spaceweather') {
|
||
SpaceWeather.init();
|
||
} else if (mode === 'waterfall') {
|
||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||
} else if (mode === 'morse') {
|
||
MorseMode.init();
|
||
}
|
||
|
||
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
||
if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
|
||
Promise.resolve(Waterfall.destroy()).catch(() => {});
|
||
}
|
||
|
||
const totalMs = Math.round(performance.now() - switchStartMs);
|
||
console.info(
|
||
`[Perf] switchMode ${previousMode} -> ${mode}: stop=${stopPhaseMs}ms tasks=${stopTaskCount} total=${totalMs}ms`,
|
||
{
|
||
updateUrl,
|
||
agentMode: isAgentMode,
|
||
}
|
||
);
|
||
requestAnimationFrame(() => {
|
||
const firstFrameMs = Math.round(performance.now() - switchStartMs);
|
||
console.info(`[Perf] switchMode ${previousMode} -> ${mode}: first-frame=${firstFrameMs}ms`);
|
||
});
|
||
}
|
||
|
||
// Handle window resize for maps (especially important on mobile orientation change)
|
||
window.addEventListener('resize', function () {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||
if (typeof BtLocate !== 'undefined') BtLocate.invalidateMap();
|
||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||
});
|
||
|
||
window.addEventListener('popstate', function () {
|
||
const mode = getModeFromQuery();
|
||
if (mode && mode !== currentMode) {
|
||
switchMode(mode, { updateUrl: false });
|
||
}
|
||
});
|
||
|
||
// Also handle orientation changes explicitly for mobile
|
||
window.addEventListener('orientationchange', function () {
|
||
setTimeout(() => {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||
}, 200);
|
||
});
|
||
|
||
// Track unique sensor devices
|
||
let uniqueDevices = new Set();
|
||
|
||
// Sensor frequency
|
||
function setSensorFreq(freq) {
|
||
document.getElementById('sensorFrequency').value = freq;
|
||
if (isSensorRunning) {
|
||
fetch('/stop_sensor', { method: 'POST' })
|
||
.then(() => setTimeout(() => startSensorDecoding(), 500));
|
||
}
|
||
}
|
||
|
||
// --- Sensor Signal Scope ---
|
||
let sensorScopeCtx = null;
|
||
let sensorScopeAnim = null;
|
||
let sensorScopeHistory = [];
|
||
let sensorScopeWaveBuffer = [];
|
||
let sensorScopeDisplayWave = [];
|
||
const SENSOR_SCOPE_LEN = 200;
|
||
const SENSOR_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||
const SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||
const SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||
const SENSOR_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||
let sensorScopeRssi = 0;
|
||
let sensorScopeSnr = 0;
|
||
let sensorScopeTargetRssi = 0;
|
||
let sensorScopeTargetSnr = 0;
|
||
let sensorScopeMsgBurst = 0;
|
||
let sensorScopeLastWaveAt = 0;
|
||
let sensorScopeLastInputSample = 0;
|
||
|
||
function resizeSensorScopeCanvas(canvas) {
|
||
if (!canvas) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||
if (canvas.width !== width || canvas.height !== height) {
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
}
|
||
}
|
||
|
||
function buildSensorWaveformFallback(rssi, snr, noise, points = 160) {
|
||
const rssiNorm = Math.min(Math.max(Math.abs(rssi) / 40, 0), 1);
|
||
const snrNorm = Math.min(Math.max((snr + 5) / 35, 0), 1);
|
||
const noiseNorm = Math.min(Math.max(Math.abs(noise) / 40, 0), 1);
|
||
|
||
const amplitude = Math.max(0.06, Math.min(1.0, (0.6 * rssiNorm + 0.4 * snrNorm) - (0.22 * noiseNorm)));
|
||
const cycles = 3 + (snrNorm * 8);
|
||
const harmonic = 0.25 + (0.35 * snrNorm);
|
||
const hiss = 0.08 + (0.18 * noiseNorm);
|
||
const phase = (performance.now() * 0.002 * (1.4 + (snrNorm * 2.2))) % (Math.PI * 2);
|
||
|
||
const waveform = [];
|
||
for (let i = 0; i < points; i++) {
|
||
const t = points > 1 ? (i / (points - 1)) : 0;
|
||
const base = Math.sin((Math.PI * 2 * cycles * t) + phase);
|
||
const overtone = Math.sin((Math.PI * 2 * (cycles * 2.4) * t) + (phase * 0.7));
|
||
const noiseWobble = Math.sin((Math.PI * 2 * (cycles * 7.0) * t) + (phase * 2.1));
|
||
let sample = amplitude * (base + (harmonic * overtone) + (hiss * noiseWobble));
|
||
sample /= (1 + harmonic + hiss);
|
||
waveform.push(Math.round(Math.max(-1, Math.min(1, sample)) * 127));
|
||
}
|
||
return waveform;
|
||
}
|
||
|
||
function appendSensorWaveformSamples(waveform) {
|
||
if (!Array.isArray(waveform) || waveform.length === 0) return;
|
||
|
||
for (const packedSample of waveform) {
|
||
const sample = Number(packedSample);
|
||
if (!Number.isFinite(sample)) continue;
|
||
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||
sensorScopeLastInputSample += (normalized - sensorScopeLastInputSample) * SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||
sensorScopeWaveBuffer.push(sensorScopeLastInputSample);
|
||
}
|
||
|
||
if (sensorScopeWaveBuffer.length > SENSOR_SCOPE_WAVE_BUFFER_LEN) {
|
||
sensorScopeWaveBuffer.splice(0, sensorScopeWaveBuffer.length - SENSOR_SCOPE_WAVE_BUFFER_LEN);
|
||
}
|
||
sensorScopeLastWaveAt = performance.now();
|
||
}
|
||
|
||
function applySensorScopeData(scopeData) {
|
||
if (!scopeData || typeof scopeData !== 'object') return;
|
||
|
||
const parsedRssi = Number(scopeData.rssi);
|
||
const parsedSnr = Number(scopeData.snr);
|
||
const parsedNoise = Number(scopeData.noise);
|
||
|
||
const rssi = Number.isFinite(parsedRssi) ? parsedRssi : 0;
|
||
const snr = Number.isFinite(parsedSnr) ? parsedSnr : 0;
|
||
const noise = Number.isFinite(parsedNoise) ? parsedNoise : 0;
|
||
|
||
sensorScopeTargetRssi = rssi;
|
||
sensorScopeTargetSnr = snr;
|
||
|
||
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||
appendSensorWaveformSamples(scopeData.waveform);
|
||
} else {
|
||
appendSensorWaveformSamples(buildSensorWaveformFallback(rssi, snr, noise));
|
||
}
|
||
}
|
||
|
||
function initSensorScope() {
|
||
const canvas = document.getElementById('sensorScopeCanvas');
|
||
if (!canvas) return;
|
||
|
||
if (sensorScopeAnim) {
|
||
cancelAnimationFrame(sensorScopeAnim);
|
||
sensorScopeAnim = null;
|
||
}
|
||
|
||
resizeSensorScopeCanvas(canvas);
|
||
sensorScopeCtx = canvas.getContext('2d');
|
||
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
|
||
sensorScopeWaveBuffer = [];
|
||
sensorScopeDisplayWave = [];
|
||
sensorScopeRssi = 0;
|
||
sensorScopeSnr = 0;
|
||
sensorScopeTargetRssi = 0;
|
||
sensorScopeTargetSnr = 0;
|
||
sensorScopeMsgBurst = 0;
|
||
sensorScopeLastWaveAt = 0;
|
||
sensorScopeLastInputSample = 0;
|
||
drawSensorScope();
|
||
}
|
||
|
||
function drawSensorScope() {
|
||
const ctx = sensorScopeCtx;
|
||
if (!ctx) return;
|
||
|
||
resizeSensorScopeCanvas(ctx.canvas);
|
||
const W = ctx.canvas.width;
|
||
const H = ctx.canvas.height;
|
||
const midY = H / 2;
|
||
|
||
// Phosphor persistence
|
||
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Smooth towards targets
|
||
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
|
||
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
|
||
|
||
// Decay targets back to idle between updates
|
||
sensorScopeTargetRssi *= 0.97;
|
||
sensorScopeTargetSnr *= 0.97;
|
||
|
||
// Keep amplitude envelope for context
|
||
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
|
||
sensorScopeHistory.push(rssiNorm);
|
||
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
|
||
sensorScopeHistory.shift();
|
||
}
|
||
|
||
// Grid lines (horizontal + vertical)
|
||
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
|
||
ctx.lineWidth = 0.8;
|
||
for (let i = 1; i < 8; i++) {
|
||
const gx = (W / 8) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(gx, 0);
|
||
ctx.lineTo(gx, H);
|
||
ctx.stroke();
|
||
}
|
||
for (let g = 0.25; g < 1; g += 0.25) {
|
||
const gy = midY - g * midY;
|
||
const gy2 = midY + g * midY;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Center baseline
|
||
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, midY);
|
||
ctx.lineTo(W, midY);
|
||
ctx.stroke();
|
||
|
||
// Slow envelope as context around baseline
|
||
const envStepX = W / (SENSOR_SCOPE_LEN - 1);
|
||
ctx.strokeStyle = 'rgba(86, 230, 120, 0.45)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = sensorScopeHistory[i] * midY * 0.85;
|
||
const y = midY - amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = sensorScopeHistory[i] * midY * 0.85;
|
||
const y = midY + amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Actual waveform trace
|
||
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||
if (sensorScopeWaveBuffer.length > 1) {
|
||
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
|
||
const sourceLen = sensorScopeWaveBuffer.length;
|
||
const sourceWindow = Math.min(sourceLen, 1536);
|
||
const sourceStart = sourceLen - sourceWindow;
|
||
|
||
if (sensorScopeDisplayWave.length !== waveformPointCount) {
|
||
sensorScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||
}
|
||
|
||
for (let i = 0; i < waveformPointCount; i++) {
|
||
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let j = start; j < end; j++) {
|
||
sum += sensorScopeWaveBuffer[j];
|
||
count++;
|
||
}
|
||
const targetSample = count > 0 ? (sum / count) : 0;
|
||
sensorScopeDisplayWave[i] += (targetSample - sensorScopeDisplayWave[i]) * SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||
}
|
||
|
||
ctx.strokeStyle = waveIsFresh ? '#40ff7a' : 'rgba(64, 255, 122, 0.45)';
|
||
ctx.lineWidth = 1.7;
|
||
ctx.shadowColor = '#40ff7a';
|
||
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||
|
||
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||
ctx.beginPath();
|
||
const firstY = midY - (sensorScopeDisplayWave[0] * midY * 0.9);
|
||
ctx.moveTo(0, firstY);
|
||
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||
const x = i * stepX;
|
||
const y = midY - (sensorScopeDisplayWave[i] * midY * 0.9);
|
||
const nx = (i + 1) * stepX;
|
||
const ny = midY - (sensorScopeDisplayWave[i + 1] * midY * 0.9);
|
||
const cx = (x + nx) / 2;
|
||
const cy = (y + ny) / 2;
|
||
ctx.quadraticCurveTo(x, y, cx, cy);
|
||
}
|
||
const lastX = (waveformPointCount - 1) * stepX;
|
||
const lastY = midY - (sensorScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||
ctx.lineTo(lastX, lastY);
|
||
ctx.stroke();
|
||
|
||
if (!waveIsFresh) {
|
||
for (let i = 0; i < sensorScopeDisplayWave.length; i++) {
|
||
sensorScopeDisplayWave[i] *= SENSOR_SCOPE_WAVE_IDLE_DECAY;
|
||
}
|
||
}
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
|
||
// SNR indicator (amber dashed line)
|
||
const snrNorm = Math.min(Math.max(Math.abs(sensorScopeSnr) / 40, 0), 1.0);
|
||
if (snrNorm > 0.01) {
|
||
const snrY = midY - snrNorm * midY * 0.9;
|
||
ctx.strokeStyle = 'rgba(255, 170, 0, 0.6)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, snrY);
|
||
ctx.lineTo(W, snrY);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// Sensor decode flash
|
||
if (sensorScopeMsgBurst > 0.01) {
|
||
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
|
||
ctx.fillRect(0, 0, W, H);
|
||
sensorScopeMsgBurst *= 0.88;
|
||
}
|
||
|
||
// Update labels
|
||
const rssiLabel = document.getElementById('sensorScopeRssiLabel');
|
||
const snrLabel = document.getElementById('sensorScopeSnrLabel');
|
||
const statusLabel = document.getElementById('sensorScopeStatusLabel');
|
||
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
|
||
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
|
||
if (statusLabel) {
|
||
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
|
||
if (Math.abs(sensorScopeRssi) > 1.2 && waveIsFresh) {
|
||
statusLabel.textContent = 'DEMODULATING';
|
||
statusLabel.style.color = '#40ff7a';
|
||
} else if (Math.abs(sensorScopeRssi) > 0.6) {
|
||
statusLabel.textContent = 'CARRIER';
|
||
statusLabel.style.color = '#78ff9a';
|
||
} else {
|
||
statusLabel.textContent = 'QUIET';
|
||
statusLabel.style.color = '#555';
|
||
}
|
||
}
|
||
|
||
sensorScopeAnim = requestAnimationFrame(drawSensorScope);
|
||
}
|
||
|
||
function stopSensorScope() {
|
||
if (sensorScopeAnim) {
|
||
cancelAnimationFrame(sensorScopeAnim);
|
||
sensorScopeAnim = null;
|
||
}
|
||
sensorScopeCtx = null;
|
||
sensorScopeWaveBuffer = [];
|
||
sensorScopeDisplayWave = [];
|
||
sensorScopeHistory = [];
|
||
sensorScopeLastWaveAt = 0;
|
||
sensorScopeLastInputSample = 0;
|
||
}
|
||
|
||
// Start sensor decoding
|
||
function startSensorDecoding() {
|
||
const freq = document.getElementById('sensorFrequency').value;
|
||
const gain = document.getElementById('sensorGain').value;
|
||
const ppm = document.getElementById('sensorPpm').value;
|
||
const device = getSelectedDevice();
|
||
|
||
// Check if using remote agent
|
||
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
|
||
// Check for conflicts with other running SDR modes
|
||
if (typeof checkAgentModeConflict === 'function' && !checkAgentModeConflict('sensor')) {
|
||
return; // User cancelled or conflict not resolved
|
||
}
|
||
|
||
// Route through agent proxy
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
ppm: ppm,
|
||
device: device
|
||
};
|
||
|
||
fetch(`/controller/agents/${currentAgent}/sensor/start`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
// Handle controller proxy response (agent response is nested in 'result')
|
||
const scanResult = data.result || data;
|
||
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
||
setSensorRunning(true);
|
||
startAgentSensorStream();
|
||
showInfo(`Sensor started on remote agent`);
|
||
} else {
|
||
alert('Error: ' + (scanResult.message || 'Failed to start sensor on agent'));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
alert('Error connecting to agent: ' + err.message);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Check if device is available
|
||
if (!checkDeviceAvailability('sensor')) {
|
||
return;
|
||
}
|
||
|
||
// Check for remote SDR
|
||
const remoteConfig = getRemoteSDRConfig();
|
||
if (remoteConfig === false) return; // Validation failed
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
ppm: ppm,
|
||
device: device,
|
||
sdr_type: getSelectedSDRType(),
|
||
bias_t: getBiasTEnabled()
|
||
};
|
||
|
||
// Add rtl_tcp params if using remote SDR
|
||
if (remoteConfig) {
|
||
config.rtl_tcp_host = remoteConfig.host;
|
||
config.rtl_tcp_port = remoteConfig.port;
|
||
}
|
||
|
||
fetch('/start_sensor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
reserveDevice(parseInt(device), 'sensor');
|
||
setSensorRunning(true);
|
||
startSensorStream();
|
||
|
||
// Initialize sensor filter bar
|
||
const filterContainer = document.getElementById('filterBarContainer');
|
||
const output = document.getElementById('output');
|
||
if (filterContainer) {
|
||
filterContainer.innerHTML = '';
|
||
const filterBar = SignalCards.createSensorFilterBar(output);
|
||
filterContainer.appendChild(filterBar);
|
||
filterContainer.style.display = 'block';
|
||
}
|
||
|
||
// Clear address history for fresh session
|
||
SignalCards.clearAddressHistory('sensor');
|
||
|
||
// Clear existing output
|
||
output.innerHTML = '<div class="placeholder signal-empty-state" style="display: none;"></div>';
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Stop sensor decoding
|
||
function stopSensorDecoding() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/sensor/stop`
|
||
: '/stop_sensor';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
setSensorRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (agentPollInterval) {
|
||
clearInterval(agentPollInterval);
|
||
agentPollInterval = null;
|
||
}
|
||
if (!isAgentMode) {
|
||
releaseDevice('sensor');
|
||
}
|
||
|
||
return postStopRequest(endpoint, timeoutMs).then((data) => {
|
||
if (isAgentMode && data && data.status !== 'error' && data.status !== 'timeout') {
|
||
showInfo('Sensor stopped on remote agent');
|
||
}
|
||
return data;
|
||
});
|
||
}
|
||
|
||
// Polling interval for agent data
|
||
let agentPollInterval = null;
|
||
|
||
// Start polling agent for sensor data
|
||
function startAgentSensorStream() {
|
||
if (agentPollInterval) {
|
||
clearInterval(agentPollInterval);
|
||
}
|
||
|
||
// Poll every 2 seconds for new data
|
||
agentPollInterval = setInterval(() => {
|
||
if (!isSensorRunning || currentAgent === 'local') {
|
||
clearInterval(agentPollInterval);
|
||
agentPollInterval = null;
|
||
return;
|
||
}
|
||
|
||
fetch(`/controller/agents/${currentAgent}/sensor/data`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.sensors) {
|
||
data.sensors.forEach(sensor => {
|
||
displaySensorMessage(sensor);
|
||
});
|
||
}
|
||
})
|
||
.catch(err => console.error('Agent poll error:', err));
|
||
}, 2000);
|
||
}
|
||
|
||
// Display a sensor message (works for both local and remote)
|
||
function displaySensorMessage(msg) {
|
||
const output = document.getElementById('output');
|
||
if (!output) return;
|
||
|
||
// Remove placeholder
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) placeholder.style.display = 'none';
|
||
|
||
// Agent polling may only return decoded packets, so derive scope updates from packet levels.
|
||
if (msg && (msg.rssi !== undefined || msg.snr !== undefined || msg.noise !== undefined)) {
|
||
applySensorScopeData(msg);
|
||
}
|
||
|
||
// Create signal card if SignalCards is available
|
||
if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) {
|
||
const card = SignalCards.createFromSensor(msg);
|
||
if (card) {
|
||
output.insertBefore(card, output.firstChild);
|
||
sensorCount++;
|
||
updateStats();
|
||
}
|
||
}
|
||
}
|
||
|
||
function setSensorRunning(running) {
|
||
isSensorRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
||
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
|
||
|
||
// Signal scope
|
||
const scopePanel = document.getElementById('sensorScopePanel');
|
||
if (scopePanel) {
|
||
if (running) {
|
||
scopePanel.style.display = 'block';
|
||
initSensorScope();
|
||
} else {
|
||
stopSensorScope();
|
||
scopePanel.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
function startSensorStream() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
|
||
eventSource = new EventSource('/stream_sensor');
|
||
|
||
eventSource.onopen = function () {
|
||
showInfo('Sensor stream connected...');
|
||
};
|
||
|
||
eventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'sensor') {
|
||
addSensorReading(data);
|
||
} else if (data.type === 'scope') {
|
||
applySensorScopeData(data);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setSensorRunning(false);
|
||
}
|
||
} else if (data.type === 'info' || data.type === 'raw') {
|
||
showInfo(data.text);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = function (e) {
|
||
console.error('Sensor stream error');
|
||
};
|
||
}
|
||
|
||
function addSensorReading(data) {
|
||
const output = document.getElementById('output');
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Store for export
|
||
allMessages.push(data);
|
||
playAlert();
|
||
pulseSignal();
|
||
|
||
// Flash sensor scope green on decode
|
||
sensorScopeMsgBurst = 1.0;
|
||
|
||
// Fallback when no dedicated scope packet has arrived recently.
|
||
if ((data.rssi !== undefined || data.snr !== undefined || data.noise !== undefined)
|
||
&& ((performance.now() - sensorScopeLastWaveAt) > 250)) {
|
||
applySensorScopeData(data);
|
||
}
|
||
|
||
sensorCount++;
|
||
document.getElementById('sensorCount').textContent = sensorCount;
|
||
|
||
// Track unique devices by model + id
|
||
const deviceKey = (data.model || 'Unknown') + '_' + (data.id || data.channel || '0');
|
||
if (!uniqueDevices.has(deviceKey)) {
|
||
uniqueDevices.add(deviceKey);
|
||
document.getElementById('deviceCount').textContent = uniqueDevices.size;
|
||
}
|
||
|
||
// Convert rtl_433 data format to our card format
|
||
const msg = {
|
||
model: data.model || 'Unknown',
|
||
id: data.id || data.channel || 'N/A',
|
||
channel: data.channel,
|
||
timestamp: data.time || new Date().toISOString(),
|
||
raw: data.raw,
|
||
frequency: data.freq
|
||
};
|
||
|
||
// Map common sensor fields
|
||
if (data.temperature_C !== undefined) {
|
||
msg.temperature = data.temperature_C;
|
||
msg.temperature_unit = 'C';
|
||
} else if (data.temperature_F !== undefined) {
|
||
msg.temperature = data.temperature_F;
|
||
msg.temperature_unit = 'F';
|
||
}
|
||
if (data.humidity !== undefined) msg.humidity = data.humidity;
|
||
if (data.battery_ok !== undefined) msg.battery = data.battery_ok ? 'OK' : 'LOW';
|
||
if (data.pressure_hPa !== undefined) {
|
||
msg.pressure = data.pressure_hPa;
|
||
msg.pressure_unit = 'hPa';
|
||
} else if (data.pressure_PSI !== undefined) {
|
||
msg.pressure = data.pressure_PSI;
|
||
msg.pressure_unit = 'PSI';
|
||
} else if (data.pressure_kPa !== undefined) {
|
||
msg.pressure = data.pressure_kPa;
|
||
msg.pressure_unit = 'kPa';
|
||
} else if (data.tire_pressure_kPa !== undefined) {
|
||
msg.pressure = data.tire_pressure_kPa;
|
||
msg.pressure_unit = 'kPa';
|
||
}
|
||
if (data.flags !== undefined) msg.state = data.flags;
|
||
else if (data.state !== undefined) msg.state = data.state;
|
||
if (data.wind_avg_km_h !== undefined) {
|
||
msg.wind_speed = data.wind_avg_km_h;
|
||
msg.wind_unit = 'km/h';
|
||
}
|
||
if (data.rain_mm !== undefined) {
|
||
msg.rain = data.rain_mm;
|
||
msg.rain_unit = 'mm';
|
||
}
|
||
|
||
// Create card using SignalCards component
|
||
const card = SignalCards.createSensorCard(msg);
|
||
output.insertBefore(card, output.firstChild);
|
||
|
||
// Add to activity timeline
|
||
if (typeof addTimelineEvent === 'function') {
|
||
addTimelineEvent('sensor', {
|
||
id: `${msg.model}-${msg.sensor_id}-${msg.timestamp}`,
|
||
label: msg.model || 'Unknown Sensor',
|
||
sublabel: msg.sensor_id ? `ID: ${msg.sensor_id}` : '',
|
||
timestamp: msg.timestamp || Date.now(),
|
||
type: 'sensor',
|
||
status: card.dataset.status || 'new'
|
||
});
|
||
}
|
||
|
||
// Update filter counts using sensor-specific filter bar
|
||
const sensorFilterBar = document.getElementById('sensorFilterBar');
|
||
if (sensorFilterBar && sensorFilterBar.applyFilters) {
|
||
sensorFilterBar.applyFilters();
|
||
}
|
||
|
||
if (autoScroll) output.scrollTop = 0;
|
||
|
||
// Keep list manageable
|
||
const cards = output.querySelectorAll('.signal-card');
|
||
while (cards.length > 100) {
|
||
output.removeChild(output.lastChild);
|
||
}
|
||
}
|
||
|
||
function toggleSensorLogging() {
|
||
const enabled = document.getElementById('sensorLogging').checked;
|
||
fetch('/logging', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enabled, log_file: 'sensor_data.log' })
|
||
});
|
||
}
|
||
|
||
// ========================================
|
||
// RTLAMR Functions
|
||
// ========================================
|
||
let isRtlamrRunning = false;
|
||
|
||
function setRtlamrFreq(freq) {
|
||
document.getElementById('rtlamrFrequency').value = freq;
|
||
}
|
||
|
||
// RTLAMR mode polling timer for agent mode
|
||
let rtlamrPollTimer = null;
|
||
let rtlamrCurrentAgent = null;
|
||
|
||
function startRtlamrDecoding() {
|
||
const freq = document.getElementById('rtlamrFrequency').value;
|
||
const gain = document.getElementById('rtlamrGain').value;
|
||
const ppm = document.getElementById('rtlamrPpm').value;
|
||
const device = getSelectedDevice();
|
||
const msgtype = document.getElementById('rtlamrMsgType').value;
|
||
const filterid = document.getElementById('rtlamrFilterId').value;
|
||
const unique = document.getElementById('rtlamrUnique').checked;
|
||
|
||
// Check if using agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
rtlamrCurrentAgent = isAgentMode ? currentAgent : null;
|
||
|
||
// Check if device is available (only for local mode)
|
||
if (!isAgentMode && !checkDeviceAvailability('rtlamr')) {
|
||
return;
|
||
}
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
ppm: ppm,
|
||
device: device,
|
||
msgtype: msgtype,
|
||
filterid: filterid,
|
||
unique: unique,
|
||
format: 'json'
|
||
};
|
||
|
||
// Determine endpoint based on agent mode
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/rtlamr/start`
|
||
: '/start_rtlamr';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
// Handle controller proxy response format
|
||
const scanResult = isAgentMode && data.result ? data.result : data;
|
||
|
||
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
||
if (!isAgentMode) {
|
||
reserveDevice(parseInt(device), 'rtlamr');
|
||
}
|
||
setRtlamrRunning(true);
|
||
startRtlamrStream(isAgentMode);
|
||
|
||
// Initialize meter filter bar (reuse sensor filter bar since same structure)
|
||
const filterContainer = document.getElementById('filterBarContainer');
|
||
const output = document.getElementById('output');
|
||
if (filterContainer) {
|
||
filterContainer.innerHTML = '';
|
||
const filterBar = SignalCards.createSensorFilterBar(output);
|
||
filterBar.id = 'meterFilterBar';
|
||
filterBar.querySelector('#sensorSearchInput').id = 'meterSearchInput';
|
||
filterBar.querySelector('#meterSearchInput').placeholder = 'Search meter ID...';
|
||
filterContainer.appendChild(filterBar);
|
||
filterContainer.style.display = 'block';
|
||
}
|
||
|
||
// Clear address history for fresh session
|
||
SignalCards.clearAddressHistory('meter');
|
||
|
||
// Clear existing output
|
||
output.innerHTML = '<div class="placeholder signal-empty-state" style="display: none;"></div>';
|
||
} else {
|
||
alert('Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopRtlamrDecoding() {
|
||
const isAgentMode = rtlamrCurrentAgent !== null;
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${rtlamrCurrentAgent}/rtlamr/stop`
|
||
: '/stop_rtlamr';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
rtlamrCurrentAgent = null;
|
||
setRtlamrRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (rtlamrPollTimer) {
|
||
clearInterval(rtlamrPollTimer);
|
||
rtlamrPollTimer = null;
|
||
}
|
||
if (!isAgentMode) {
|
||
releaseDevice('rtlamr');
|
||
}
|
||
|
||
return postStopRequest(endpoint, timeoutMs);
|
||
}
|
||
|
||
function setRtlamrRunning(running) {
|
||
isRtlamrRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
||
document.getElementById('startRtlamrBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopRtlamrBtn').style.display = running ? 'block' : 'none';
|
||
|
||
// Update mode indicator with frequency
|
||
if (running) {
|
||
const freq = document.getElementById('rtlamrFrequency').value;
|
||
setActiveModeIndicator('METERS @ ' + freq + ' MHz');
|
||
} else {
|
||
setActiveModeIndicator('METERS');
|
||
}
|
||
}
|
||
|
||
function startRtlamrStream(isAgentMode = false) {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
|
||
// Use different stream endpoint for agent mode
|
||
const streamUrl = isAgentMode ? '/controller/stream/all' : '/stream_rtlamr';
|
||
eventSource = new EventSource(streamUrl);
|
||
|
||
eventSource.onopen = function () {
|
||
showInfo('RTLAMR stream connected...');
|
||
};
|
||
|
||
eventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (isAgentMode) {
|
||
// Handle multi-agent stream format
|
||
if (data.scan_type === 'rtlamr' && data.payload) {
|
||
const payload = data.payload;
|
||
if (payload.type === 'rtlamr') {
|
||
payload.agent_name = data.agent_name;
|
||
addRtlamrReading(payload);
|
||
} else if (payload.type === 'status') {
|
||
if (payload.text === 'stopped') {
|
||
setRtlamrRunning(false);
|
||
}
|
||
} else if (payload.type === 'info' || payload.type === 'raw') {
|
||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||
}
|
||
}
|
||
} else {
|
||
// Local stream format
|
||
if (data.type === 'rtlamr') {
|
||
addRtlamrReading(data);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setRtlamrRunning(false);
|
||
}
|
||
} else if (data.type === 'info' || data.type === 'raw') {
|
||
showInfo(data.text);
|
||
}
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = function (e) {
|
||
console.error('RTLAMR stream error');
|
||
};
|
||
|
||
// Start polling fallback for agent mode
|
||
if (isAgentMode) {
|
||
startRtlamrPolling();
|
||
}
|
||
}
|
||
|
||
// Track last reading count for polling
|
||
let lastRtlamrReadingCount = 0;
|
||
|
||
function startRtlamrPolling() {
|
||
if (rtlamrPollTimer) return;
|
||
lastRtlamrReadingCount = 0;
|
||
|
||
const pollInterval = 2000;
|
||
rtlamrPollTimer = setInterval(async () => {
|
||
if (!isRtlamrRunning || !rtlamrCurrentAgent) {
|
||
clearInterval(rtlamrPollTimer);
|
||
rtlamrPollTimer = null;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/controller/agents/${rtlamrCurrentAgent}/rtlamr/data`);
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const result = data.result || data;
|
||
const readings = result.data || [];
|
||
|
||
// Process new readings
|
||
if (readings.length > lastRtlamrReadingCount) {
|
||
const newReadings = readings.slice(lastRtlamrReadingCount);
|
||
newReadings.forEach(reading => {
|
||
const displayReading = {
|
||
type: 'rtlamr',
|
||
...reading,
|
||
agent_name: result.agent_name || 'Remote Agent'
|
||
};
|
||
addRtlamrReading(displayReading);
|
||
});
|
||
lastRtlamrReadingCount = readings.length;
|
||
}
|
||
} catch (err) {
|
||
console.error('RTLAMR polling error:', err);
|
||
}
|
||
}, pollInterval);
|
||
}
|
||
|
||
function addRtlamrReading(data) {
|
||
const output = document.getElementById('output');
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Store for export (all raw readings)
|
||
allMessages.push(data);
|
||
pulseSignal();
|
||
|
||
sensorCount++;
|
||
document.getElementById('sensorCount').textContent = sensorCount;
|
||
|
||
// Aggregate meter data using MeterAggregator
|
||
const { meter, isNew } = MeterAggregator.ingest(data);
|
||
|
||
// Track unique meters by ID
|
||
const meterId = meter.id;
|
||
if (meterId !== 'Unknown') {
|
||
const deviceKey = 'METER_' + meterId;
|
||
if (!uniqueDevices.has(deviceKey)) {
|
||
uniqueDevices.add(deviceKey);
|
||
document.getElementById('deviceCount').textContent = uniqueDevices.size;
|
||
}
|
||
}
|
||
|
||
// Check if card already exists for this meter
|
||
const existingCard = document.getElementById('metercard_' + meterId);
|
||
|
||
if (existingCard) {
|
||
// Update existing card in place
|
||
SignalCards.updateAggregatedMeterCard(existingCard, meter);
|
||
} else {
|
||
// Create new aggregated meter card
|
||
const card = SignalCards.createAggregatedMeterCard(meter);
|
||
output.insertBefore(card, output.firstChild);
|
||
|
||
// Only play alert for new meters (not updates)
|
||
playAlert();
|
||
}
|
||
|
||
// Update filter counts
|
||
SignalCards.updateCounts(output);
|
||
|
||
// Limit to max 50 unique meters (cards won't pile up since we update in place)
|
||
const cards = output.querySelectorAll('.signal-card.meter-aggregated');
|
||
while (cards.length > 50) {
|
||
// Remove oldest card (last one)
|
||
const oldestCard = output.querySelector('.signal-card.meter-aggregated:last-of-type');
|
||
if (oldestCard) {
|
||
output.removeChild(oldestCard);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleRtlamrUnique() {
|
||
// No action needed, value is read on start
|
||
}
|
||
|
||
function toggleRtlamrLogging() {
|
||
const enabled = document.getElementById('rtlamrLogging').checked;
|
||
fetch('/logging', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enabled, log_file: 'rtlamr_data.log' })
|
||
});
|
||
}
|
||
|
||
// NOTE: Audio alert settings moved to static/js/core/audio.js
|
||
|
||
// Message storage for export
|
||
let allMessages = [];
|
||
|
||
function exportCSV() {
|
||
if (allMessages.length === 0) {
|
||
alert('No messages to export');
|
||
return;
|
||
}
|
||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||
const csv = [headers.join(',')];
|
||
allMessages.forEach(msg => {
|
||
const row = [
|
||
msg.timestamp || '',
|
||
msg.protocol || '',
|
||
msg.address || '',
|
||
msg.function || '',
|
||
msg.msg_type || '',
|
||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||
];
|
||
csv.push(row.join(','));
|
||
});
|
||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||
}
|
||
|
||
function exportJSON() {
|
||
if (allMessages.length === 0) {
|
||
alert('No messages to export');
|
||
return;
|
||
}
|
||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||
}
|
||
|
||
function downloadFile(content, filename, type) {
|
||
const blob = new Blob([content], { type });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Auto-scroll setting
|
||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||
|
||
function toggleAutoScroll() {
|
||
autoScroll = !autoScroll;
|
||
localStorage.setItem('autoScroll', autoScroll);
|
||
updateAutoScrollButton();
|
||
}
|
||
|
||
function updateAutoScrollButton() {
|
||
const btn = document.getElementById('autoScrollBtn');
|
||
if (btn) {
|
||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||
btn.classList.toggle('active', autoScroll);
|
||
}
|
||
}
|
||
|
||
// Signal activity meter
|
||
let signalActivity = 0;
|
||
let lastMessageTime = 0;
|
||
|
||
function updateSignalMeter() {
|
||
const now = Date.now();
|
||
const timeSinceLastMsg = now - lastMessageTime;
|
||
|
||
// Decay signal activity over time
|
||
if (timeSinceLastMsg > 1000) {
|
||
signalActivity = Math.max(0, signalActivity - 0.05);
|
||
}
|
||
|
||
const meter = document.getElementById('signalMeter');
|
||
const bars = meter?.querySelectorAll('.signal-bar');
|
||
if (bars) {
|
||
const activeBars = Math.ceil(signalActivity * bars.length);
|
||
bars.forEach((bar, i) => {
|
||
bar.classList.toggle('active', i < activeBars);
|
||
});
|
||
}
|
||
}
|
||
|
||
function pulseSignal() {
|
||
signalActivity = Math.min(1, signalActivity + 0.4);
|
||
lastMessageTime = Date.now();
|
||
}
|
||
|
||
// Relative timestamps
|
||
function getRelativeTime(timestamp) {
|
||
if (!timestamp) return '';
|
||
const now = new Date();
|
||
const parts = timestamp.split(':');
|
||
const msgTime = new Date();
|
||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||
|
||
const diff = Math.floor((now - msgTime) / 1000);
|
||
if (diff < 5) return 'just now';
|
||
if (diff < 60) return diff + 's ago';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
return timestamp;
|
||
}
|
||
|
||
function updateRelativeTimes() {
|
||
document.querySelectorAll('.msg-time').forEach(el => {
|
||
const ts = el.dataset.timestamp;
|
||
if (ts) el.textContent = getRelativeTime(ts);
|
||
});
|
||
}
|
||
|
||
// Update timers
|
||
setInterval(updateSignalMeter, 100);
|
||
setInterval(updateRelativeTimes, 10000);
|
||
|
||
// Default presets (UK frequencies)
|
||
const defaultPresets = ['153.350', '153.025'];
|
||
|
||
// Load presets from localStorage or use defaults
|
||
function loadPresets() {
|
||
const saved = localStorage.getItem('pagerPresets');
|
||
return saved ? JSON.parse(saved) : [...defaultPresets];
|
||
}
|
||
|
||
function savePresets(presets) {
|
||
localStorage.setItem('pagerPresets', JSON.stringify(presets));
|
||
}
|
||
|
||
function renderPresets() {
|
||
const presets = loadPresets();
|
||
const container = document.getElementById('presetButtons');
|
||
container.innerHTML = presets.map(freq =>
|
||
`<button class="preset-btn" onclick="setFreq('${freq}')" oncontextmenu="removePreset('${freq}'); return false;" title="Right-click to remove">${freq}</button>`
|
||
).join('');
|
||
}
|
||
|
||
function addPreset() {
|
||
const input = document.getElementById('newPresetFreq');
|
||
const freq = input.value.trim();
|
||
if (!freq || isNaN(parseFloat(freq))) {
|
||
alert('Please enter a valid frequency');
|
||
return;
|
||
}
|
||
const presets = loadPresets();
|
||
if (!presets.includes(freq)) {
|
||
presets.push(freq);
|
||
savePresets(presets);
|
||
renderPresets();
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
function removePreset(freq) {
|
||
if (confirm('Remove preset ' + freq + ' MHz?')) {
|
||
let presets = loadPresets();
|
||
presets = presets.filter(p => p !== freq);
|
||
savePresets(presets);
|
||
renderPresets();
|
||
}
|
||
}
|
||
|
||
function resetPresets() {
|
||
if (confirm('Reset to default presets?')) {
|
||
savePresets([...defaultPresets]);
|
||
renderPresets();
|
||
}
|
||
}
|
||
|
||
// Initialize presets on load
|
||
renderPresets();
|
||
|
||
// Initialize button states on load
|
||
updateMuteButton();
|
||
updateAutoScrollButton();
|
||
|
||
// NOTE: Audio context initialization moved to static/js/core/audio.js
|
||
|
||
function setFreq(freq) {
|
||
document.getElementById('frequency').value = freq;
|
||
// Auto-restart decoder with new frequency if currently running
|
||
if (isRunning) {
|
||
fetch('/stop', { method: 'POST' })
|
||
.then(() => {
|
||
setTimeout(() => startDecoding(), 500);
|
||
});
|
||
}
|
||
}
|
||
|
||
// SDR hardware capabilities
|
||
const sdrCapabilities = {
|
||
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
|
||
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
|
||
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
|
||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 },
|
||
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
|
||
};
|
||
|
||
// Current device list with SDR type info
|
||
let currentDeviceList = [];
|
||
|
||
// SDR Device Usage Tracking
|
||
// Tracks which mode is using which device index
|
||
const sdrDeviceUsage = {
|
||
// deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner')
|
||
};
|
||
|
||
function getDeviceInUseBy(deviceIndex) {
|
||
return sdrDeviceUsage[deviceIndex] || null;
|
||
}
|
||
|
||
function isDeviceInUse(deviceIndex) {
|
||
return sdrDeviceUsage[deviceIndex] !== undefined;
|
||
}
|
||
|
||
function reserveDevice(deviceIndex, modeName) {
|
||
sdrDeviceUsage[deviceIndex] = modeName;
|
||
updateDeviceSelectStatus();
|
||
}
|
||
|
||
function releaseDevice(modeName) {
|
||
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) {
|
||
if (mode === modeName) {
|
||
delete sdrDeviceUsage[idx];
|
||
}
|
||
}
|
||
updateDeviceSelectStatus();
|
||
}
|
||
|
||
function getAvailableDevice() {
|
||
// Find first device not in use
|
||
for (const device of currentDeviceList) {
|
||
if (!isDeviceInUse(device.index)) {
|
||
return device.index;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function updateDeviceSelectStatus() {
|
||
// Update device dropdown to show which devices are in use
|
||
const select = document.getElementById('deviceSelect');
|
||
if (!select) return;
|
||
|
||
const options = select.querySelectorAll('option');
|
||
options.forEach(opt => {
|
||
const idx = parseInt(opt.value);
|
||
const usedBy = getDeviceInUseBy(idx);
|
||
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
|
||
if (usedBy) {
|
||
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;
|
||
opt.style.color = 'var(--accent-orange)';
|
||
} else {
|
||
opt.textContent = baseName;
|
||
opt.style.color = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function checkDeviceAvailability(modeName) {
|
||
const selectedDevice = parseInt(getSelectedDevice());
|
||
const usedBy = getDeviceInUseBy(selectedDevice);
|
||
|
||
if (usedBy && usedBy !== modeName) {
|
||
// Device is in use by another mode
|
||
const availableDevice = getAvailableDevice();
|
||
|
||
if (availableDevice !== null) {
|
||
// Another device is available - offer to switch
|
||
const switchDevice = confirm(
|
||
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}.\n\n` +
|
||
`Device ${availableDevice} is available. Switch to it?`
|
||
);
|
||
if (switchDevice) {
|
||
document.getElementById('deviceSelect').value = availableDevice;
|
||
return true; // Can proceed with new device
|
||
}
|
||
return false; // User declined to switch
|
||
} else {
|
||
// No other devices available
|
||
showNotification('SDR In Use',
|
||
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}. ` +
|
||
`No other SDR devices available. Stop ${usedBy} first or connect another SDR.`
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
return true; // Device is available
|
||
}
|
||
|
||
function onSDRTypeChanged() {
|
||
const sdrType = document.getElementById('sdrTypeSelect').value;
|
||
const select = document.getElementById('deviceSelect');
|
||
|
||
// Filter devices by selected SDR type
|
||
const filteredDevices = currentDeviceList.filter(d =>
|
||
(d.sdr_type || 'rtlsdr') === sdrType
|
||
);
|
||
|
||
if (filteredDevices.length === 0) {
|
||
select.innerHTML = `<option value="0">No ${sdrCapabilities[sdrType]?.name || sdrType} devices found</option>`;
|
||
} else {
|
||
select.innerHTML = filteredDevices.map(d => {
|
||
const serialSuffix = d.serial && d.serial !== 'N/A' && d.serial !== 'Unknown' ? ` (SN: ${d.serial})` : '';
|
||
return `<option value="${d.index}" data-sdr-type="${d.sdr_type || 'rtlsdr'}">${d.index}: ${d.name}${serialSuffix}</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Update capabilities display
|
||
updateCapabilitiesDisplay(sdrType);
|
||
}
|
||
|
||
function updateCapabilitiesDisplay(sdrType) {
|
||
const caps = sdrCapabilities[sdrType];
|
||
if (caps) {
|
||
document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`;
|
||
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
|
||
}
|
||
}
|
||
|
||
function refreshDevices() {
|
||
fetch('/devices')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
// Store full device list with SDR type info
|
||
currentDeviceList = devices;
|
||
deviceList = devices;
|
||
|
||
// Auto-select SDR type if devices found
|
||
if (devices.length > 0) {
|
||
const firstType = devices[0].sdr_type || 'rtlsdr';
|
||
document.getElementById('sdrTypeSelect').value = firstType;
|
||
}
|
||
|
||
// Trigger filter update
|
||
onSDRTypeChanged();
|
||
|
||
// Also refresh SDR status panel
|
||
fetchSdrStatus();
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to refresh devices:', err);
|
||
const select = document.getElementById('deviceSelect');
|
||
select.innerHTML = '<option value="0">Error loading devices</option>';
|
||
});
|
||
}
|
||
|
||
// SDR Device Status Panel
|
||
let sdrStatusPollingInterval = null;
|
||
|
||
function renderSdrStatus(devices) {
|
||
const container = document.getElementById('sdrStatusList');
|
||
if (!container) return;
|
||
|
||
if (!devices || devices.length === 0) {
|
||
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">No SDR devices detected</div>';
|
||
return;
|
||
}
|
||
|
||
const html = devices.map(d => {
|
||
const isActive = d.in_use;
|
||
const statusDot = isActive
|
||
? '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #00ff88; box-shadow: 0 0 6px #00ff88; margin-right: 6px;"></span>'
|
||
: '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #555; margin-right: 6px;"></span>';
|
||
const modeName = d.used_by ? d.used_by.toUpperCase() : 'IDLE';
|
||
const modeColor = isActive ? '#00ff88' : '#666';
|
||
const sdrType = (d.sdr_type || 'RTL').toUpperCase().replace('RTLSDR', 'RTL');
|
||
|
||
return `<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-color);">
|
||
<div style="display: flex; align-items: center;">
|
||
${statusDot}
|
||
<span style="font-size: 11px;">#${d.index} ${d.name || 'Unknown'}${d.serial && d.serial !== 'N/A' && d.serial !== 'Unknown' ? ` (${d.serial})` : ''}</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<span style="font-size: 10px; color: ${modeColor}; font-weight: bold;">${modeName}</span>
|
||
<span style="font-size: 9px; padding: 1px 4px; background: var(--bg-tertiary); border-radius: 3px; color: #888;">${sdrType}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function fetchSdrStatus() {
|
||
// Avoid probing SDR inventory while HackRF SubGHz mode is active.
|
||
// Device discovery runs hackrf_info and can disrupt active HackRF streams.
|
||
if (typeof currentMode !== 'undefined' && currentMode === 'subghz') {
|
||
return;
|
||
}
|
||
fetch('/devices/status')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
renderSdrStatus(devices);
|
||
})
|
||
.catch(err => {
|
||
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
|
||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
|
||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
|
||
if (!transient) {
|
||
console.error('Failed to fetch SDR status:', err);
|
||
}
|
||
const container = document.getElementById('sdrStatusList');
|
||
if (container) {
|
||
if (transient) {
|
||
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">Status temporarily unavailable</div>';
|
||
} else {
|
||
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function startSdrStatusPolling() {
|
||
// Initial fetch
|
||
fetchSdrStatus();
|
||
// Poll every 5 seconds
|
||
sdrStatusPollingInterval = setInterval(fetchSdrStatus, 5000);
|
||
}
|
||
|
||
function stopSdrStatusPolling() {
|
||
if (sdrStatusPollingInterval) {
|
||
clearInterval(sdrStatusPollingInterval);
|
||
sdrStatusPollingInterval = null;
|
||
}
|
||
}
|
||
|
||
function getSelectedDevice() {
|
||
return document.getElementById('deviceSelect').value;
|
||
}
|
||
|
||
function getSelectedSDRType() {
|
||
return document.getElementById('sdrTypeSelect').value;
|
||
}
|
||
|
||
// Bias-T power setting
|
||
function saveBiasTSetting() {
|
||
const enabled = document.getElementById('biasT')?.checked || false;
|
||
localStorage.setItem('biasTEnabled', enabled);
|
||
}
|
||
|
||
function getBiasTEnabled() {
|
||
return document.getElementById('biasT')?.checked || false;
|
||
}
|
||
|
||
function loadBiasTSetting() {
|
||
const saved = localStorage.getItem('biasTEnabled');
|
||
if (saved === 'true') {
|
||
const checkbox = document.getElementById('biasT');
|
||
if (checkbox) checkbox.checked = true;
|
||
}
|
||
}
|
||
|
||
function toggleRemoteSDR() {
|
||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||
const configDiv = document.getElementById('remoteSDRConfig');
|
||
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
|
||
|
||
configDiv.style.display = useRemote ? 'block' : 'none';
|
||
|
||
// Dim local device controls when using remote
|
||
localControls.forEach(el => {
|
||
el.style.opacity = useRemote ? '0.5' : '1';
|
||
el.disabled = useRemote;
|
||
});
|
||
}
|
||
|
||
function getRemoteSDRConfig() {
|
||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||
if (!useRemote) return null;
|
||
|
||
const host = document.getElementById('rtlTcpHost').value.trim();
|
||
const port = parseInt(document.getElementById('rtlTcpPort').value) || 1234;
|
||
|
||
if (!host) {
|
||
alert('Please enter rtl_tcp host address');
|
||
return false;
|
||
}
|
||
|
||
return { host, port };
|
||
}
|
||
|
||
function getSelectedProtocols() {
|
||
const protocols = [];
|
||
if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512');
|
||
if (document.getElementById('proto_pocsag1200').checked) protocols.push('POCSAG1200');
|
||
if (document.getElementById('proto_pocsag2400').checked) protocols.push('POCSAG2400');
|
||
if (document.getElementById('proto_flex').checked) protocols.push('FLEX');
|
||
return protocols;
|
||
}
|
||
|
||
// Pager mode polling timer for agent mode
|
||
let pagerPollTimer = null;
|
||
|
||
// --- Pager Signal Scope ---
|
||
let pagerScopeCtx = null;
|
||
let pagerScopeAnim = null;
|
||
let pagerScopeHistory = [];
|
||
let pagerScopeWaveBuffer = [];
|
||
let pagerScopeDisplayWave = [];
|
||
const SCOPE_HISTORY_LEN = 200;
|
||
const SCOPE_WAVE_BUFFER_LEN = 2048;
|
||
const SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||
const SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||
const SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||
let pagerScopeRms = 0;
|
||
let pagerScopePeak = 0;
|
||
let pagerScopeTargetRms = 0;
|
||
let pagerScopeTargetPeak = 0;
|
||
let pagerScopeMsgBurst = 0;
|
||
let pagerScopeLastWaveAt = 0;
|
||
let pagerScopeLastInputSample = 0;
|
||
|
||
function resizePagerScopeCanvas(canvas) {
|
||
if (!canvas) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||
if (canvas.width !== width || canvas.height !== height) {
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
}
|
||
}
|
||
|
||
function applyPagerScopeData(scopeData) {
|
||
if (!scopeData || typeof scopeData !== 'object') return;
|
||
|
||
pagerScopeTargetRms = Number(scopeData.rms) || 0;
|
||
pagerScopeTargetPeak = Number(scopeData.peak) || 0;
|
||
|
||
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||
for (const packedSample of scopeData.waveform) {
|
||
const sample = Number(packedSample);
|
||
if (!Number.isFinite(sample)) continue;
|
||
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||
pagerScopeLastInputSample += (normalized - pagerScopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||
pagerScopeWaveBuffer.push(pagerScopeLastInputSample);
|
||
}
|
||
if (pagerScopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) {
|
||
pagerScopeWaveBuffer.splice(0, pagerScopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN);
|
||
}
|
||
pagerScopeLastWaveAt = performance.now();
|
||
}
|
||
}
|
||
|
||
function initPagerScope() {
|
||
const canvas = document.getElementById('pagerScopeCanvas');
|
||
if (!canvas) return;
|
||
|
||
if (pagerScopeAnim) {
|
||
cancelAnimationFrame(pagerScopeAnim);
|
||
pagerScopeAnim = null;
|
||
}
|
||
|
||
resizePagerScopeCanvas(canvas);
|
||
pagerScopeCtx = canvas.getContext('2d');
|
||
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
||
pagerScopeWaveBuffer = [];
|
||
pagerScopeDisplayWave = [];
|
||
pagerScopeRms = 0;
|
||
pagerScopePeak = 0;
|
||
pagerScopeTargetRms = 0;
|
||
pagerScopeTargetPeak = 0;
|
||
pagerScopeMsgBurst = 0;
|
||
pagerScopeLastWaveAt = 0;
|
||
pagerScopeLastInputSample = 0;
|
||
drawPagerScope();
|
||
}
|
||
|
||
function drawPagerScope() {
|
||
const ctx = pagerScopeCtx;
|
||
if (!ctx) return;
|
||
|
||
resizePagerScopeCanvas(ctx.canvas);
|
||
const W = ctx.canvas.width;
|
||
const H = ctx.canvas.height;
|
||
const midY = H / 2;
|
||
|
||
// Phosphor persistence
|
||
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Smooth towards target values
|
||
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
||
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
||
|
||
// Keep a slow amplitude envelope for readability
|
||
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
||
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
||
pagerScopeHistory.shift();
|
||
}
|
||
|
||
// Grid lines (horizontal + vertical)
|
||
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
||
ctx.lineWidth = 0.8;
|
||
for (let i = 1; i < 8; i++) {
|
||
const gx = (W / 8) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(gx, 0);
|
||
ctx.lineTo(gx, H);
|
||
ctx.stroke();
|
||
}
|
||
for (let g = 0.25; g < 1; g += 0.25) {
|
||
const gy = midY - g * midY;
|
||
const gy2 = midY + g * midY;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Center baseline
|
||
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, midY);
|
||
ctx.lineTo(W, midY);
|
||
ctx.stroke();
|
||
|
||
// Slow envelope as context around baseline
|
||
const envStepX = W / (SCOPE_HISTORY_LEN - 1);
|
||
ctx.strokeStyle = 'rgba(90, 180, 255, 0.45)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = pagerScopeHistory[i] * midY * 0.85;
|
||
const y = midY - amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = pagerScopeHistory[i] * midY * 0.85;
|
||
const y = midY + amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Actual waveform from real incoming audio samples
|
||
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||
if (pagerScopeWaveBuffer.length > 1) {
|
||
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
|
||
const sourceLen = pagerScopeWaveBuffer.length;
|
||
const sourceWindow = Math.min(sourceLen, 1536);
|
||
const sourceStart = sourceLen - sourceWindow;
|
||
|
||
if (pagerScopeDisplayWave.length !== waveformPointCount) {
|
||
pagerScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||
}
|
||
|
||
for (let i = 0; i < waveformPointCount; i++) {
|
||
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let j = start; j < end; j++) {
|
||
sum += pagerScopeWaveBuffer[j];
|
||
count++;
|
||
}
|
||
const targetSample = count > 0 ? (sum / count) : 0;
|
||
pagerScopeDisplayWave[i] += (targetSample - pagerScopeDisplayWave[i]) * SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||
}
|
||
|
||
ctx.strokeStyle = waveIsFresh ? '#2efbff' : 'rgba(46, 251, 255, 0.45)';
|
||
ctx.lineWidth = 1.7;
|
||
ctx.shadowColor = '#2efbff';
|
||
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||
|
||
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||
ctx.beginPath();
|
||
const firstY = midY - (pagerScopeDisplayWave[0] * midY * 0.9);
|
||
ctx.moveTo(0, firstY);
|
||
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||
const x = i * stepX;
|
||
const y = midY - (pagerScopeDisplayWave[i] * midY * 0.9);
|
||
const nx = (i + 1) * stepX;
|
||
const ny = midY - (pagerScopeDisplayWave[i + 1] * midY * 0.9);
|
||
const cx = (x + nx) / 2;
|
||
const cy = (y + ny) / 2;
|
||
ctx.quadraticCurveTo(x, y, cx, cy);
|
||
}
|
||
const lastX = (waveformPointCount - 1) * stepX;
|
||
const lastY = midY - (pagerScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||
ctx.lineTo(lastX, lastY);
|
||
ctx.stroke();
|
||
|
||
if (!waveIsFresh) {
|
||
for (let i = 0; i < pagerScopeDisplayWave.length; i++) {
|
||
pagerScopeDisplayWave[i] *= SCOPE_WAVE_IDLE_DECAY;
|
||
}
|
||
}
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
|
||
// Peak indicator (dashed red line)
|
||
const peakNorm = Math.min(pagerScopePeak / 32768, 1.0);
|
||
if (peakNorm > 0.01) {
|
||
const peakY = midY - peakNorm * midY * 0.9;
|
||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, peakY);
|
||
ctx.lineTo(W, peakY);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// Message decode flash (green overlay)
|
||
if (pagerScopeMsgBurst > 0.01) {
|
||
ctx.fillStyle = `rgba(0, 255, 100, ${pagerScopeMsgBurst * 0.15})`;
|
||
ctx.fillRect(0, 0, W, H);
|
||
pagerScopeMsgBurst *= 0.88;
|
||
}
|
||
|
||
// Update labels
|
||
const rmsLabel = document.getElementById('scopeRmsLabel');
|
||
const peakLabel = document.getElementById('scopePeakLabel');
|
||
const statusLabel = document.getElementById('scopeStatusLabel');
|
||
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
||
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
||
if (statusLabel) {
|
||
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
|
||
if (pagerScopeRms > 1300 && waveIsFresh) {
|
||
statusLabel.textContent = 'DEMODULATING';
|
||
statusLabel.style.color = '#00ff88';
|
||
} else if (pagerScopeRms > 500) {
|
||
statusLabel.textContent = 'CARRIER';
|
||
statusLabel.style.color = '#2efbff';
|
||
} else {
|
||
statusLabel.textContent = 'QUIET';
|
||
statusLabel.style.color = '#555';
|
||
}
|
||
}
|
||
|
||
pagerScopeAnim = requestAnimationFrame(drawPagerScope);
|
||
}
|
||
|
||
function stopPagerScope() {
|
||
if (pagerScopeAnim) {
|
||
cancelAnimationFrame(pagerScopeAnim);
|
||
pagerScopeAnim = null;
|
||
}
|
||
pagerScopeCtx = null;
|
||
pagerScopeWaveBuffer = [];
|
||
pagerScopeDisplayWave = [];
|
||
pagerScopeHistory = [];
|
||
pagerScopeLastWaveAt = 0;
|
||
pagerScopeLastInputSample = 0;
|
||
}
|
||
|
||
function startDecoding() {
|
||
const freq = document.getElementById('frequency').value;
|
||
const gain = document.getElementById('gain').value;
|
||
const squelch = document.getElementById('squelch').value;
|
||
const ppm = document.getElementById('ppm').value;
|
||
const device = getSelectedDevice();
|
||
const protocols = getSelectedProtocols();
|
||
|
||
if (protocols.length === 0) {
|
||
alert('Please select at least one protocol');
|
||
return;
|
||
}
|
||
|
||
// Check if using agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
// Check if device is available (only for local mode)
|
||
if (!isAgentMode && !checkDeviceAvailability('pager')) {
|
||
return;
|
||
}
|
||
|
||
// Check for remote SDR (only for local mode)
|
||
const remoteConfig = isAgentMode ? null : getRemoteSDRConfig();
|
||
if (remoteConfig === false) return; // Validation failed
|
||
|
||
const config = {
|
||
frequency: freq,
|
||
gain: gain,
|
||
squelch: squelch,
|
||
ppm: ppm,
|
||
device: device,
|
||
sdr_type: getSelectedSDRType(),
|
||
protocols: protocols,
|
||
bias_t: getBiasTEnabled()
|
||
};
|
||
|
||
// Add rtl_tcp params if using remote SDR (local mode only)
|
||
if (remoteConfig) {
|
||
config.rtl_tcp_host = remoteConfig.host;
|
||
config.rtl_tcp_port = remoteConfig.port;
|
||
}
|
||
|
||
// Determine endpoint based on agent mode
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/pager/start`
|
||
: '/start';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
// Handle controller proxy response format (agent response is nested in 'result')
|
||
const scanResult = isAgentMode && data.result ? data.result : data;
|
||
|
||
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
||
if (!isAgentMode) {
|
||
reserveDevice(parseInt(device), 'pager');
|
||
}
|
||
setRunning(true);
|
||
startStream(isAgentMode);
|
||
|
||
// Initialize filter bar
|
||
const filterContainer = document.getElementById('filterBarContainer');
|
||
const output = document.getElementById('output');
|
||
if (filterContainer) {
|
||
// Clear any existing filter bar and create pager filter
|
||
filterContainer.innerHTML = '';
|
||
const filterBar = SignalCards.createPagerFilterBar(output);
|
||
filterContainer.appendChild(filterBar);
|
||
filterContainer.style.display = 'block';
|
||
}
|
||
|
||
// Clear address history for fresh session
|
||
SignalCards.clearAddressHistory('pager');
|
||
} else {
|
||
alert('Error: ' + (scanResult.message || scanResult.error || 'Failed to start pager decoding'));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Start error:', err);
|
||
alert('Error starting pager decoding: ' + err.message);
|
||
});
|
||
}
|
||
|
||
function stopDecoding() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/pager/stop`
|
||
: '/stop';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
if (!isAgentMode) {
|
||
releaseDevice('pager');
|
||
}
|
||
setRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (pagerPollTimer) {
|
||
clearInterval(pagerPollTimer);
|
||
pagerPollTimer = null;
|
||
}
|
||
|
||
return postStopRequest(endpoint, timeoutMs);
|
||
}
|
||
|
||
function killAll() {
|
||
fetch('/killall', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Release all devices
|
||
Object.keys(sdrDeviceUsage).forEach(idx => delete sdrDeviceUsage[idx]);
|
||
updateDeviceSelectStatus();
|
||
|
||
setRunning(false);
|
||
setSensorRunning(false);
|
||
isScannerRunning = false;
|
||
isAudioPlaying = false;
|
||
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
showInfo('All processes stopped' + (data.processes.length ? ` (${data.processes.length} killed)` : ' (none were running)'));
|
||
});
|
||
}
|
||
|
||
function checkStatus() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/pager/status`
|
||
: '/status';
|
||
|
||
fetch(endpoint)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Handle agent response format (may be nested in 'result')
|
||
const statusData = isAgentMode && data.result ? data.result : data;
|
||
const running = statusData.running;
|
||
|
||
if (running !== isRunning) {
|
||
setRunning(running);
|
||
if (running && !eventSource) {
|
||
startStream(isAgentMode);
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Silently ignore - server may be restarting or network issue
|
||
});
|
||
}
|
||
|
||
// Periodic status check every 5 seconds
|
||
setInterval(checkStatus, 5000);
|
||
|
||
function toggleLogging() {
|
||
const enabled = document.getElementById('loggingEnabled').checked;
|
||
const logFile = document.getElementById('logFilePath').value;
|
||
fetch('/logging', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enabled, log_file: logFile })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
showInfo(data.logging ? 'Logging enabled: ' + data.log_file : 'Logging disabled');
|
||
});
|
||
}
|
||
|
||
function setRunning(running) {
|
||
isRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
||
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
||
|
||
// Signal scope
|
||
const scopePanel = document.getElementById('pagerScopePanel');
|
||
if (scopePanel) {
|
||
if (running) {
|
||
scopePanel.style.display = 'block';
|
||
initPagerScope();
|
||
} else {
|
||
stopPagerScope();
|
||
scopePanel.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
function startStream(isAgentMode = false) {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
|
||
// Use different stream endpoint for agent mode
|
||
const streamUrl = isAgentMode ? '/controller/stream/all' : '/stream';
|
||
eventSource = new EventSource(streamUrl);
|
||
|
||
eventSource.onopen = function () {
|
||
showInfo('Stream connected...');
|
||
};
|
||
|
||
eventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
// Handle multi-agent stream format
|
||
if (isAgentMode) {
|
||
// Multi-agent stream tags data with scan_type and agent_name
|
||
if (data.scan_type === 'pager' && data.payload) {
|
||
const payload = data.payload;
|
||
if (payload.type === 'message') {
|
||
// Add agent info to the message
|
||
payload.agent_name = data.agent_name;
|
||
addMessage(payload);
|
||
} else if (payload.type === 'status') {
|
||
if (payload.text === 'stopped') {
|
||
setRunning(false);
|
||
} else if (payload.text === 'started') {
|
||
showInfo(`Decoder started on ${data.agent_name}, waiting for signals...`);
|
||
}
|
||
} else if (payload.type === 'info') {
|
||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||
} else if (payload.type === 'scope') {
|
||
applyPagerScopeData(payload);
|
||
}
|
||
} else if (data.type === 'keepalive') {
|
||
// Ignore keepalive messages
|
||
}
|
||
} else {
|
||
// Local stream format
|
||
if (data.type === 'message') {
|
||
addMessage(data);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setRunning(false);
|
||
} else if (data.text === 'started') {
|
||
showInfo('Decoder started, waiting for signals...');
|
||
}
|
||
} else if (data.type === 'info') {
|
||
showInfo(data.text);
|
||
} else if (data.type === 'raw') {
|
||
showInfo(data.text);
|
||
} else if (data.type === 'scope') {
|
||
applyPagerScopeData(data);
|
||
}
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = function (e) {
|
||
checkStatus();
|
||
};
|
||
|
||
// Start polling fallback for agent mode (in case push isn't enabled)
|
||
if (isAgentMode) {
|
||
startPagerPolling();
|
||
}
|
||
}
|
||
|
||
// Track last message count to avoid duplicates during polling
|
||
let lastPagerMsgCount = 0;
|
||
|
||
function startPagerPolling() {
|
||
if (pagerPollTimer) return;
|
||
lastPagerMsgCount = 0;
|
||
|
||
const pollInterval = 2000; // 2 seconds
|
||
pagerPollTimer = setInterval(async () => {
|
||
if (!isRunning) {
|
||
clearInterval(pagerPollTimer);
|
||
pagerPollTimer = null;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/controller/agents/${currentAgent}/pager/data`);
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const result = data.result || data;
|
||
const modeData = result.data || result;
|
||
|
||
// Process messages from polling response
|
||
if (modeData.messages && Array.isArray(modeData.messages)) {
|
||
const newMsgs = modeData.messages.slice(lastPagerMsgCount);
|
||
newMsgs.forEach(msg => {
|
||
// Convert to expected format
|
||
const displayMsg = {
|
||
type: 'message',
|
||
protocol: msg.protocol || 'UNKNOWN',
|
||
address: msg.address || '',
|
||
function: msg.function || '',
|
||
msg_type: msg.msg_type || 'Alpha',
|
||
message: msg.message || '',
|
||
timestamp: msg.received_at || new Date().toISOString(),
|
||
agent_name: result.agent_name || 'Remote Agent'
|
||
};
|
||
addMessage(displayMsg);
|
||
});
|
||
lastPagerMsgCount = modeData.messages.length;
|
||
}
|
||
} catch (err) {
|
||
console.error('Pager polling error:', err);
|
||
}
|
||
}, pollInterval);
|
||
}
|
||
|
||
function addMessage(msg) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Store message for export (always, even if filtered)
|
||
allMessages.push(msg);
|
||
|
||
// Check if message should be filtered from display
|
||
const isFiltered = shouldFilterMessage(msg);
|
||
|
||
// Check if address is muted
|
||
const isMuted = SignalCards.isAddressMuted(msg.address);
|
||
|
||
// Update counts (always, even if filtered)
|
||
msgCount++;
|
||
document.getElementById('msgCount').textContent = msgCount;
|
||
|
||
let protoClass = '';
|
||
if (msg.protocol.includes('POCSAG')) {
|
||
pocsagCount++;
|
||
protoClass = 'pocsag';
|
||
document.getElementById('pocsagCount').textContent = pocsagCount;
|
||
} else if (msg.protocol.includes('FLEX')) {
|
||
flexCount++;
|
||
protoClass = 'flex';
|
||
document.getElementById('flexCount').textContent = flexCount;
|
||
}
|
||
|
||
// If filtered or muted, skip display but update filtered count
|
||
if (isFiltered || isMuted) {
|
||
filteredCount++;
|
||
return;
|
||
}
|
||
|
||
// Play audio alert (only for non-filtered messages)
|
||
playAlert();
|
||
|
||
// Update signal meter
|
||
pulseSignal();
|
||
|
||
// Flash signal scope green on decode
|
||
pagerScopeMsgBurst = 1.0;
|
||
|
||
// Use SignalCards component to create the message card (auto-detects status)
|
||
const msgEl = SignalCards.createPagerCard(msg);
|
||
|
||
output.insertBefore(msgEl, output.firstChild);
|
||
|
||
// Add to activity timeline
|
||
if (typeof addTimelineEvent === 'function') {
|
||
addTimelineEvent('pager', {
|
||
id: `${msg.address}-${msg.timestamp}`,
|
||
label: msg.address,
|
||
sublabel: msg.protocol,
|
||
timestamp: msg.timestamp || Date.now(),
|
||
type: 'pager',
|
||
status: msgEl.dataset.status || 'new'
|
||
});
|
||
}
|
||
|
||
// Update filter counts
|
||
SignalCards.updateCounts(output);
|
||
|
||
// Auto-scroll to top (newest messages)
|
||
if (autoScroll) {
|
||
output.scrollTop = 0;
|
||
}
|
||
|
||
// Limit messages displayed (keep placeholder/empty-state)
|
||
const cards = output.querySelectorAll('.signal-card');
|
||
while (cards.length > 100) {
|
||
output.removeChild(cards[cards.length - 1]);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
// Escape for use in HTML attributes (especially onclick handlers)
|
||
if (text === null || text === undefined) return '';
|
||
var s = String(text);
|
||
s = s.replace(/&/g, '&');
|
||
s = s.replace(/'/g, ''');
|
||
s = s.replace(/"/g, '"');
|
||
s = s.replace(/</g, '<');
|
||
s = s.replace(/>/g, '>');
|
||
return s;
|
||
}
|
||
|
||
function isValidMac(mac) {
|
||
// Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||
}
|
||
|
||
function isValidChannel(ch) {
|
||
// Validate WiFi channel (1-200 covers all bands)
|
||
const num = parseInt(ch, 10);
|
||
return !isNaN(num) && num >= 1 && num <= 200;
|
||
}
|
||
|
||
function showInfo(text) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Clear placeholder only (has the 'placeholder' class)
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
const infoEl = document.createElement('div');
|
||
infoEl.className = 'info-msg';
|
||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #888; word-break: break-all;';
|
||
infoEl.textContent = text;
|
||
output.insertBefore(infoEl, output.firstChild);
|
||
}
|
||
|
||
function showError(text) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Clear placeholder only (has the 'placeholder' class)
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
const errorEl = document.createElement('div');
|
||
errorEl.className = 'error-msg';
|
||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||
errorEl.textContent = '⚠ ' + text;
|
||
output.insertBefore(errorEl, output.firstChild);
|
||
}
|
||
|
||
function clearMessages() {
|
||
document.getElementById('output').innerHTML = `
|
||
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
|
||
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
|
||
</div>
|
||
`;
|
||
msgCount = 0;
|
||
pocsagCount = 0;
|
||
flexCount = 0;
|
||
sensorCount = 0;
|
||
filteredCount = 0;
|
||
allMessages = [];
|
||
uniqueDevices.clear();
|
||
document.getElementById('msgCount').textContent = '0';
|
||
document.getElementById('pocsagCount').textContent = '0';
|
||
document.getElementById('flexCount').textContent = '0';
|
||
document.getElementById('sensorCount').textContent = '0';
|
||
document.getElementById('deviceCount').textContent = '0';
|
||
|
||
// Clear meter aggregator data
|
||
if (typeof MeterAggregator !== 'undefined') {
|
||
MeterAggregator.clear();
|
||
}
|
||
|
||
// Reset recon data
|
||
deviceDatabase.clear();
|
||
newDeviceAlerts = 0;
|
||
anomalyAlerts = 0;
|
||
document.getElementById('trackedCount').textContent = '0';
|
||
document.getElementById('newDeviceCount').textContent = '0';
|
||
document.getElementById('anomalyCount').textContent = '0';
|
||
document.getElementById('reconContent').innerHTML = '<div style="color: #444; text-align: center; padding: 30px; font-size: 11px;">Device intelligence data will appear here as signals are intercepted.</div>';
|
||
}
|
||
|
||
// ============== DEVICE INTELLIGENCE & RECONNAISSANCE ==============
|
||
|
||
// Device tracking database
|
||
const deviceDatabase = new Map(); // key: deviceId, value: device profile
|
||
// Default to false if not set
|
||
let reconEnabled = localStorage.getItem('reconEnabled') === 'true';
|
||
let newDeviceAlerts = 0;
|
||
let anomalyAlerts = 0;
|
||
|
||
// Device profile structure
|
||
function createDeviceProfile(deviceId, protocol, firstSeen) {
|
||
return {
|
||
id: deviceId,
|
||
protocol: protocol,
|
||
firstSeen: firstSeen,
|
||
lastSeen: firstSeen,
|
||
transmissionCount: 1,
|
||
transmissions: [firstSeen], // timestamps of recent transmissions
|
||
avgInterval: null, // average time between transmissions
|
||
addresses: new Set(),
|
||
models: new Set(),
|
||
messages: [],
|
||
isNew: true,
|
||
anomalies: [],
|
||
signalStrength: [],
|
||
encrypted: null // null = unknown, true/false
|
||
};
|
||
}
|
||
|
||
// Analyze transmission patterns for anomalies
|
||
function analyzeTransmissions(profile) {
|
||
const anomalies = [];
|
||
const now = Date.now();
|
||
|
||
// Need at least 3 transmissions to analyze patterns
|
||
if (profile.transmissions.length < 3) {
|
||
return anomalies;
|
||
}
|
||
|
||
// Calculate intervals between transmissions
|
||
const intervals = [];
|
||
for (let i = 1; i < profile.transmissions.length; i++) {
|
||
intervals.push(profile.transmissions[i] - profile.transmissions[i - 1]);
|
||
}
|
||
|
||
// Calculate average and standard deviation
|
||
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||
profile.avgInterval = avg;
|
||
|
||
const variance = intervals.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / intervals.length;
|
||
const stdDev = Math.sqrt(variance);
|
||
|
||
// Check for burst transmission (sudden increase in frequency)
|
||
const lastInterval = intervals[intervals.length - 1];
|
||
if (avg > 0 && lastInterval < avg * 0.2) {
|
||
anomalies.push({
|
||
type: 'burst',
|
||
severity: 'medium',
|
||
message: 'Burst transmission detected - interval ' + Math.round(lastInterval / 1000) + 's vs avg ' + Math.round(avg / 1000) + 's'
|
||
});
|
||
}
|
||
|
||
// Check for silence break (device was quiet, now transmitting again)
|
||
if (avg > 0 && lastInterval > avg * 5) {
|
||
anomalies.push({
|
||
type: 'silence_break',
|
||
severity: 'low',
|
||
message: 'Device resumed after ' + Math.round(lastInterval / 60000) + ' min silence'
|
||
});
|
||
}
|
||
|
||
return anomalies;
|
||
}
|
||
|
||
// Check for encryption indicators
|
||
function detectEncryption(message) {
|
||
if (!message || message === '[No Message]' || message === '[Tone Only]') {
|
||
return null; // Can't determine
|
||
}
|
||
|
||
// Check for high entropy (random-looking data)
|
||
const printableRatio = (message.match(/[a-zA-Z0-9\s.,!?-]/g) || []).length / message.length;
|
||
|
||
// Check for common encrypted patterns (hex strings, base64-like)
|
||
const hexPattern = /^[0-9A-Fa-f\s]+$/;
|
||
const hasNonPrintable = /[^\x20-\x7E]/.test(message);
|
||
|
||
if (printableRatio > 0.8 && !hasNonPrintable) {
|
||
return false; // Likely plaintext
|
||
} else if (hexPattern.test(message.replace(/\s/g, '')) || hasNonPrintable) {
|
||
return true; // Likely encrypted or encoded
|
||
}
|
||
|
||
return null; // Unknown
|
||
}
|
||
|
||
// Generate device fingerprint
|
||
function generateDeviceId(data) {
|
||
if (data.protocol && data.protocol.includes('POCSAG')) {
|
||
return 'PAGER_' + (data.address || 'UNK');
|
||
} else if (data.protocol === 'FLEX') {
|
||
return 'FLEX_' + (data.address || 'UNK');
|
||
} else if (data.protocol === 'WiFi-AP') {
|
||
return 'WIFI_AP_' + (data.address || 'UNK').replace(/:/g, '');
|
||
} else if (data.protocol === 'WiFi-Client') {
|
||
return 'WIFI_CLIENT_' + (data.address || 'UNK').replace(/:/g, '');
|
||
} else if (data.protocol === 'Bluetooth' || data.protocol === 'BLE') {
|
||
return 'BT_' + (data.address || 'UNK').replace(/:/g, '');
|
||
} else if (data.protocol === 'Meter') {
|
||
// Utility meter (rtlamr)
|
||
return 'METER_' + (data.meterId || data.address || 'UNK');
|
||
} else if (data.model) {
|
||
// 433MHz sensor
|
||
const id = data.id || data.channel || data.unit || '0';
|
||
return 'SENSOR_' + data.model.replace(/\s+/g, '_') + '_' + id;
|
||
}
|
||
return 'UNKNOWN_' + Date.now();
|
||
}
|
||
|
||
// Track a device transmission
|
||
function trackDevice(data) {
|
||
const now = Date.now();
|
||
const deviceId = generateDeviceId(data);
|
||
const protocol = data.protocol || data.model || 'Unknown';
|
||
|
||
let profile = deviceDatabase.get(deviceId);
|
||
let isNewDevice = false;
|
||
|
||
if (!profile) {
|
||
// New device discovered
|
||
profile = createDeviceProfile(deviceId, protocol, now);
|
||
isNewDevice = true;
|
||
newDeviceAlerts++;
|
||
document.getElementById('newDeviceCount').textContent = newDeviceAlerts;
|
||
} else {
|
||
// Update existing profile
|
||
profile.lastSeen = now;
|
||
profile.transmissionCount++;
|
||
profile.transmissions.push(now);
|
||
profile.isNew = false;
|
||
|
||
// Keep only last 100 transmissions for analysis
|
||
if (profile.transmissions.length > 100) {
|
||
profile.transmissions = profile.transmissions.slice(-100);
|
||
}
|
||
}
|
||
|
||
// Track addresses
|
||
if (data.address) profile.addresses.add(data.address);
|
||
if (data.model) profile.models.add(data.model);
|
||
|
||
// Store recent messages (keep last 10)
|
||
if (data.message) {
|
||
profile.messages.unshift({
|
||
text: data.message,
|
||
time: now
|
||
});
|
||
if (profile.messages.length > 10) profile.messages.pop();
|
||
|
||
// Detect encryption
|
||
const encrypted = detectEncryption(data.message);
|
||
if (encrypted !== null) profile.encrypted = encrypted;
|
||
}
|
||
|
||
// Analyze for anomalies
|
||
const newAnomalies = analyzeTransmissions(profile);
|
||
if (newAnomalies.length > 0) {
|
||
profile.anomalies = profile.anomalies.concat(newAnomalies);
|
||
anomalyAlerts += newAnomalies.length;
|
||
document.getElementById('anomalyCount').textContent = anomalyAlerts;
|
||
}
|
||
|
||
deviceDatabase.set(deviceId, profile);
|
||
document.getElementById('trackedCount').textContent = deviceDatabase.size;
|
||
|
||
// Update recon display
|
||
if (reconEnabled) {
|
||
updateReconDisplay(deviceId, profile, isNewDevice, newAnomalies);
|
||
}
|
||
|
||
return { deviceId, profile, isNewDevice, anomalies: newAnomalies };
|
||
}
|
||
|
||
// Update reconnaissance display
|
||
function updateReconDisplay(deviceId, profile, isNewDevice, anomalies) {
|
||
const content = document.getElementById('reconContent');
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = content.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder) placeholder.remove();
|
||
|
||
// Check if device row already exists
|
||
let row = document.getElementById('device_' + deviceId.replace(/[^a-zA-Z0-9]/g, '_'));
|
||
|
||
if (!row) {
|
||
// Create new row
|
||
row = document.createElement('div');
|
||
row.id = 'device_' + deviceId.replace(/[^a-zA-Z0-9]/g, '_');
|
||
row.className = 'device-row' + (isNewDevice ? ' new-device' : '');
|
||
content.insertBefore(row, content.firstChild);
|
||
}
|
||
|
||
// Determine protocol badge class
|
||
let badgeClass = 'proto-unknown';
|
||
if (profile.protocol.includes('POCSAG')) badgeClass = 'proto-pocsag';
|
||
else if (profile.protocol === 'FLEX') badgeClass = 'proto-flex';
|
||
else if (profile.protocol.includes('SENSOR') || profile.models.size > 0) badgeClass = 'proto-433';
|
||
|
||
// Calculate transmission rate bar width
|
||
const maxRate = 100; // Max expected transmissions
|
||
const rateWidth = Math.min(100, (profile.transmissionCount / maxRate) * 100);
|
||
|
||
// Determine timeline status
|
||
const timeSinceLast = Date.now() - profile.lastSeen;
|
||
let timelineDot = 'recent';
|
||
if (timeSinceLast > 300000) timelineDot = 'old'; // > 5 min
|
||
else if (timeSinceLast > 60000) timelineDot = 'stale'; // > 1 min
|
||
|
||
// Build encryption indicator
|
||
let encStatus = 'Unknown';
|
||
let encClass = '';
|
||
if (profile.encrypted === true) { encStatus = 'Encrypted'; encClass = 'encrypted'; }
|
||
else if (profile.encrypted === false) { encStatus = 'Plaintext'; encClass = 'plaintext'; }
|
||
|
||
// Format time
|
||
const lastSeenStr = getRelativeTime(new Date(profile.lastSeen).toTimeString().split(' ')[0]);
|
||
const firstSeenStr = new Date(profile.firstSeen).toLocaleTimeString();
|
||
|
||
// Update row content
|
||
row.className = 'device-row' + (isNewDevice ? ' new-device' : '') + (anomalies.length > 0 ? ' anomaly' : '');
|
||
row.innerHTML = `
|
||
<div class="device-info">
|
||
<div class="device-name-row">
|
||
<span class="timeline-dot ${timelineDot}"></span>
|
||
<span class="badge ${badgeClass}">${profile.protocol.substring(0, 10)}</span>
|
||
${deviceId.substring(0, 30)}
|
||
</div>
|
||
<div class="device-id">
|
||
First: ${firstSeenStr} | Last: ${lastSeenStr} | TX: ${profile.transmissionCount}
|
||
${profile.avgInterval ? ' | Interval: ' + Math.round(profile.avgInterval / 1000) + 's' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="device-meta ${encClass}">${encStatus}</div>
|
||
<div>
|
||
<div class="transmission-bar">
|
||
<div class="transmission-bar-fill" style="width: ${rateWidth}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="device-meta">${Array.from(profile.addresses).slice(0, 2).join(', ')}</div>
|
||
`;
|
||
|
||
// Show anomaly alerts
|
||
if (anomalies.length > 0) {
|
||
anomalies.forEach(a => {
|
||
const alertEl = document.createElement('div');
|
||
alertEl.style.cssText = 'padding: 5px 15px; background: rgba(255,51,102,0.1); border-left: 2px solid var(--accent-red); font-size: 10px; color: var(--accent-red);';
|
||
alertEl.textContent = '⚠ ' + a.message;
|
||
row.appendChild(alertEl);
|
||
});
|
||
}
|
||
|
||
// Limit displayed devices
|
||
while (content.children.length > 50) {
|
||
content.removeChild(content.lastChild);
|
||
}
|
||
}
|
||
|
||
// Toggle recon panel visibility
|
||
function toggleRecon() {
|
||
reconEnabled = !reconEnabled;
|
||
localStorage.setItem('reconEnabled', reconEnabled);
|
||
document.getElementById('reconPanel').style.display = reconEnabled ? 'block' : 'none';
|
||
document.getElementById('reconBtn').classList.toggle('active', reconEnabled);
|
||
|
||
// Populate recon display if enabled and we have data
|
||
if (reconEnabled && deviceDatabase.size > 0) {
|
||
deviceDatabase.forEach((profile, deviceId) => {
|
||
updateReconDisplay(deviceId, profile, false, []);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Initialize recon state
|
||
if (reconEnabled) {
|
||
document.getElementById('reconPanel').style.display = 'block';
|
||
document.getElementById('reconBtn').classList.add('active');
|
||
} else {
|
||
document.getElementById('reconPanel').style.display = 'none';
|
||
}
|
||
|
||
// Hook into existing message handlers to track devices
|
||
const originalAddMessage = addMessage;
|
||
addMessage = function (msg) {
|
||
originalAddMessage(msg);
|
||
trackDevice(msg);
|
||
};
|
||
|
||
const originalAddSensorReading = addSensorReading;
|
||
addSensorReading = function (data) {
|
||
originalAddSensorReading(data);
|
||
trackDevice(data);
|
||
};
|
||
|
||
// Hook rtlamr readings into device intelligence
|
||
const originalAddRtlamrReading = addRtlamrReading;
|
||
addRtlamrReading = function (data) {
|
||
originalAddRtlamrReading(data);
|
||
// Transform rtlamr data for device tracking
|
||
const msgData = data.Message || {};
|
||
const meterInfo = getMeterTypeInfo(msgData.EndpointType, data.Type);
|
||
trackDevice({
|
||
protocol: 'Meter',
|
||
meterId: String(msgData.ID || 'Unknown'),
|
||
address: String(msgData.ID || 'Unknown'),
|
||
message: `${meterInfo.utility} - ${(msgData.Consumption || 0).toLocaleString()} units`,
|
||
model: meterInfo.manufacturer || data.Type || 'Unknown',
|
||
meterType: data.Type,
|
||
endpointType: msgData.EndpointType,
|
||
utility: meterInfo.utility,
|
||
manufacturer: meterInfo.manufacturer,
|
||
consumption: msgData.Consumption
|
||
});
|
||
};
|
||
|
||
// Meter type/manufacturer lookup based on ERT endpoint types and message formats
|
||
function getMeterTypeInfo(endpointType, msgType) {
|
||
// Common ERT endpoint type mappings (varies by utility)
|
||
const endpointInfo = {
|
||
// Electric meter types (0-7 common)
|
||
0: { utility: 'Electric', manufacturer: 'Generic' },
|
||
1: { utility: 'Electric', manufacturer: 'Generic' },
|
||
2: { utility: 'Electric', manufacturer: 'Itron' },
|
||
3: { utility: 'Electric', manufacturer: 'Itron' },
|
||
4: { utility: 'Electric', manufacturer: 'Landis+Gyr' },
|
||
5: { utility: 'Electric', manufacturer: 'Landis+Gyr' },
|
||
6: { utility: 'Electric', manufacturer: 'Elster' },
|
||
7: { utility: 'Electric', manufacturer: 'Elster' },
|
||
// Gas meter types (8-15)
|
||
8: { utility: 'Gas', manufacturer: 'Itron' },
|
||
9: { utility: 'Gas', manufacturer: 'Itron' },
|
||
10: { utility: 'Gas', manufacturer: 'Sensus' },
|
||
11: { utility: 'Gas', manufacturer: 'Sensus' },
|
||
12: { utility: 'Gas', manufacturer: 'Badger' },
|
||
13: { utility: 'Gas', manufacturer: 'Neptune' },
|
||
// Water meter types (16-23)
|
||
16: { utility: 'Water', manufacturer: 'Badger' },
|
||
17: { utility: 'Water', manufacturer: 'Badger' },
|
||
18: { utility: 'Water', manufacturer: 'Neptune' },
|
||
19: { utility: 'Water', manufacturer: 'Neptune' },
|
||
20: { utility: 'Water', manufacturer: 'Sensus' },
|
||
21: { utility: 'Water', manufacturer: 'Sensus' },
|
||
22: { utility: 'Water', manufacturer: 'Master Meter' },
|
||
23: { utility: 'Water', manufacturer: 'Mueller' },
|
||
// Extended types
|
||
156: { utility: 'Electric', manufacturer: 'Itron OpenWay' },
|
||
157: { utility: 'Electric', manufacturer: 'Itron OpenWay' },
|
||
180: { utility: 'Gas', manufacturer: 'Itron ERT' },
|
||
188: { utility: 'Water', manufacturer: 'Badger ORION' },
|
||
220: { utility: 'Electric', manufacturer: 'Landis+Gyr Focus' }
|
||
};
|
||
|
||
// Message type hints
|
||
const msgTypeInfo = {
|
||
'SCM': { utility: 'Electric', manufacturer: 'Standard ERT' },
|
||
'SCM+': { utility: 'Electric', manufacturer: 'Enhanced ERT' },
|
||
'IDM': { utility: 'Electric', manufacturer: 'Interval Data' },
|
||
'NetIDM': { utility: 'Electric', manufacturer: 'Network IDM' },
|
||
'R900': { utility: 'Water', manufacturer: 'Neptune R900' },
|
||
'R900BCD': { utility: 'Water', manufacturer: 'Neptune R900' }
|
||
};
|
||
|
||
// Try endpoint type first
|
||
if (endpointType !== undefined && endpointInfo[endpointType]) {
|
||
return endpointInfo[endpointType];
|
||
}
|
||
|
||
// Fall back to message type
|
||
if (msgType && msgTypeInfo[msgType]) {
|
||
return msgTypeInfo[msgType];
|
||
}
|
||
|
||
// Default based on endpoint range
|
||
if (endpointType !== undefined) {
|
||
if (endpointType < 8) return { utility: 'Electric', manufacturer: 'Unknown' };
|
||
if (endpointType < 16) return { utility: 'Gas', manufacturer: 'Unknown' };
|
||
if (endpointType < 24) return { utility: 'Water', manufacturer: 'Unknown' };
|
||
}
|
||
|
||
return { utility: 'Unknown', manufacturer: 'Unknown' };
|
||
}
|
||
|
||
// Export device database
|
||
function exportDeviceDB() {
|
||
const data = [];
|
||
deviceDatabase.forEach((profile, id) => {
|
||
data.push({
|
||
id: id,
|
||
protocol: profile.protocol,
|
||
firstSeen: new Date(profile.firstSeen).toISOString(),
|
||
lastSeen: new Date(profile.lastSeen).toISOString(),
|
||
transmissionCount: profile.transmissionCount,
|
||
avgIntervalSeconds: profile.avgInterval ? Math.round(profile.avgInterval / 1000) : null,
|
||
addresses: Array.from(profile.addresses),
|
||
models: Array.from(profile.models),
|
||
encrypted: profile.encrypted,
|
||
anomalyCount: profile.anomalies.length,
|
||
recentMessages: profile.messages.slice(0, 5).map(m => m.text)
|
||
});
|
||
});
|
||
downloadFile(JSON.stringify(data, null, 2), 'intercept_device_intelligence.json', 'application/json');
|
||
}
|
||
|
||
// Toggle recon panel collapse
|
||
function toggleReconCollapse() {
|
||
const panel = document.getElementById('reconPanel');
|
||
const icon = document.getElementById('reconCollapseIcon');
|
||
panel.classList.toggle('collapsed');
|
||
icon.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
|
||
}
|
||
|
||
// ============== WIFI RECONNAISSANCE ==============
|
||
|
||
let wifiEventSource = null;
|
||
let monitorInterface = null;
|
||
let wifiNetworks = {};
|
||
let wifiClients = {};
|
||
let apCount = 0;
|
||
let clientCount = 0;
|
||
let handshakeCount = 0;
|
||
let rogueApCount = 0;
|
||
let droneCount = 0;
|
||
let detectedDrones = {}; // Track detected drones by BSSID
|
||
let ssidToBssids = {}; // Track SSIDs to their BSSIDs for rogue AP detection
|
||
let rogueApDetails = {}; // Store details about rogue APs: {ssid: [{bssid, signal, channel, firstSeen}]}
|
||
let rogueBssids = new Set(); // Track all BSSIDs that are suspected rogues
|
||
let activeCapture = null; // {bssid, channel, file, startTime, pollInterval}
|
||
let watchMacs = JSON.parse(localStorage.getItem('watchMacs') || '[]');
|
||
let alertedMacs = new Set(); // Prevent duplicate alerts per session
|
||
let selectedWifiDevice = null; // Selected network or client for details view
|
||
let selectedWifiType = null; // 'network' or 'client'
|
||
|
||
// 5GHz channel mapping for the graph
|
||
const channels5g = ['36', '40', '44', '48', '52', '56', '60', '64', '100', '149', '153', '157', '161', '165'];
|
||
|
||
// Drone SSID patterns for detection
|
||
const dronePatterns = [
|
||
/^DJI[-_]/i, /Mavic/i, /Phantom/i, /^Spark[-_]/i, /^Mini[-_]/i, /^Air[-_]/i,
|
||
/Inspire/i, /Matrice/i, /Avata/i, /^FPV[-_]/i, /Osmo/i, /RoboMaster/i, /Tello/i,
|
||
/Parrot/i, /Bebop/i, /Anafi/i, /^Disco[-_]/i, /Mambo/i, /Swing/i,
|
||
/Autel/i, /^EVO[-_]/i, /Dragonfish/i, /Skydio/i,
|
||
/Holy.?Stone/i, /Potensic/i, /SYMA/i, /Hubsan/i, /Eachine/i, /FIMI/i,
|
||
/Yuneec/i, /Typhoon/i, /PowerVision/i, /PowerEgg/i,
|
||
/Drone/i, /^UAV[-_]/i, /Quadcopter/i, /^RC[-_]Drone/i
|
||
];
|
||
|
||
// Drone OUI prefixes
|
||
const droneOuiPrefixes = {
|
||
'60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI',
|
||
'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI',
|
||
'90:03:B7': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot',
|
||
'8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel', 'F8:0F:6F': 'Skydio'
|
||
};
|
||
|
||
// Check if network is a drone
|
||
function isDrone(ssid, bssid) {
|
||
// Check SSID patterns
|
||
if (ssid) {
|
||
for (const pattern of dronePatterns) {
|
||
if (pattern.test(ssid)) {
|
||
return { isDrone: true, method: 'SSID', brand: ssid.split(/[-_\s]/)[0] };
|
||
}
|
||
}
|
||
}
|
||
// Check OUI prefix
|
||
if (bssid) {
|
||
const prefix = bssid.substring(0, 8).toUpperCase();
|
||
if (droneOuiPrefixes[prefix]) {
|
||
return { isDrone: true, method: 'OUI', brand: droneOuiPrefixes[prefix] };
|
||
}
|
||
}
|
||
return { isDrone: false };
|
||
}
|
||
|
||
// Handle drone detection
|
||
function handleDroneDetection(net, droneInfo) {
|
||
if (detectedDrones[net.bssid]) return; // Already detected
|
||
|
||
detectedDrones[net.bssid] = {
|
||
ssid: net.essid,
|
||
bssid: net.bssid,
|
||
brand: droneInfo.brand,
|
||
method: droneInfo.method,
|
||
signal: net.power,
|
||
channel: net.channel,
|
||
firstSeen: new Date().toISOString()
|
||
};
|
||
|
||
droneCount++;
|
||
document.getElementById('droneCount').textContent = droneCount;
|
||
|
||
// Calculate approximate distance from signal strength
|
||
const rssi = parseInt(net.power) || -70;
|
||
const distance = estimateDroneDistance(rssi);
|
||
|
||
// Triple alert for drones
|
||
playAlert();
|
||
setTimeout(playAlert, 200);
|
||
setTimeout(playAlert, 400);
|
||
|
||
// Show drone alert
|
||
showDroneAlert(net.essid, net.bssid, droneInfo.brand, distance, rssi);
|
||
}
|
||
|
||
// Estimate distance from RSSI (rough approximation)
|
||
function estimateDroneDistance(rssi) {
|
||
// Using free-space path loss model (very approximate)
|
||
// Reference: -30 dBm at 1 meter
|
||
const txPower = -30;
|
||
const n = 2.5; // Path loss exponent (2-4, higher for obstacles)
|
||
const distance = Math.pow(10, (txPower - rssi) / (10 * n));
|
||
return Math.round(distance);
|
||
}
|
||
|
||
// Show drone alert popup
|
||
function showDroneAlert(ssid, bssid, brand, distance, rssi) {
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'drone-alert';
|
||
alertDiv.innerHTML = `
|
||
<div style="font-weight: bold; color: var(--accent-orange); font-size: 16px;">DRONE DETECTED</div>
|
||
<div style="margin: 10px 0;">
|
||
<div><strong>SSID:</strong> ${escapeHtml(ssid || 'Unknown')}</div>
|
||
<div><strong>BSSID:</strong> ${bssid}</div>
|
||
<div><strong>Brand:</strong> ${brand || 'Unknown'}</div>
|
||
<div><strong>Signal:</strong> ${rssi} dBm</div>
|
||
<div><strong>Est. Distance:</strong> ~${distance}m</div>
|
||
</div>
|
||
<button onclick="this.parentElement.remove()" style="padding: 6px 16px; cursor: pointer; background: var(--accent-orange); border: none; color: #000; border-radius: 4px;">Dismiss</button>
|
||
`;
|
||
alertDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1a1a2e; border: 2px solid var(--accent-orange); padding: 20px; border-radius: 8px; z-index: 10000; text-align: center; box-shadow: 0 0 30px rgba(255,165,0,0.5); min-width: 280px;';
|
||
document.body.appendChild(alertDiv);
|
||
setTimeout(() => { if (alertDiv.parentElement) alertDiv.remove(); }, 15000);
|
||
}
|
||
|
||
// Initialize watch list display
|
||
function initWatchList() {
|
||
updateWatchListDisplay();
|
||
}
|
||
|
||
// Add MAC to watch list
|
||
function addWatchMac() {
|
||
const input = document.getElementById('watchMacInput');
|
||
const mac = input.value.trim().toUpperCase();
|
||
if (!mac || !/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(mac)) {
|
||
alert('Please enter a valid MAC address (AA:BB:CC:DD:EE:FF)');
|
||
return;
|
||
}
|
||
if (!watchMacs.includes(mac)) {
|
||
watchMacs.push(mac);
|
||
localStorage.setItem('watchMacs', JSON.stringify(watchMacs));
|
||
updateWatchListDisplay();
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
// Remove MAC from watch list
|
||
function removeWatchMac(mac) {
|
||
watchMacs = watchMacs.filter(m => m !== mac);
|
||
localStorage.setItem('watchMacs', JSON.stringify(watchMacs));
|
||
alertedMacs.delete(mac);
|
||
updateWatchListDisplay();
|
||
}
|
||
|
||
// Update watch list display
|
||
function updateWatchListDisplay() {
|
||
const container = document.getElementById('watchList');
|
||
if (!container) return;
|
||
if (watchMacs.length === 0) {
|
||
container.innerHTML = '<div style="color: #555;">No MACs in watch list</div>';
|
||
} else {
|
||
container.innerHTML = watchMacs.map(mac =>
|
||
`<div style="display: flex; justify-content: space-between; align-items: center; padding: 2px 0;">
|
||
<span>${mac}</span>
|
||
<button onclick="removeWatchMac('${mac}')" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 10px;">✕</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
// Check if MAC is in watch list and alert
|
||
function checkWatchList(mac, type) {
|
||
const upperMac = mac.toUpperCase();
|
||
if (watchMacs.includes(upperMac) && !alertedMacs.has(upperMac)) {
|
||
alertedMacs.add(upperMac);
|
||
// Play alert sound multiple times for urgency
|
||
playAlert();
|
||
setTimeout(playAlert, 300);
|
||
setTimeout(playAlert, 600);
|
||
// Show prominent alert
|
||
showProximityAlert(mac, type);
|
||
}
|
||
}
|
||
|
||
// Show proximity alert popup
|
||
function showProximityAlert(mac, type) {
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'proximity-alert';
|
||
alertDiv.innerHTML = `
|
||
<div style="font-weight: bold; color: var(--accent-red);">⚠ PROXIMITY ALERT</div>
|
||
<div>Watched ${type} detected:</div>
|
||
<div style="font-family: monospace; font-size: 14px;">${mac}</div>
|
||
<button onclick="this.parentElement.remove()" style="margin-top: 8px; padding: 4px 12px; cursor: pointer;">Dismiss</button>
|
||
`;
|
||
alertDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1a1a2e; border: 2px solid var(--accent-red); padding: 20px; border-radius: 8px; z-index: 10000; text-align: center; box-shadow: 0 0 30px rgba(255,0,0,0.5);';
|
||
document.body.appendChild(alertDiv);
|
||
// Auto-dismiss after 10 seconds
|
||
setTimeout(() => alertDiv.remove(), 10000);
|
||
}
|
||
|
||
// Check for rogue APs (same SSID, different BSSID)
|
||
// Extract OUI (manufacturer ID) from MAC address
|
||
function getOui(mac) {
|
||
if (!mac) return '';
|
||
return mac.toUpperCase().substring(0, 8); // First 3 octets: "AA:BB:CC"
|
||
}
|
||
|
||
function checkRogueAP(ssid, bssid, channel, signal) {
|
||
if (!ssid || ssid === 'Hidden' || ssid === '[Hidden]') return false;
|
||
|
||
if (!ssidToBssids[ssid]) {
|
||
ssidToBssids[ssid] = new Set();
|
||
}
|
||
|
||
// Store details for this BSSID
|
||
if (!rogueApDetails[ssid]) {
|
||
rogueApDetails[ssid] = [];
|
||
}
|
||
|
||
// Check if we already have this BSSID stored
|
||
const existingEntry = rogueApDetails[ssid].find(e => e.bssid === bssid);
|
||
if (!existingEntry) {
|
||
rogueApDetails[ssid].push({
|
||
bssid: bssid,
|
||
channel: channel || '?',
|
||
signal: signal || '?',
|
||
oui: getOui(bssid),
|
||
firstSeen: new Date().toLocaleTimeString()
|
||
});
|
||
}
|
||
|
||
const isNewBssid = !ssidToBssids[ssid].has(bssid);
|
||
ssidToBssids[ssid].add(bssid);
|
||
|
||
// Only flag as rogue if multiple BSSIDs AND different manufacturers (OUIs)
|
||
// This prevents false positives from mesh WiFi systems and enterprise networks
|
||
if (ssidToBssids[ssid].size > 1 && isNewBssid) {
|
||
// Check if all BSSIDs have the same OUI (manufacturer)
|
||
const ouis = new Set(rogueApDetails[ssid].map(e => e.oui));
|
||
|
||
// If all BSSIDs have the same OUI, it's likely a mesh system - not rogue
|
||
if (ouis.size === 1) {
|
||
// Same manufacturer - probably mesh system, not rogue
|
||
return false;
|
||
}
|
||
|
||
// Different manufacturers detected - this is suspicious!
|
||
rogueApCount++;
|
||
document.getElementById('rogueApCount').textContent = rogueApCount;
|
||
playAlert();
|
||
|
||
// Mark ALL BSSIDs with this SSID as suspected rogues
|
||
ssidToBssids[ssid].forEach(b => rogueBssids.add(b));
|
||
|
||
// Get the BSSIDs to show in alert
|
||
const bssidList = rogueApDetails[ssid].map(e => e.bssid).join(', ');
|
||
showInfo(`Rogue AP: "${ssid}" has ${ouis.size} different vendors: ${bssidList}`);
|
||
showNotification('Rogue AP Detected!', `"${ssid}" has different vendor BSSIDs`);
|
||
|
||
// Update all network cards with this SSID to show rogue indicator
|
||
ssidToBssids[ssid].forEach(rogueBssid => {
|
||
const net = wifiNetworks[rogueBssid];
|
||
if (net) addWifiNetworkCard(net, false);
|
||
});
|
||
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Show rogue AP details popup
|
||
function showRogueApDetails() {
|
||
const rogueSSIDs = Object.keys(rogueApDetails).filter(ssid =>
|
||
rogueApDetails[ssid].length > 1
|
||
);
|
||
|
||
if (rogueSSIDs.length === 0) {
|
||
showInfo('No rogue APs detected. Rogue AP = same SSID on multiple BSSIDs.');
|
||
return;
|
||
}
|
||
|
||
// Remove existing popup if any
|
||
const existing = document.getElementById('rogueApPopup');
|
||
if (existing) existing.remove();
|
||
|
||
// Build details HTML
|
||
let html = '<div style="max-height: 300px; overflow-y: auto;">';
|
||
rogueSSIDs.forEach(ssid => {
|
||
const aps = rogueApDetails[ssid];
|
||
html += `<div style="margin-bottom: 12px;">
|
||
<div style="color: var(--accent-red); font-weight: bold; margin-bottom: 4px;">
|
||
"${ssid}" (${aps.length} BSSIDs)
|
||
</div>
|
||
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||
<tr style="color: var(--text-dim);">
|
||
<th style="text-align: left; padding: 2px 8px;">BSSID</th>
|
||
<th style="text-align: left; padding: 2px 8px;">CH</th>
|
||
<th style="text-align: left; padding: 2px 8px;">Signal</th>
|
||
<th style="text-align: left; padding: 2px 8px;">First Seen</th>
|
||
</tr>`;
|
||
aps.forEach((ap, idx) => {
|
||
const bgColor = idx % 2 === 0 ? 'rgba(255,255,255,0.05)' : 'transparent';
|
||
html += `<tr style="background: ${bgColor};">
|
||
<td style="padding: 2px 8px; font-family: monospace;">${ap.bssid}</td>
|
||
<td style="padding: 2px 8px;">${ap.channel}</td>
|
||
<td style="padding: 2px 8px;">${ap.signal} dBm</td>
|
||
<td style="padding: 2px 8px;">${ap.firstSeen}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</table></div>';
|
||
});
|
||
html += '</div>';
|
||
html += '<div style="margin-top: 8px; font-size: 9px; color: var(--text-dim);">Multiple BSSIDs for same SSID may indicate rogue AP or legitimate multi-AP setup</div>';
|
||
|
||
// Create popup
|
||
const popup = document.createElement('div');
|
||
popup.id = 'rogueApPopup';
|
||
popup.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--accent-red);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
z-index: 10000;
|
||
min-width: 400px;
|
||
max-width: 600px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||
`;
|
||
popup.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<span style="font-weight: bold; color: var(--accent-red);">Rogue AP Details</span>
|
||
<button onclick="this.parentElement.parentElement.remove()"
|
||
style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px;">✕</button>
|
||
</div>
|
||
${html}
|
||
`;
|
||
|
||
document.body.appendChild(popup);
|
||
}
|
||
|
||
// Show drone details popup
|
||
function showDroneDetails() {
|
||
const drones = Object.values(detectedDrones);
|
||
|
||
if (drones.length === 0) {
|
||
showInfo('No drones detected. Drones are identified by SSID patterns and manufacturer OUI.');
|
||
return;
|
||
}
|
||
|
||
// Remove existing popup if any
|
||
const existing = document.getElementById('droneDetailsPopup');
|
||
if (existing) existing.remove();
|
||
|
||
// Build details HTML
|
||
let html = '<div style="max-height: 300px; overflow-y: auto;">';
|
||
html += `<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||
<tr style="color: var(--text-dim);">
|
||
<th style="text-align: left; padding: 4px 8px;">Brand</th>
|
||
<th style="text-align: left; padding: 4px 8px;">SSID</th>
|
||
<th style="text-align: left; padding: 4px 8px;">BSSID</th>
|
||
<th style="text-align: left; padding: 4px 8px;">CH</th>
|
||
<th style="text-align: left; padding: 4px 8px;">Signal</th>
|
||
<th style="text-align: left; padding: 4px 8px;">Distance</th>
|
||
<th style="text-align: left; padding: 4px 8px;">Detected</th>
|
||
</tr>`;
|
||
|
||
drones.forEach((drone, idx) => {
|
||
const bgColor = idx % 2 === 0 ? 'rgba(255,165,0,0.1)' : 'transparent';
|
||
const rssi = parseInt(drone.signal) || -70;
|
||
const distance = estimateDroneDistance(rssi);
|
||
const timeStr = new Date(drone.firstSeen).toLocaleTimeString();
|
||
html += `<tr style="background: ${bgColor};">
|
||
<td style="padding: 4px 8px; font-weight: bold; color: var(--accent-orange);">${drone.brand || 'Unknown'}</td>
|
||
<td style="padding: 4px 8px;">${drone.ssid || '[Hidden]'}</td>
|
||
<td style="padding: 4px 8px; font-family: monospace; font-size: 9px;">${drone.bssid}</td>
|
||
<td style="padding: 4px 8px;">${drone.channel || '?'}</td>
|
||
<td style="padding: 4px 8px;">${drone.signal || '?'} dBm</td>
|
||
<td style="padding: 4px 8px;">~${distance}m</td>
|
||
<td style="padding: 4px 8px;">${timeStr}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</table></div>';
|
||
html += '<div style="margin-top: 8px; font-size: 9px; color: var(--text-dim);">Detection via: SSID pattern matching and manufacturer OUI lookup</div>';
|
||
|
||
// Create popup
|
||
const popup = document.createElement('div');
|
||
popup.id = 'droneDetailsPopup';
|
||
popup.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--accent-orange);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
z-index: 10000;
|
||
min-width: 500px;
|
||
max-width: 700px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||
`;
|
||
popup.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<span style="font-weight: bold; color: var(--accent-orange);">Detected Drones (${drones.length})</span>
|
||
<button onclick="this.parentElement.parentElement.remove()"
|
||
style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px;">✕</button>
|
||
</div>
|
||
${html}
|
||
`;
|
||
|
||
document.body.appendChild(popup);
|
||
}
|
||
|
||
// Update 5GHz channel graph
|
||
function updateChannel5gGraph() {
|
||
const bars = document.querySelectorAll('#channelGraph5g .channel-bar');
|
||
const labels = document.querySelectorAll('#channelGraph5g .channel-label');
|
||
|
||
// Count networks per 5GHz channel
|
||
const channelCounts = {};
|
||
channels5g.forEach(ch => channelCounts[ch] = 0);
|
||
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const ch = net.channel?.toString().trim();
|
||
if (channels5g.includes(ch)) {
|
||
channelCounts[ch]++;
|
||
}
|
||
});
|
||
|
||
const maxCount = Math.max(1, ...Object.values(channelCounts));
|
||
|
||
bars.forEach((bar, i) => {
|
||
const ch = channels5g[i];
|
||
const count = channelCounts[ch] || 0;
|
||
const height = Math.max(2, (count / maxCount) * 50);
|
||
bar.style.height = height + 'px';
|
||
bar.className = 'channel-bar' + (count > 0 ? ' active' : '') + (count > 3 ? ' congested' : '') + (count > 5 ? ' very-congested' : '');
|
||
});
|
||
}
|
||
|
||
// ============== NEW FEATURES ==============
|
||
|
||
// Network Topology Graph
|
||
function drawNetworkGraph() {
|
||
const canvas = document.getElementById('networkGraph');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.offsetWidth;
|
||
const height = canvas.offsetHeight;
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
// Clear
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
const networks = Object.values(wifiNetworks);
|
||
const clients = Object.values(wifiClients);
|
||
|
||
if (networks.length === 0) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '12px sans-serif';
|
||
ctx.fillText('Start scanning to see network topology', width / 2 - 100, height / 2);
|
||
return;
|
||
}
|
||
|
||
// Calculate positions for APs (top row)
|
||
const apPositions = {};
|
||
const apSpacing = width / (networks.length + 1);
|
||
networks.forEach((net, i) => {
|
||
apPositions[net.bssid] = {
|
||
x: apSpacing * (i + 1),
|
||
y: 40,
|
||
ssid: net.essid,
|
||
isDrone: isDrone(net.essid, net.bssid).isDrone
|
||
};
|
||
});
|
||
|
||
// Draw connections from clients to APs
|
||
ctx.strokeStyle = '#1a1a1a';
|
||
ctx.lineWidth = 1;
|
||
clients.forEach(client => {
|
||
if (client.ap && apPositions[client.ap]) {
|
||
const ap = apPositions[client.ap];
|
||
const clientY = 120 + (Math.random() * 60);
|
||
const clientX = ap.x + (Math.random() - 0.5) * 80;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(ap.x, ap.y + 15);
|
||
ctx.lineTo(clientX, clientY - 10);
|
||
ctx.stroke();
|
||
|
||
// Draw client node
|
||
ctx.beginPath();
|
||
ctx.arc(clientX, clientY, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#00ff88';
|
||
ctx.fill();
|
||
}
|
||
});
|
||
|
||
// Draw AP nodes
|
||
Object.entries(apPositions).forEach(([bssid, pos]) => {
|
||
ctx.beginPath();
|
||
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
|
||
ctx.fillStyle = pos.isDrone ? '#ff8800' : '#00d4ff';
|
||
ctx.fill();
|
||
|
||
// Draw label
|
||
ctx.fillStyle = '#888';
|
||
ctx.font = '9px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
const label = (pos.ssid || 'Hidden').substring(0, 12);
|
||
ctx.fillText(label, pos.x, pos.y + 25);
|
||
});
|
||
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// Channel Recommendation
|
||
function updateChannelRecommendation() {
|
||
const channelCounts24 = {};
|
||
const channelCounts5 = {};
|
||
|
||
// Initialize
|
||
for (let i = 1; i <= 13; i++) channelCounts24[i] = 0;
|
||
channels5g.forEach(ch => channelCounts5[ch] = 0);
|
||
|
||
// Count networks per channel
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const ch = parseInt(net.channel);
|
||
if (ch >= 1 && ch <= 13) {
|
||
// 2.4 GHz channels overlap, so count neighbors too
|
||
for (let i = Math.max(1, ch - 2); i <= Math.min(13, ch + 2); i++) {
|
||
channelCounts24[i] = (channelCounts24[i] || 0) + (i === ch ? 1 : 0.5);
|
||
}
|
||
} else if (channels5g.includes(ch.toString())) {
|
||
channelCounts5[ch.toString()]++;
|
||
}
|
||
});
|
||
|
||
// Count total networks for context
|
||
const totalNetworks = Object.keys(wifiNetworks).length;
|
||
|
||
// Find best 2.4 GHz channel (1, 6, or 11 preferred - non-overlapping)
|
||
const preferred24 = [1, 6, 11];
|
||
let best24 = 1;
|
||
let minCount24 = Infinity;
|
||
let channelUsage24 = [];
|
||
preferred24.forEach(ch => {
|
||
channelUsage24.push({ channel: ch, count: channelCounts24[ch] || 0 });
|
||
if ((channelCounts24[ch] || 0) < minCount24) {
|
||
minCount24 = channelCounts24[ch] || 0;
|
||
best24 = ch;
|
||
}
|
||
});
|
||
|
||
// Find best 5 GHz channel
|
||
let best5 = '36';
|
||
let minCount5 = Infinity;
|
||
let used5g = 0;
|
||
channels5g.forEach(ch => {
|
||
const count = channelCounts5[ch] || 0;
|
||
if (count > 0) used5g++;
|
||
if (count < minCount5) {
|
||
minCount5 = count;
|
||
best5 = ch;
|
||
}
|
||
});
|
||
|
||
// Update UI with more context (with null checks for v2 layout)
|
||
const rec24El = document.getElementById('rec24Channel');
|
||
const rec24ReasonEl = document.getElementById('rec24Reason');
|
||
const rec5El = document.getElementById('rec5Channel');
|
||
const rec5ReasonEl = document.getElementById('rec5Reason');
|
||
|
||
if (rec24El) rec24El.textContent = best24;
|
||
if (totalNetworks === 0) {
|
||
if (rec24ReasonEl) rec24ReasonEl.textContent = '(no networks detected)';
|
||
} else {
|
||
const usage = channelUsage24.map(c => `CH${c.channel}:${Math.round(c.count)}`).join(', ');
|
||
if (rec24ReasonEl) rec24ReasonEl.textContent =
|
||
minCount24 === 0 ? '(clear)' : `(${Math.round(minCount24)} interference) [${usage}]`;
|
||
}
|
||
|
||
if (rec5El) rec5El.textContent = best5;
|
||
if (totalNetworks === 0) {
|
||
if (rec5ReasonEl) rec5ReasonEl.textContent = '(no networks detected)';
|
||
} else {
|
||
if (rec5ReasonEl) rec5ReasonEl.textContent =
|
||
minCount5 === 0 ? `(clear, ${channels5g.length - used5g} unused)` : `(${minCount5} networks)`;
|
||
}
|
||
}
|
||
|
||
// Device Correlation (WiFi <-> Bluetooth)
|
||
let deviceCorrelations = [];
|
||
let correlationFetchPending = false;
|
||
|
||
function correlateDevices() {
|
||
// Use server-side correlation API for better analysis
|
||
if (correlationFetchPending) return;
|
||
correlationFetchPending = true;
|
||
|
||
fetch('/correlation?min_confidence=0.4')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
deviceCorrelations = data.correlations || [];
|
||
updateCorrelationDisplay();
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.warn('Correlation fetch failed:', err);
|
||
// Fallback to local OUI matching
|
||
correlateDevicesLocal();
|
||
})
|
||
.finally(() => {
|
||
correlationFetchPending = false;
|
||
});
|
||
}
|
||
|
||
function correlateDevicesLocal() {
|
||
// Fallback: simple OUI-based correlation
|
||
deviceCorrelations = [];
|
||
const wifiMacs = Object.keys(wifiNetworks).concat(Object.keys(wifiClients));
|
||
const btDeviceMap = getBluetoothDevicesSnapshot();
|
||
const btMacs = Object.keys(btDeviceMap);
|
||
|
||
wifiMacs.forEach(wifiMac => {
|
||
const wifiOui = wifiMac.substring(0, 8).toUpperCase();
|
||
btMacs.forEach(btMac => {
|
||
const btOui = btMac.substring(0, 8).toUpperCase();
|
||
if (wifiOui === btOui) {
|
||
const wifiDev = wifiNetworks[wifiMac] || wifiClients[wifiMac];
|
||
const btDev = btDeviceMap[btMac];
|
||
deviceCorrelations.push({
|
||
wifi_mac: wifiMac,
|
||
bt_mac: btMac,
|
||
wifi_name: wifiDev?.essid || wifiDev?.mac || wifiMac,
|
||
bt_name: btDev?.name || btMac,
|
||
confidence: 0.5,
|
||
reason: 'same OUI'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
updateCorrelationDisplay();
|
||
}
|
||
|
||
function getBluetoothDevicesSnapshot() {
|
||
const snapshot = {};
|
||
if (typeof BluetoothMode === 'undefined' || typeof BluetoothMode.getDevices !== 'function') {
|
||
return snapshot;
|
||
}
|
||
|
||
const devices = BluetoothMode.getDevices();
|
||
devices.forEach(device => {
|
||
const address = String(device.address || device.mac || '').toUpperCase();
|
||
// Correlation fallback is OUI-based, so only include MAC-form addresses.
|
||
if (!/^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/.test(address)) return;
|
||
snapshot[address] = device;
|
||
});
|
||
|
||
return snapshot;
|
||
}
|
||
|
||
function updateCorrelationDisplay() {
|
||
const list = document.getElementById('correlationList');
|
||
if (!list) return;
|
||
|
||
if (deviceCorrelations.length === 0) {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">No correlated devices found yet</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = deviceCorrelations.slice(0, 10).map(c => {
|
||
const confidence = Math.round((c.confidence || 0.5) * 100);
|
||
const confidenceColor = confidence >= 70 ? 'var(--accent-green)' :
|
||
confidence >= 50 ? 'var(--accent-orange)' : 'var(--text-dim)';
|
||
return `
|
||
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="color: var(--accent-cyan);">${c.wifi_name || c.wifi_mac}</span>
|
||
<span class="correlation-badge" style="background: ${confidenceColor};">${confidence}%</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="color: #6495ED;">${c.bt_name || c.bt_mac}</span>
|
||
<span style="font-size: 9px; color: var(--text-dim);">${c.reason || ''}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Hidden SSID Revealer
|
||
let revealedSsids = {}; // {bssid: ssid}
|
||
|
||
function revealHiddenSsid(bssid, ssid) {
|
||
if (ssid && ssid !== '' && ssid !== 'Hidden' && ssid !== '[Hidden]') {
|
||
if (!revealedSsids[bssid]) {
|
||
revealedSsids[bssid] = ssid;
|
||
updateHiddenSsidDisplay();
|
||
showNotification('Hidden SSID Revealed', `"${ssid}" on ${bssid}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateHiddenSsidDisplay() {
|
||
const list = document.getElementById('hiddenSsidList');
|
||
if (!list) return;
|
||
|
||
const entries = Object.entries(revealedSsids);
|
||
const hiddenCount = Object.keys(hiddenNetworks).length;
|
||
|
||
if (entries.length === 0) {
|
||
if (hiddenCount > 0) {
|
||
list.innerHTML = `<div style="color: var(--text-dim);">Monitoring ${hiddenCount} hidden network${hiddenCount > 1 ? 's' : ''}...</div>`;
|
||
} else {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">No hidden networks detected</div>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
let html = entries.map(([bssid, ssid]) => `
|
||
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
|
||
<span style="color: var(--accent-green);">✓ "${escapeHtml(ssid)}"</span>
|
||
<span style="color: var(--text-dim); font-size: 9px;"> (${bssid})</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
if (hiddenCount > 0) {
|
||
html += `<div style="color: var(--text-dim); margin-top: 4px; font-size: 10px;">+ ${hiddenCount} hidden still monitoring</div>`;
|
||
}
|
||
|
||
list.innerHTML = html;
|
||
}
|
||
|
||
// NOTE: Browser Notifications code moved to static/js/core/audio.js
|
||
|
||
// Sync legacy WiFi data to v2 channel chart
|
||
function syncLegacyToChannelChart() {
|
||
if (typeof ChannelChart === 'undefined') return;
|
||
|
||
const networksList = Object.values(wifiNetworks);
|
||
if (networksList.length === 0) return;
|
||
|
||
// Calculate channel stats from legacy networks
|
||
const stats = {};
|
||
|
||
// Initialize 2.4 GHz channels
|
||
for (let ch = 1; ch <= 11; ch++) {
|
||
stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, utilization_score: 0 };
|
||
}
|
||
// Initialize 5 GHz channels
|
||
[36, 40, 44, 48, 149, 153, 157, 161, 165].forEach(ch => {
|
||
stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, utilization_score: 0 };
|
||
});
|
||
|
||
// Count APs per channel
|
||
networksList.forEach(net => {
|
||
const ch = parseInt(net.channel);
|
||
if (stats[ch]) {
|
||
stats[ch].ap_count++;
|
||
}
|
||
});
|
||
|
||
// Calculate utilization (0-1)
|
||
const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count));
|
||
Object.values(stats).forEach(s => {
|
||
s.utilization_score = s.ap_count / maxAPs;
|
||
});
|
||
|
||
// Get active band from tab
|
||
const activeTab = document.querySelector('.channel-band-tab.active');
|
||
const band = activeTab ? activeTab.dataset.band : '2.4';
|
||
const bandFilter = band === '2.4' ? '2.4GHz' : '5GHz';
|
||
|
||
const filteredStats = Object.values(stats).filter(s => s.band === bandFilter);
|
||
ChannelChart.update(filteredStats, []);
|
||
}
|
||
|
||
// Update visualizations periodically
|
||
setInterval(() => {
|
||
if (currentMode === 'wifi') {
|
||
updateChannelRecommendation();
|
||
correlateDevices();
|
||
updateHiddenSsidDisplay();
|
||
updateProbeAnalysis();
|
||
syncLegacyToChannelChart();
|
||
}
|
||
}, 2000);
|
||
|
||
// Refresh WiFi interfaces
|
||
function refreshWifiInterfaces() {
|
||
const select = document.getElementById('wifiInterfaceSelect');
|
||
select.innerHTML = '<option value="">Loading interfaces...</option>';
|
||
|
||
// Check if we're in agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
if (isAgentMode) {
|
||
// Fetch from agent via controller
|
||
fetch(`/controller/agents/${currentAgent}?refresh=true`)
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Failed to fetch agent interfaces');
|
||
return r.json();
|
||
})
|
||
.then(data => {
|
||
const interfaces = data.agent?.interfaces?.wifi_interfaces || [];
|
||
if (interfaces.length === 0) {
|
||
select.innerHTML = '<option value="">No WiFi interfaces on agent</option>';
|
||
showNotification('WiFi', 'No WiFi interfaces found on remote agent.');
|
||
monitorInterface = null;
|
||
updateMonitorStatus(false);
|
||
} else {
|
||
select.innerHTML = interfaces.map(i => {
|
||
let label = i.name || i;
|
||
if (i.display_name) label = i.display_name;
|
||
else if (i.type) label += ` (${i.type})`;
|
||
if (i.monitor_capable) label += ' [Monitor OK]';
|
||
return `<option value="${i.name || i}" data-type="${i.type || 'managed'}">${label}</option>`;
|
||
}).join('');
|
||
showNotification('WiFi', `Found ${interfaces.length} interface(s) on agent`);
|
||
|
||
// Check if any interface is already in monitor mode
|
||
const monitorIface = interfaces.find(i => i.type === 'monitor');
|
||
if (monitorIface) {
|
||
monitorInterface = monitorIface.name;
|
||
updateMonitorStatus(true);
|
||
select.value = monitorIface.name;
|
||
} else {
|
||
monitorInterface = null;
|
||
updateMonitorStatus(false);
|
||
}
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to refresh agent interfaces:', err);
|
||
select.innerHTML = '<option value="">Error loading agent interfaces</option>';
|
||
showNotification('WiFi', 'Failed to load agent interfaces');
|
||
});
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/interfaces')
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Failed to fetch interfaces');
|
||
return r.json();
|
||
})
|
||
.then(data => {
|
||
if (!data.interfaces || data.interfaces.length === 0) {
|
||
select.innerHTML = '<option value="">No WiFi interfaces found</option>';
|
||
showNotification('WiFi', 'No WiFi interfaces detected. Make sure you have a WiFi adapter connected.');
|
||
} else {
|
||
select.innerHTML = data.interfaces.map(i => {
|
||
// Build descriptive label with available info
|
||
let label = i.name;
|
||
let details = [];
|
||
if (i.chipset) details.push(i.chipset);
|
||
else if (i.driver) details.push(i.driver);
|
||
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
|
||
if (details.length > 0) label += ' - ' + details.join(' | ');
|
||
label += ` (${i.type})`;
|
||
if (i.monitor_capable) label += ' [Monitor OK]';
|
||
return `<option value="${i.name}">${label}</option>`;
|
||
}).join('');
|
||
showNotification('WiFi', `Found ${data.interfaces.length} interface(s)`);
|
||
}
|
||
|
||
// Update tool status
|
||
const statusDiv = document.getElementById('wifiToolStatus');
|
||
if (statusDiv) {
|
||
statusDiv.innerHTML = `
|
||
<span>airmon-ng:</span><span class="tool-status ${data.tools?.airmon ? 'ok' : 'missing'}">${data.tools?.airmon ? 'OK' : 'Missing'}</span>
|
||
<span>airodump-ng:</span><span class="tool-status ${data.tools?.airodump ? 'ok' : 'missing'}">${data.tools?.airodump ? 'OK' : 'Missing'}</span>
|
||
`;
|
||
}
|
||
|
||
// Update monitor status
|
||
if (data.monitor_interface) {
|
||
monitorInterface = data.monitor_interface;
|
||
updateMonitorStatus(true);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Error fetching WiFi interfaces:', err);
|
||
select.innerHTML = '<option value="">Error loading interfaces</option>';
|
||
showNotification('WiFi Error', 'Could not detect WiFi interfaces: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// Enable monitor mode
|
||
function enableMonitorMode() {
|
||
const iface = document.getElementById('wifiInterfaceSelect').value;
|
||
if (!iface) {
|
||
alert('Please select an interface');
|
||
return;
|
||
}
|
||
|
||
const killProcesses = document.getElementById('killProcesses').checked;
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
// Show loading state
|
||
const btn = document.getElementById('monitorStartBtn');
|
||
const originalText = btn.textContent;
|
||
btn.textContent = 'Enabling...';
|
||
btn.disabled = true;
|
||
|
||
// Use agent endpoint if in agent mode
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/wifi/monitor`
|
||
: '/wifi/monitor';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
|
||
if (data.status === 'success') {
|
||
monitorInterface = data.monitor_interface;
|
||
updateMonitorStatus(true);
|
||
const location = isAgentMode ? ' on remote agent' : '';
|
||
showInfo('Monitor mode enabled on ' + monitorInterface + location + ' - Ready to scan!');
|
||
|
||
// Refresh interface list and auto-select the monitor interface
|
||
refreshWifiInterfaces();
|
||
} else {
|
||
alert('Error: ' + (data.message || 'Unknown error'));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
alert('Error: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// Disable monitor mode
|
||
function disableMonitorMode() {
|
||
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/wifi/monitor`
|
||
: '/wifi/monitor';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, action: 'stop' })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
monitorInterface = null;
|
||
updateMonitorStatus(false);
|
||
showInfo('Monitor mode disabled');
|
||
} else {
|
||
alert('Error: ' + (data.message || 'Unknown error'));
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateMonitorStatus(enabled) {
|
||
document.getElementById('monitorStartBtn').style.display = enabled ? 'none' : 'block';
|
||
document.getElementById('monitorStopBtn').style.display = enabled ? 'block' : 'none';
|
||
document.getElementById('monitorStatus').innerHTML = enabled
|
||
? 'Monitor mode: <span style="color: var(--accent-green);">Active (' + monitorInterface + ')</span>'
|
||
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
|
||
}
|
||
|
||
function getWifiChannelPresetList(preset) {
|
||
switch (preset) {
|
||
case '2.4-common':
|
||
return '1,6,11';
|
||
case '2.4-all':
|
||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||
case '5-low':
|
||
return '36,40,44,48';
|
||
case '5-mid':
|
||
return '52,56,60,64';
|
||
case '5-high':
|
||
return '149,153,157,161,165';
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function buildWifiChannelConfig() {
|
||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||
|
||
const listValue = listInput.trim();
|
||
const presetValue = getWifiChannelPresetList(preset);
|
||
const channels = listValue || presetValue || '';
|
||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||
|
||
return {
|
||
channels: channels || null,
|
||
channel: Number.isFinite(channel) ? channel : null,
|
||
};
|
||
}
|
||
|
||
// Start WiFi scan - auto-enables monitor mode if needed
|
||
async function startWifiScan() {
|
||
console.log('startWifiScan called');
|
||
const band = document.getElementById('wifiBand').value;
|
||
const channelConfig = buildWifiChannelConfig();
|
||
|
||
// Auto-enable monitor mode if not already enabled
|
||
if (!monitorInterface) {
|
||
const iface = document.getElementById('wifiInterfaceSelect').value;
|
||
console.log('Selected interface:', iface);
|
||
|
||
if (!iface) {
|
||
showNotification('WiFi Error', 'No WiFi interface selected. Please select an adapter from the dropdown.');
|
||
alert('No WiFi interface selected. Please select an adapter from the dropdown above.');
|
||
return;
|
||
}
|
||
|
||
// Show status
|
||
document.getElementById('statusText').textContent = 'Enabling monitor mode...';
|
||
document.getElementById('statusDot').classList.add('running');
|
||
showNotification('WiFi', 'Enabling monitor mode on ' + iface + '...');
|
||
|
||
try {
|
||
const killProcesses = document.getElementById('killProcesses').checked;
|
||
console.log('Enabling monitor mode, kill processes:', killProcesses);
|
||
|
||
const monitorResp = await fetch('/wifi/monitor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses })
|
||
});
|
||
const monitorData = await monitorResp.json();
|
||
console.log('Monitor response:', monitorData);
|
||
|
||
if (monitorData.status === 'success') {
|
||
monitorInterface = monitorData.monitor_interface;
|
||
updateMonitorStatus(true);
|
||
showNotification('Monitor Mode', 'Enabled on ' + monitorInterface);
|
||
} else {
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Monitor Error', monitorData.message || 'Failed to enable monitor mode');
|
||
alert('Monitor mode failed: ' + (monitorData.message || 'Unknown error'));
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
console.error('Monitor mode error:', err);
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Monitor Error', err.message);
|
||
alert('Monitor mode error: ' + err.message);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Now start the scan
|
||
document.getElementById('statusText').textContent = 'Starting scan...';
|
||
console.log('Starting scan on', monitorInterface);
|
||
|
||
try {
|
||
const scanResp = await fetch('/wifi/scan/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
interface: monitorInterface,
|
||
band: band,
|
||
channel: channelConfig.channel,
|
||
channels: channelConfig.channels,
|
||
})
|
||
});
|
||
const scanData = await scanResp.json();
|
||
console.log('Scan response:', scanData);
|
||
|
||
if (scanData.status === 'started') {
|
||
setWifiRunning(true);
|
||
startWifiStream();
|
||
showNotification('WiFi Scanner', 'Scanning started on ' + monitorInterface);
|
||
} else {
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Scan Error', scanData.message || 'Failed to start scan');
|
||
alert('Scan failed: ' + (scanData.message || 'Unknown error'));
|
||
}
|
||
} catch (err) {
|
||
console.error('Scan error:', err);
|
||
document.getElementById('statusText').textContent = 'Idle';
|
||
document.getElementById('statusDot').classList.remove('running');
|
||
showNotification('Scan Error', err.message);
|
||
alert('Scan error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// Stop WiFi scan
|
||
function stopWifiScan() {
|
||
setWifiRunning(false);
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
wifiEventSource = null;
|
||
}
|
||
|
||
if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') {
|
||
return Promise.resolve(WiFiMode.stopScan()).catch((err) => {
|
||
console.warn('[WiFi] stop via WiFiMode failed:', err);
|
||
});
|
||
}
|
||
|
||
return postStopRequest('/wifi/scan/stop', LOCAL_STOP_TIMEOUT_MS);
|
||
}
|
||
|
||
function setWifiRunning(running) {
|
||
isWifiRunning = running;
|
||
document.getElementById('statusDot').classList.toggle('running', running);
|
||
document.getElementById('statusText').textContent = running ? 'Scanning...' : 'Idle';
|
||
document.getElementById('startWifiBtn').style.display = running ? 'none' : 'block';
|
||
document.getElementById('stopWifiBtn').style.display = running ? 'block' : 'none';
|
||
}
|
||
|
||
// Batching state for WiFi updates
|
||
let pendingWifiUpdate = false;
|
||
let pendingWifiNetworks = [];
|
||
let pendingWifiClients = [];
|
||
|
||
function scheduleWifiUIUpdate() {
|
||
if (pendingWifiUpdate) return;
|
||
pendingWifiUpdate = true;
|
||
requestAnimationFrame(() => {
|
||
// Process networks
|
||
pendingWifiNetworks.forEach(data => handleWifiNetworkImmediate(data));
|
||
pendingWifiNetworks = [];
|
||
|
||
// Process clients (limit to last 5 per frame)
|
||
const clientsToProcess = pendingWifiClients.slice(-5);
|
||
pendingWifiClients = [];
|
||
clientsToProcess.forEach(data => handleWifiClientImmediate(data));
|
||
|
||
// Update graphs once per frame instead of per-network
|
||
updateChannelGraph();
|
||
updateChannel5gGraph();
|
||
|
||
// Update selected device panel
|
||
updateWifiSelectedDevice();
|
||
|
||
// Update probe analysis (throttled)
|
||
if (clientsToProcess.length > 0) {
|
||
scheduleProbeAnalysisUpdate();
|
||
}
|
||
|
||
pendingWifiUpdate = false;
|
||
});
|
||
}
|
||
|
||
// Start WiFi event stream
|
||
function startWifiStream() {
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
}
|
||
|
||
wifiEventSource = new EventSource('/wifi/stream');
|
||
|
||
wifiEventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (data.type === 'network') {
|
||
pendingWifiNetworks.push(data);
|
||
scheduleWifiUIUpdate();
|
||
} else if (data.type === 'client') {
|
||
pendingWifiClients.push(data);
|
||
scheduleWifiUIUpdate();
|
||
} else if (data.type === 'info' || data.type === 'raw') {
|
||
showInfo(data.text);
|
||
} else if (data.type === 'error') {
|
||
showError(data.text);
|
||
} else if (data.type === 'status') {
|
||
if (data.text === 'stopped') {
|
||
setWifiRunning(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
wifiEventSource.onerror = function () {
|
||
console.error('WiFi stream error');
|
||
};
|
||
}
|
||
|
||
// Track networks that were originally hidden
|
||
let hiddenNetworks = {}; // {bssid: true} for networks first seen with hidden ESSID
|
||
|
||
// Handle discovered WiFi network (called from batched update)
|
||
function handleWifiNetworkImmediate(net) {
|
||
const isNew = !wifiNetworks[net.bssid];
|
||
const previousNet = wifiNetworks[net.bssid];
|
||
wifiNetworks[net.bssid] = net;
|
||
|
||
// Track if this network was originally hidden
|
||
if (isNew) {
|
||
const isHidden = !net.essid || net.essid === '' || net.essid === 'Hidden' || net.essid === '[Hidden]';
|
||
if (isHidden) {
|
||
hiddenNetworks[net.bssid] = true;
|
||
}
|
||
}
|
||
|
||
// Check if a previously hidden network now has a revealed SSID
|
||
if (hiddenNetworks[net.bssid] && net.essid && net.essid !== '' && net.essid !== 'Hidden' && net.essid !== '[Hidden]') {
|
||
revealHiddenSsid(net.bssid, net.essid);
|
||
delete hiddenNetworks[net.bssid]; // No longer hidden
|
||
}
|
||
|
||
if (isNew) {
|
||
apCount++;
|
||
document.getElementById('apCount').textContent = apCount;
|
||
playAlert();
|
||
pulseSignal();
|
||
|
||
// Check for rogue AP (same SSID, different BSSID)
|
||
checkRogueAP(net.essid, net.bssid, net.channel, net.power);
|
||
|
||
// Check proximity watch list
|
||
checkWatchList(net.bssid, 'AP');
|
||
|
||
// Check for drone
|
||
const droneCheck = isDrone(net.essid, net.bssid);
|
||
if (droneCheck.isDrone) {
|
||
handleDroneDetection(net, droneCheck);
|
||
showNotification('Drone Detected', `${droneCheck.brand}: ${net.essid}`);
|
||
}
|
||
}
|
||
|
||
// Update recon display
|
||
const droneInfo = isDrone(net.essid, net.bssid);
|
||
trackDevice({
|
||
protocol: droneInfo.isDrone ? 'DRONE' : 'WiFi-AP',
|
||
address: net.bssid,
|
||
message: net.essid || '[Hidden SSID]',
|
||
model: net.essid,
|
||
channel: net.channel,
|
||
privacy: net.privacy,
|
||
isDrone: droneInfo.isDrone,
|
||
droneBrand: droneInfo.brand
|
||
});
|
||
|
||
// Add to output
|
||
addWifiNetworkCard(net, isNew);
|
||
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
|
||
}
|
||
|
||
// Handle discovered WiFi client (called from batched update)
|
||
function handleWifiClientImmediate(client) {
|
||
const isNew = !wifiClients[client.mac];
|
||
wifiClients[client.mac] = client;
|
||
|
||
if (isNew) {
|
||
clientCount++;
|
||
document.getElementById('clientCount').textContent = clientCount;
|
||
|
||
// Check proximity watch list
|
||
checkWatchList(client.mac, 'Client');
|
||
}
|
||
|
||
// If client is connected to a hidden network and has probes, try to reveal the SSID
|
||
if (client.bssid && hiddenNetworks[client.bssid] && client.probes) {
|
||
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
|
||
if (probes.length > 0) {
|
||
// Use the first probe as the likely SSID for this hidden network
|
||
revealHiddenSsid(client.bssid, probes[0]);
|
||
delete hiddenNetworks[client.bssid];
|
||
}
|
||
}
|
||
|
||
// Track in device intelligence with vendor info
|
||
const vendorInfo = client.vendor && client.vendor !== 'Unknown' ? ` [${client.vendor}]` : '';
|
||
trackDevice({
|
||
protocol: 'WiFi-Client',
|
||
address: client.mac,
|
||
message: (client.probes || '[No probes]') + vendorInfo,
|
||
bssid: client.bssid,
|
||
vendor: client.vendor
|
||
});
|
||
|
||
// Update probe analysis when we get client data with probes
|
||
if (client.probes && client.probes.trim()) {
|
||
scheduleProbeAnalysisUpdate();
|
||
}
|
||
|
||
// Add client card to device list
|
||
addWifiClientCard(client, isNew);
|
||
}
|
||
|
||
// Throttled probe analysis (called less frequently)
|
||
let lastProbeAnalysisUpdate = 0;
|
||
function scheduleProbeAnalysisUpdate() {
|
||
const now = Date.now();
|
||
if (now - lastProbeAnalysisUpdate > 2000) {
|
||
lastProbeAnalysisUpdate = now;
|
||
updateProbeAnalysis();
|
||
}
|
||
}
|
||
|
||
// Update client probe analysis panel
|
||
function updateProbeAnalysis() {
|
||
const list = document.getElementById('probeAnalysisList');
|
||
if (!list) return;
|
||
|
||
const clientsWithProbes = Object.values(wifiClients).filter(c => c.probes && c.probes.trim());
|
||
const allProbes = new Set();
|
||
let privacyLeaks = 0;
|
||
|
||
// Count unique probes and privacy leaks
|
||
clientsWithProbes.forEach(client => {
|
||
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
|
||
probes.forEach(p => allProbes.add(p));
|
||
|
||
// Check for sensitive network names (home networks, corporate, etc.)
|
||
probes.forEach(probe => {
|
||
const lowerProbe = probe.toLowerCase();
|
||
if (lowerProbe.includes('home') || lowerProbe.includes('office') ||
|
||
lowerProbe.includes('corp') || lowerProbe.includes('work') ||
|
||
lowerProbe.includes('private') || lowerProbe.includes('hotel') ||
|
||
lowerProbe.includes('airport') || lowerProbe.match(/^[a-z]+-[a-z]+$/i)) {
|
||
privacyLeaks++;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Update counters (with null checks for v2 layout)
|
||
const probeClientEl = document.getElementById('probeClientCount');
|
||
const probeSSIDEl = document.getElementById('probeSSIDCount');
|
||
const probePrivacyEl = document.getElementById('probePrivacyCount');
|
||
if (probeClientEl) probeClientEl.textContent = clientsWithProbes.length;
|
||
if (probeSSIDEl) probeSSIDEl.textContent = allProbes.size;
|
||
if (probePrivacyEl) probePrivacyEl.textContent = privacyLeaks;
|
||
|
||
if (clientsWithProbes.length === 0) {
|
||
list.innerHTML = '<div style="color: var(--text-dim);">Waiting for client probe requests...</div>';
|
||
return;
|
||
}
|
||
|
||
// Sort by number of probes (most revealing first)
|
||
clientsWithProbes.sort((a, b) => {
|
||
const aCount = (a.probes || '').split(',').length;
|
||
const bCount = (b.probes || '').split(',').length;
|
||
return bCount - aCount;
|
||
});
|
||
|
||
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||
|
||
clientsWithProbes.forEach(client => {
|
||
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
|
||
const vendorBadge = client.vendor && client.vendor !== 'Unknown'
|
||
? `<span style="background: var(--bg-tertiary); padding: 1px 4px; border-radius: 2px; font-size: 9px; margin-left: 5px;">${escapeHtml(client.vendor)}</span>`
|
||
: '';
|
||
|
||
// Check for privacy-revealing probes
|
||
const probeHtml = probes.map(probe => {
|
||
const lowerProbe = probe.toLowerCase();
|
||
const isSensitive = lowerProbe.includes('home') || lowerProbe.includes('office') ||
|
||
lowerProbe.includes('corp') || lowerProbe.includes('work') ||
|
||
lowerProbe.includes('private') || lowerProbe.includes('hotel') ||
|
||
lowerProbe.includes('airport') || lowerProbe.match(/^[a-z]+-[a-z]+$/i);
|
||
|
||
const style = isSensitive
|
||
? 'background: var(--accent-orange); color: #000; padding: 1px 4px; border-radius: 2px; margin: 1px;'
|
||
: 'background: var(--bg-tertiary); padding: 1px 4px; border-radius: 2px; margin: 1px;';
|
||
|
||
return `<span style="${style}" title="${isSensitive ? 'Potentially sensitive - reveals user location history' : ''}">${escapeHtml(probe)}</span>`;
|
||
}).join(' ');
|
||
|
||
html += `
|
||
<div style="border-left: 2px solid var(--accent-cyan); padding-left: 8px; cursor: pointer;" onclick="selectWifiDevice('${escapeAttr(client.mac)}', 'client')" title="Click for details">
|
||
<div style="display: flex; align-items: center; gap: 5px; margin-bottom: 3px;">
|
||
<span style="color: var(--accent-cyan); font-family: monospace; font-size: 10px;">${escapeHtml(client.mac)}</span>
|
||
${vendorBadge}
|
||
<span style="color: var(--text-dim); font-size: 9px;">(${probes.length} probe${probes.length !== 1 ? 's' : ''})</span>
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 2px; font-size: 10px;">
|
||
${probeHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div>';
|
||
list.innerHTML = html;
|
||
}
|
||
|
||
// Select a WiFi network or client for detailed view
|
||
function selectWifiDevice(id, type) {
|
||
selectedWifiDevice = id;
|
||
selectedWifiType = type;
|
||
updateWifiSelectedDevice();
|
||
}
|
||
|
||
// Update the selected WiFi device panel
|
||
function updateWifiSelectedDevice() {
|
||
const panel = document.getElementById('wifiSelectedDevice');
|
||
if (!panel) return;
|
||
|
||
if (!selectedWifiDevice) {
|
||
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a network or client to view details</div>';
|
||
return;
|
||
}
|
||
|
||
if (selectedWifiType === 'network') {
|
||
const net = wifiNetworks[selectedWifiDevice];
|
||
if (!net) {
|
||
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Network no longer visible</div>';
|
||
return;
|
||
}
|
||
|
||
const power = parseInt(net.power) || -100;
|
||
const signalPercent = Math.max(0, Math.min(100, (power + 100) * 2));
|
||
const signalColor = power >= -50 ? 'var(--accent-green)' : power >= -70 ? 'var(--accent-orange)' : 'var(--accent-red)';
|
||
const isRogue = rogueBssids.has(net.bssid);
|
||
|
||
panel.innerHTML = `
|
||
${isRogue ? '<div class="rogue-indicator" style="margin: -10px -10px 10px -10px; padding: 8px;">SUSPECTED ROGUE ACCESS POINT</div>' : ''}
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||
<div style="grid-column: span 2; text-align: center; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);">
|
||
<div style="font-size: 18px; color: ${isRogue ? 'var(--accent-red)' : 'var(--accent-cyan)'}; font-weight: bold;">${escapeHtml(net.essid || '[Hidden]')}</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${escapeHtml(net.bssid)}</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">SIGNAL</div>
|
||
<div style="color: ${signalColor}; font-size: 16px; font-weight: bold;">${power} dBm</div>
|
||
<div style="background: var(--bg-tertiary); height: 4px; border-radius: 2px; margin-top: 4px;">
|
||
<div style="background: ${signalColor}; height: 100%; width: ${signalPercent}%; border-radius: 2px;"></div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">CHANNEL</div>
|
||
<div style="color: var(--accent-cyan); font-size: 16px; font-weight: bold;">${net.channel}</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">SECURITY</div>
|
||
<div style="color: ${(net.privacy || '').includes('WPA3') ? 'var(--accent-green)' : (net.privacy || '').includes('WPA') ? 'var(--accent-orange)' : 'var(--accent-red)'};">${escapeHtml(net.privacy || 'Unknown')}</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">BEACONS</div>
|
||
<div style="color: var(--text-secondary);">${net.beacons || 0}</div>
|
||
</div>
|
||
<div style="grid-column: span 2; display: flex; gap: 8px; margin-top: 8px;">
|
||
<button class="preset-btn" onclick="targetNetwork('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="flex: 1;">Target</button>
|
||
<button class="preset-btn" onclick="captureHandshake('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="flex: 1; border-color: var(--accent-orange); color: var(--accent-orange);">Handshake</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (selectedWifiType === 'client') {
|
||
const client = wifiClients[selectedWifiDevice];
|
||
if (!client) {
|
||
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Client no longer visible</div>';
|
||
return;
|
||
}
|
||
|
||
const power = parseInt(client.power) || -100;
|
||
const signalPercent = Math.max(0, Math.min(100, (power + 100) * 2));
|
||
const signalColor = power >= -50 ? 'var(--accent-green)' : power >= -70 ? 'var(--accent-orange)' : 'var(--accent-red)';
|
||
const probes = (client.probes || '').split(',').map(p => p.trim()).filter(p => p);
|
||
const associatedNet = client.bssid && wifiNetworks[client.bssid];
|
||
|
||
panel.innerHTML = `
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||
<div style="grid-column: span 2; text-align: center; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);">
|
||
<div style="font-size: 14px; color: var(--accent-orange); font-weight: bold;">CLIENT DEVICE</div>
|
||
<div style="font-size: 12px; color: var(--text-secondary);">${escapeHtml(client.mac)}</div>
|
||
${client.vendor ? `<div style="font-size: 10px; color: var(--text-muted);">${escapeHtml(client.vendor)}</div>` : ''}
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">SIGNAL</div>
|
||
<div style="color: ${signalColor}; font-size: 16px; font-weight: bold;">${power} dBm</div>
|
||
<div style="background: var(--bg-tertiary); height: 4px; border-radius: 2px; margin-top: 4px;">
|
||
<div style="background: ${signalColor}; height: 100%; width: ${signalPercent}%; border-radius: 2px;"></div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">PACKETS</div>
|
||
<div style="color: var(--text-secondary);">${client.packets || 0}</div>
|
||
</div>
|
||
${associatedNet ? `
|
||
<div style="grid-column: span 2; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">CONNECTED TO</div>
|
||
<div style="color: var(--accent-cyan);">${escapeHtml(associatedNet.essid || associatedNet.bssid)}</div>
|
||
</div>
|
||
` : ''}
|
||
${probes.length > 0 ? `
|
||
<div style="grid-column: span 2; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-dim); font-size: 9px;">PROBING FOR</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
|
||
${probes.slice(0, 5).map(p => `<span style="background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px;">${escapeHtml(p)}</span>`).join('')}
|
||
${probes.length > 5 ? `<span style="color: var(--text-muted);">+${probes.length - 5} more</span>` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Add WiFi network card to device list
|
||
function addWifiNetworkCard(net, isNew) {
|
||
// Use the WiFi device list panel instead of the generic output
|
||
const deviceList = document.getElementById('wifiDeviceListContent');
|
||
if (!deviceList) return;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder && placeholder.textContent.includes('Start scanning')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Check if card already exists
|
||
let card = document.getElementById('wifi_' + net.bssid.replace(/:/g, ''));
|
||
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = 'wifi_' + net.bssid.replace(/:/g, '');
|
||
card.className = 'sensor-card wifi-network-card';
|
||
card.style.borderLeftColor = net.privacy.includes('WPA') ? 'var(--accent-orange)' :
|
||
net.privacy.includes('WEP') ? 'var(--accent-red)' :
|
||
'var(--accent-green)';
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = () => selectWifiDevice(net.bssid, 'network');
|
||
deviceList.insertBefore(card, deviceList.firstChild);
|
||
|
||
// Update device count
|
||
const countEl = document.getElementById('wifiDeviceListCount');
|
||
if (countEl) countEl.textContent = Object.keys(wifiNetworks).length;
|
||
}
|
||
|
||
// Handle signal strength - airodump returns -1 when not measured
|
||
let signalStrength = parseInt(net.power);
|
||
if (isNaN(signalStrength) || signalStrength === -1) {
|
||
signalStrength = null; // No reading available
|
||
}
|
||
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
|
||
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
|
||
|
||
const wpsEnabled = net.wps === '1' || net.wps === 'Yes' || (net.privacy || '').includes('WPS');
|
||
const wpsHtml = wpsEnabled ? '<span class="wps-enabled">WPS</span>' : '';
|
||
const isRogue = rogueBssids.has(net.bssid);
|
||
const rogueHtml = isRogue ? '<div class="rogue-indicator">SUSPECTED ROGUE AP</div>' : '';
|
||
|
||
// Update card border for rogue APs
|
||
if (isRogue) {
|
||
card.style.borderLeftColor = 'var(--accent-red)';
|
||
card.style.borderLeftWidth = '4px';
|
||
card.style.background = 'rgba(255, 0, 0, 0.1)';
|
||
}
|
||
|
||
card.innerHTML = `
|
||
${rogueHtml}
|
||
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||
<span class="device-name">${escapeHtml(net.essid || '[Hidden]')}${wpsHtml}</span>
|
||
<span style="color: #444; font-size: 10px;">CH ${net.channel}</span>
|
||
</div>
|
||
<div class="sensor-data">
|
||
<div class="data-item">
|
||
<div class="data-label">BSSID</div>
|
||
<div class="data-value" style="font-size: 11px;">${escapeHtml(net.bssid)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Security</div>
|
||
<div class="data-value" style="color: ${(net.privacy || '').includes('WPA') ? 'var(--accent-orange)' : net.privacy === 'OPN' ? 'var(--accent-green)' : 'var(--accent-red)'}">${escapeHtml(net.privacy || '')}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Signal</div>
|
||
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Beacons</div>
|
||
<div class="data-value">${net.beacons}</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 5px; flex-wrap: wrap;">
|
||
<button class="preset-btn" onclick="targetNetwork('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px;">Target</button>
|
||
<button class="preset-btn" onclick="captureHandshake('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px; border-color: var(--accent-orange); color: var(--accent-orange);">Handshake</button>
|
||
</div>
|
||
`;
|
||
|
||
if (autoScroll) output.scrollTop = 0;
|
||
|
||
// Feed to activity timeline if it's a new network
|
||
if (isNew && typeof addTimelineEvent === 'function') {
|
||
const normalized = typeof WiFiTimelineAdapter !== 'undefined'
|
||
? WiFiTimelineAdapter.normalizeNetwork({
|
||
ssid: net.essid,
|
||
bssid: net.bssid,
|
||
channel: net.channel,
|
||
rssi: signalStrength,
|
||
security: net.privacy
|
||
})
|
||
: {
|
||
id: net.bssid,
|
||
label: net.essid || '[Hidden]',
|
||
strength: signalBars || 3,
|
||
duration: 1500,
|
||
type: 'wifi'
|
||
};
|
||
addTimelineEvent('wifi', normalized);
|
||
}
|
||
}
|
||
|
||
// Add WiFi client card to device list
|
||
function addWifiClientCard(client, isNew) {
|
||
const deviceList = document.getElementById('wifiDeviceListContent');
|
||
if (!deviceList) return;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
|
||
if (placeholder && placeholder.textContent.includes('Start scanning')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Check if card already exists
|
||
let card = document.getElementById('client_' + client.mac.replace(/:/g, ''));
|
||
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = 'client_' + client.mac.replace(/:/g, '');
|
||
card.className = 'sensor-card wifi-client-card';
|
||
card.style.borderLeftColor = 'var(--accent-purple)';
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = () => selectWifiDevice(client.mac, 'client');
|
||
deviceList.appendChild(card); // Clients go after networks
|
||
|
||
// Update device count
|
||
const countEl = document.getElementById('wifiDeviceListCount');
|
||
if (countEl) countEl.textContent = Object.keys(wifiNetworks).length + Object.keys(wifiClients).length;
|
||
}
|
||
|
||
// Handle signal strength
|
||
let signalStrength = parseInt(client.power);
|
||
if (isNaN(signalStrength) || signalStrength === -1) {
|
||
signalStrength = null;
|
||
}
|
||
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
|
||
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
|
||
|
||
// Get connected AP info
|
||
const connectedAP = client.bssid && wifiNetworks[client.bssid];
|
||
const apName = connectedAP ? (connectedAP.essid || '[Hidden]') : (client.bssid || 'Not Associated');
|
||
|
||
// Format probes
|
||
const probes = client.probes ? client.probes.split(',').map(p => p.trim()).filter(p => p) : [];
|
||
const probesDisplay = probes.length > 0 ? probes.slice(0, 3).join(', ') + (probes.length > 3 ? ` +${probes.length - 3}` : '') : 'None';
|
||
|
||
card.innerHTML = `
|
||
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||
<span class="device-name" style="color: var(--accent-purple);">${escapeHtml(client.vendor || 'Client')}</span>
|
||
<span style="font-size: 10px; color: var(--text-dim);">CLIENT</span>
|
||
</div>
|
||
<div class="sensor-data">
|
||
<div class="data-item">
|
||
<div class="data-label">MAC</div>
|
||
<div class="data-value" style="font-size: 11px;">${escapeHtml(client.mac)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Connected To</div>
|
||
<div class="data-value" style="color: var(--accent-cyan);">${escapeHtml(apName)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Signal</div>
|
||
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">Probes</div>
|
||
<div class="data-value" style="font-size: 10px;">${escapeHtml(probesDisplay)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Target a network for attack
|
||
function targetNetwork(bssid, channel) {
|
||
document.getElementById('targetBssid').value = bssid;
|
||
document.getElementById('wifiChannel').value = channel;
|
||
showInfo('Targeted: ' + bssid + ' on channel ' + channel);
|
||
}
|
||
|
||
// Start handshake capture
|
||
async function captureHandshake(bssid, channel) {
|
||
if (!confirm('Start handshake capture for ' + bssid + '? This will stop the current scan.')) {
|
||
return;
|
||
}
|
||
|
||
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
|
||
if (!iface) {
|
||
showError('No monitor interface available. Enable monitor mode first.');
|
||
return;
|
||
}
|
||
|
||
// Stop any existing scan first
|
||
if (isWifiRunning) {
|
||
showInfo('Stopping current scan...');
|
||
try {
|
||
await fetch('/wifi/scan/stop', { method: 'POST' });
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
wifiEventSource = null;
|
||
}
|
||
setWifiRunning(false);
|
||
// Brief delay to ensure process stops
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
} catch (e) {
|
||
console.error('Error stopping scan:', e);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/wifi/handshake/capture', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bssid: bssid, channel: channel, interface: iface })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'started') {
|
||
showInfo('Capturing handshakes for ' + bssid);
|
||
setWifiRunning(true);
|
||
|
||
// Update handshake indicator to show active capture
|
||
const hsSpan = document.getElementById('handshakeCount');
|
||
hsSpan.style.animation = 'pulse 1s infinite';
|
||
hsSpan.title = 'Capturing: ' + bssid;
|
||
|
||
// Show capture status panel
|
||
const panel = document.getElementById('captureStatusPanel');
|
||
panel.style.display = 'block';
|
||
document.getElementById('captureTargetBssid').textContent = bssid;
|
||
document.getElementById('captureTargetChannel').textContent = channel;
|
||
document.getElementById('captureFilePath').textContent = data.capture_file;
|
||
document.getElementById('captureStatus').textContent = 'Waiting for handshake...';
|
||
document.getElementById('captureStatus').style.color = 'var(--accent-orange)';
|
||
|
||
// Store active capture info and start polling
|
||
activeCapture = {
|
||
bssid: bssid,
|
||
channel: channel,
|
||
file: data.capture_file,
|
||
startTime: Date.now(),
|
||
pollInterval: setInterval(checkCaptureStatus, 5000) // Check every 5 seconds
|
||
};
|
||
} else {
|
||
showError('Handshake capture failed: ' + (data.message || 'Unknown error'));
|
||
}
|
||
} catch (err) {
|
||
showError('Handshake capture error: ' + err.message);
|
||
console.error('Handshake capture error:', err);
|
||
}
|
||
}
|
||
|
||
// Check handshake capture status
|
||
function checkCaptureStatus() {
|
||
if (!activeCapture) {
|
||
showInfo('No active handshake capture');
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/handshake/status', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ file: activeCapture.file, bssid: activeCapture.bssid })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
const statusSpan = document.getElementById('captureStatus');
|
||
const elapsed = Math.round((Date.now() - activeCapture.startTime) / 1000);
|
||
const elapsedStr = elapsed < 60 ? elapsed + 's' : Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's';
|
||
|
||
if (data.handshake_found) {
|
||
// Handshake captured!
|
||
statusSpan.textContent = '✓ VALID HANDSHAKE CAPTURED!';
|
||
statusSpan.style.color = 'var(--accent-green)';
|
||
handshakeCount++;
|
||
document.getElementById('handshakeCount').textContent = handshakeCount;
|
||
playAlert();
|
||
showInfo('Handshake captured for ' + activeCapture.bssid + '. File: ' + data.file);
|
||
showNotification('Handshake Captured!', `Target: ${activeCapture.bssid}`);
|
||
|
||
// Stop polling
|
||
if (activeCapture.pollInterval) {
|
||
clearInterval(activeCapture.pollInterval);
|
||
}
|
||
document.getElementById('handshakeCount').style.animation = '';
|
||
|
||
// Show crack button in the capture panel
|
||
const panel = document.getElementById('captureStatusPanel');
|
||
const existingCrackBtn = panel.querySelector('.crack-btn');
|
||
if (!existingCrackBtn) {
|
||
const crackDiv = document.createElement('div');
|
||
crackDiv.style.marginTop = '10px';
|
||
crackDiv.innerHTML = `
|
||
<button class="preset-btn crack-btn" onclick="crackHandshake('${data.file}', '${activeCapture.bssid}')" style="width: 100%; background: var(--accent-green); border-color: var(--accent-green); color: #000; font-weight: bold;">
|
||
Crack with Aircrack-ng
|
||
</button>
|
||
`;
|
||
panel.querySelector('.section') ? panel.querySelector('.section').appendChild(crackDiv) : panel.appendChild(crackDiv);
|
||
}
|
||
|
||
// Store the captured file for later use
|
||
activeCapture.captured = true;
|
||
activeCapture.capturedFile = data.file;
|
||
} else if (data.file_exists) {
|
||
const sizeKB = (data.file_size / 1024).toFixed(1);
|
||
let extra = '';
|
||
if (data.handshake_checked && data.handshake_valid === false) {
|
||
extra = data.handshake_reason ? ' • ' + data.handshake_reason : ' • No valid handshake yet';
|
||
}
|
||
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')' + extra;
|
||
statusSpan.style.color = 'var(--accent-orange)';
|
||
} else if (data.status === 'stopped') {
|
||
statusSpan.textContent = 'Capture stopped';
|
||
statusSpan.style.color = 'var(--text-dim)';
|
||
if (activeCapture.pollInterval) {
|
||
clearInterval(activeCapture.pollInterval);
|
||
}
|
||
} else {
|
||
statusSpan.textContent = 'Waiting for data... (' + elapsedStr + ')';
|
||
statusSpan.style.color = 'var(--accent-orange)';
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Capture status check failed:', err);
|
||
});
|
||
}
|
||
|
||
// Stop handshake capture
|
||
function stopHandshakeCapture() {
|
||
if (activeCapture && activeCapture.pollInterval) {
|
||
clearInterval(activeCapture.pollInterval);
|
||
}
|
||
|
||
// Stop the WiFi scan (which stops airodump-ng)
|
||
stopWifiScan();
|
||
|
||
document.getElementById('captureStatus').textContent = 'Stopped';
|
||
document.getElementById('captureStatus').style.color = 'var(--text-dim)';
|
||
document.getElementById('handshakeCount').style.animation = '';
|
||
|
||
// Keep the panel visible so user can see the file path
|
||
showInfo('Handshake capture stopped. Check ' + (activeCapture ? activeCapture.file : 'capture file'));
|
||
|
||
activeCapture = null;
|
||
}
|
||
|
||
// Crack handshake with aircrack-ng
|
||
function crackHandshake(captureFile, bssid) {
|
||
const wordlist = prompt('Enter path to wordlist file:\n\nCommon locations:\n- /usr/share/wordlists/rockyou.txt\n- /usr/share/john/password.lst', '/usr/share/wordlists/rockyou.txt');
|
||
|
||
if (!wordlist) {
|
||
showInfo('Cracking cancelled');
|
||
return;
|
||
}
|
||
|
||
showInfo('Starting aircrack-ng... This may take a while.');
|
||
|
||
fetch('/wifi/handshake/crack', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
capture_file: captureFile,
|
||
bssid: bssid,
|
||
wordlist: wordlist
|
||
})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.password) {
|
||
showInfo('PASSWORD FOUND: ' + data.password);
|
||
showNotification('Password Cracked', data.password);
|
||
alert('Password found!\n\n' + data.password + '\n\nThis has been logged.');
|
||
} else if (data.status === 'not_found') {
|
||
showInfo('Password not found in wordlist. Try a different wordlist.');
|
||
alert('Password not found in wordlist.\n\nTry using a larger or different wordlist.');
|
||
} else if (data.status === 'running') {
|
||
showInfo('Aircrack-ng is running in background. Check terminal for progress.');
|
||
} else {
|
||
showError('Crack failed: ' + (data.message || 'Unknown error'));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showError('Crack error: ' + err.message);
|
||
console.error('Crack error:', err);
|
||
});
|
||
}
|
||
|
||
// Beacon Flood Detection
|
||
let beaconHistory = [];
|
||
let lastBeaconCheck = Date.now();
|
||
|
||
function checkBeaconFlood(networks) {
|
||
const now = Date.now();
|
||
const windowMs = 5000; // 5 second window
|
||
|
||
// Add current networks to history
|
||
beaconHistory.push({ time: now, count: Object.keys(networks).length });
|
||
|
||
// Remove old entries
|
||
beaconHistory = beaconHistory.filter(h => now - h.time < windowMs);
|
||
|
||
// Calculate rate of new networks
|
||
if (beaconHistory.length >= 2) {
|
||
const oldest = beaconHistory[0];
|
||
const newest = beaconHistory[beaconHistory.length - 1];
|
||
const timeDiff = (newest.time - oldest.time) / 1000;
|
||
const countDiff = newest.count - oldest.count;
|
||
|
||
if (timeDiff > 0) {
|
||
const rate = countDiff / timeDiff;
|
||
|
||
// Alert if more than 10 new networks per second
|
||
if (rate > 10) {
|
||
document.getElementById('beaconFloodAlert').style.display = 'block';
|
||
document.getElementById('beaconFloodRate').textContent = rate.toFixed(1);
|
||
if (!muted) playAlertSound();
|
||
} else if (rate < 2) {
|
||
document.getElementById('beaconFloodAlert').style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send deauth
|
||
function sendDeauth() {
|
||
const bssid = document.getElementById('targetBssid').value;
|
||
const client = document.getElementById('targetClient').value || 'FF:FF:FF:FF:FF:FF';
|
||
const count = document.getElementById('deauthCount').value || '5';
|
||
|
||
if (!bssid) {
|
||
alert('Enter target BSSID');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Send ' + count + ' deauth packets to ' + bssid + '?\\n\\n⚠ Only use on networks you own or have authorization to test!')) {
|
||
return;
|
||
}
|
||
|
||
fetch('/wifi/deauth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bssid: bssid, client: client, count: parseInt(count) })
|
||
}).then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
showInfo(data.message);
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============== WIFI VISUALIZATIONS ==============
|
||
|
||
let radarCtx = null;
|
||
let radarAngle = 0;
|
||
let radarAnimFrame = null;
|
||
let radarNetworks = []; // {x, y, strength, ssid, bssid}
|
||
let targetBssidForSignal = null;
|
||
|
||
// Initialize radar canvas
|
||
function initRadar() {
|
||
const canvas = document.getElementById('radarCanvas');
|
||
if (!canvas) return;
|
||
|
||
radarCtx = canvas.getContext('2d');
|
||
canvas.width = 150;
|
||
canvas.height = 150;
|
||
|
||
// Start animation
|
||
if (!radarAnimFrame) {
|
||
animateRadar();
|
||
}
|
||
}
|
||
|
||
// Animate radar sweep
|
||
function animateRadar() {
|
||
if (!radarCtx) {
|
||
radarAnimFrame = null;
|
||
return;
|
||
}
|
||
|
||
const canvas = radarCtx.canvas;
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 5;
|
||
|
||
// Clear canvas
|
||
radarCtx.fillStyle = 'rgba(0, 10, 10, 0.1)';
|
||
radarCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Draw grid circles
|
||
radarCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
|
||
radarCtx.lineWidth = 1;
|
||
for (let r = radius / 4; r <= radius; r += radius / 4) {
|
||
radarCtx.beginPath();
|
||
radarCtx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
radarCtx.stroke();
|
||
}
|
||
|
||
// Draw crosshairs
|
||
radarCtx.beginPath();
|
||
radarCtx.moveTo(cx, cy - radius);
|
||
radarCtx.lineTo(cx, cy + radius);
|
||
radarCtx.moveTo(cx - radius, cy);
|
||
radarCtx.lineTo(cx + radius, cy);
|
||
radarCtx.stroke();
|
||
|
||
// Draw sweep line
|
||
radarCtx.strokeStyle = 'rgba(0, 255, 136, 0.8)';
|
||
radarCtx.lineWidth = 2;
|
||
radarCtx.beginPath();
|
||
radarCtx.moveTo(cx, cy);
|
||
radarCtx.lineTo(
|
||
cx + Math.cos(radarAngle) * radius,
|
||
cy + Math.sin(radarAngle) * radius
|
||
);
|
||
radarCtx.stroke();
|
||
|
||
// Draw sweep gradient
|
||
const gradient = radarCtx.createConicalGradient ?
|
||
null : // Not supported in all browsers
|
||
radarCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||
|
||
radarCtx.fillStyle = 'rgba(0, 255, 136, 0.05)';
|
||
radarCtx.beginPath();
|
||
radarCtx.moveTo(cx, cy);
|
||
radarCtx.arc(cx, cy, radius, radarAngle - 0.5, radarAngle);
|
||
radarCtx.closePath();
|
||
radarCtx.fill();
|
||
|
||
// Draw network blips
|
||
radarNetworks.forEach(net => {
|
||
const age = Date.now() - net.timestamp;
|
||
const alpha = Math.max(0.1, 1 - age / 10000);
|
||
|
||
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha})`;
|
||
radarCtx.beginPath();
|
||
radarCtx.arc(net.x, net.y, 4 + (1 - alpha) * 3, 0, Math.PI * 2);
|
||
radarCtx.fill();
|
||
|
||
// Glow effect
|
||
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha * 0.3})`;
|
||
radarCtx.beginPath();
|
||
radarCtx.arc(net.x, net.y, 8 + (1 - alpha) * 5, 0, Math.PI * 2);
|
||
radarCtx.fill();
|
||
});
|
||
|
||
// Update angle
|
||
radarAngle += 0.03;
|
||
if (radarAngle > Math.PI * 2) radarAngle = 0;
|
||
|
||
radarAnimFrame = requestAnimationFrame(animateRadar);
|
||
}
|
||
|
||
// Add network to radar
|
||
function addNetworkToRadar(net) {
|
||
const canvas = document.getElementById('radarCanvas');
|
||
if (!canvas) return;
|
||
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 10;
|
||
|
||
// Convert signal strength to distance (stronger = closer)
|
||
const power = parseInt(net.power) || -80;
|
||
const distance = Math.max(0.1, Math.min(1, (power + 100) / 60));
|
||
const r = radius * (1 - distance);
|
||
|
||
// Random angle based on BSSID hash
|
||
let angle = 0;
|
||
for (let i = 0; i < net.bssid.length; i++) {
|
||
angle += net.bssid.charCodeAt(i);
|
||
}
|
||
angle = (angle % 360) * Math.PI / 180;
|
||
|
||
const x = cx + Math.cos(angle) * r;
|
||
const y = cy + Math.sin(angle) * r;
|
||
|
||
// Update or add
|
||
const existing = radarNetworks.find(n => n.bssid === net.bssid);
|
||
if (existing) {
|
||
existing.x = x;
|
||
existing.y = y;
|
||
existing.timestamp = Date.now();
|
||
} else {
|
||
radarNetworks.push({
|
||
x, y,
|
||
bssid: net.bssid,
|
||
ssid: net.essid,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
// Limit to 50 networks
|
||
if (radarNetworks.length > 50) {
|
||
radarNetworks.shift();
|
||
}
|
||
}
|
||
|
||
// Update channel graph
|
||
function updateChannelGraph() {
|
||
const channels = {};
|
||
for (let i = 1; i <= 13; i++) channels[i] = 0;
|
||
|
||
// Count networks per channel
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const ch = parseInt(net.channel);
|
||
if (ch >= 1 && ch <= 13) {
|
||
channels[ch]++;
|
||
}
|
||
});
|
||
|
||
// Find max for scaling
|
||
const maxCount = Math.max(1, ...Object.values(channels));
|
||
|
||
// Update bars
|
||
const bars = document.querySelectorAll('#channelGraph .channel-bar');
|
||
bars.forEach((bar, i) => {
|
||
const ch = i + 1;
|
||
const count = channels[ch] || 0;
|
||
const height = Math.max(2, (count / maxCount) * 55);
|
||
bar.style.height = height + 'px';
|
||
|
||
bar.classList.remove('active', 'congested', 'very-congested');
|
||
if (count > 0) bar.classList.add('active');
|
||
if (count >= 3) bar.classList.add('congested');
|
||
if (count >= 5) bar.classList.add('very-congested');
|
||
});
|
||
}
|
||
|
||
// Update security donut chart
|
||
function updateSecurityDonut() {
|
||
const canvas = document.getElementById('securityCanvas');
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const radius = Math.min(cx, cy) - 2;
|
||
const innerRadius = radius * 0.6;
|
||
|
||
// Count security types
|
||
let wpa3 = 0, wpa2 = 0, wep = 0, open = 0;
|
||
Object.values(wifiNetworks).forEach(net => {
|
||
const priv = (net.privacy || '').toUpperCase();
|
||
if (priv.includes('WPA3')) wpa3++;
|
||
else if (priv.includes('WPA')) wpa2++;
|
||
else if (priv.includes('WEP')) wep++;
|
||
else if (priv === 'OPN' || priv === '' || priv === 'OPEN') open++;
|
||
else wpa2++; // Default to WPA2
|
||
});
|
||
|
||
const total = wpa3 + wpa2 + wep + open;
|
||
|
||
// Update legend
|
||
document.getElementById('wpa3Count').textContent = wpa3;
|
||
document.getElementById('wpa2Count').textContent = wpa2;
|
||
document.getElementById('wepCount').textContent = wep;
|
||
document.getElementById('openCount').textContent = open;
|
||
|
||
// Clear canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
if (total === 0) {
|
||
// Draw empty circle
|
||
ctx.strokeStyle = '#1a1a1a';
|
||
ctx.lineWidth = radius - innerRadius;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, (radius + innerRadius) / 2, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
return;
|
||
}
|
||
|
||
// Draw segments
|
||
const colors = {
|
||
wpa3: '#00ff88',
|
||
wpa2: '#ff8800',
|
||
wep: '#ff3366',
|
||
open: '#00d4ff'
|
||
};
|
||
|
||
const data = [
|
||
{ value: wpa3, color: colors.wpa3 },
|
||
{ value: wpa2, color: colors.wpa2 },
|
||
{ value: wep, color: colors.wep },
|
||
{ value: open, color: colors.open }
|
||
];
|
||
|
||
let startAngle = -Math.PI / 2;
|
||
|
||
data.forEach(segment => {
|
||
if (segment.value === 0) return;
|
||
|
||
const sliceAngle = (segment.value / total) * Math.PI * 2;
|
||
|
||
ctx.fillStyle = segment.color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
startAngle += sliceAngle;
|
||
});
|
||
|
||
// Draw inner circle (donut hole)
|
||
ctx.fillStyle = '#000';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Draw total in center
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = 'bold 16px Roboto Condensed';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(total, cx, cy);
|
||
}
|
||
|
||
// Update signal strength meter for targeted network
|
||
function updateSignalMeter(net) {
|
||
if (!net) return;
|
||
|
||
targetBssidForSignal = net.bssid;
|
||
|
||
const ssidEl = document.getElementById('targetSsid');
|
||
const valueEl = document.getElementById('signalValue');
|
||
const barsEl = document.querySelectorAll('.signal-bar-large');
|
||
|
||
ssidEl.textContent = net.essid || net.bssid;
|
||
|
||
const power = parseInt(net.power) || -100;
|
||
valueEl.textContent = power + ' dBm';
|
||
|
||
// Determine signal quality
|
||
let quality = 'weak';
|
||
let activeBars = 1;
|
||
|
||
if (power >= -50) { quality = 'strong'; activeBars = 5; }
|
||
else if (power >= -60) { quality = 'strong'; activeBars = 4; }
|
||
else if (power >= -70) { quality = 'medium'; activeBars = 3; }
|
||
else if (power >= -80) { quality = 'medium'; activeBars = 2; }
|
||
else { quality = 'weak'; activeBars = 1; }
|
||
|
||
valueEl.className = 'signal-value ' + quality;
|
||
|
||
barsEl.forEach((bar, i) => {
|
||
bar.className = 'signal-bar-large';
|
||
if (i < activeBars) {
|
||
bar.classList.add('active', quality);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Hook into handleWifiNetworkImmediate to update visualizations
|
||
const originalHandleWifiNetworkImmediate = handleWifiNetworkImmediate;
|
||
handleWifiNetworkImmediate = function (net) {
|
||
originalHandleWifiNetworkImmediate(net);
|
||
|
||
// Update radar
|
||
addNetworkToRadar(net);
|
||
|
||
// Update security donut
|
||
updateSecurityDonut();
|
||
|
||
// Update signal meter if this is the targeted network
|
||
if (targetBssidForSignal === net.bssid) {
|
||
updateSignalMeter(net);
|
||
}
|
||
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
|
||
};
|
||
|
||
// Update targetNetwork to also set signal meter
|
||
const originalTargetNetwork = targetNetwork;
|
||
targetNetwork = function (bssid, channel) {
|
||
originalTargetNetwork(bssid, channel);
|
||
|
||
const net = wifiNetworks[bssid];
|
||
if (net) {
|
||
updateSignalMeter(net);
|
||
}
|
||
};
|
||
|
||
// ============== BLUETOOTH COMPATIBILITY SHIMS ==============
|
||
|
||
function getBluetoothModeApi() {
|
||
if (typeof BluetoothMode === 'undefined') return null;
|
||
return BluetoothMode;
|
||
}
|
||
|
||
function syncBtRunningState() {
|
||
const bt = getBluetoothModeApi();
|
||
if (!bt || typeof bt.isScanning !== 'function') {
|
||
return isBtRunning;
|
||
}
|
||
isBtRunning = bt.isScanning();
|
||
return isBtRunning;
|
||
}
|
||
|
||
function refreshBtInterfaces() {
|
||
const bt = getBluetoothModeApi();
|
||
if (!bt) return;
|
||
if (typeof bt.checkCapabilities === 'function') bt.checkCapabilities();
|
||
syncBtRunningState();
|
||
}
|
||
|
||
function startBtScan() {
|
||
const bt = getBluetoothModeApi();
|
||
if (!bt || typeof bt.startScan !== 'function') return;
|
||
bt.startScan();
|
||
setTimeout(syncBtRunningState, 0);
|
||
}
|
||
|
||
function stopBtScan() {
|
||
const bt = getBluetoothModeApi();
|
||
let stopPromise = Promise.resolve();
|
||
if (bt && typeof bt.stopScan === 'function') {
|
||
stopPromise = Promise.resolve(bt.stopScan()).catch((err) => {
|
||
console.warn('[BT] stop failed:', err);
|
||
});
|
||
}
|
||
setTimeout(syncBtRunningState, 0);
|
||
return stopPromise;
|
||
}
|
||
|
||
function setBtRunning(running) {
|
||
isBtRunning = !!running;
|
||
syncBtRunningState();
|
||
}
|
||
|
||
function initBtRadar() {
|
||
// Radar lifecycle is handled by BluetoothMode.
|
||
syncBtRunningState();
|
||
}
|
||
|
||
function resetBtAdapter() {
|
||
// Legacy hook retained for old callers.
|
||
if (typeof showInfo === 'function') {
|
||
showInfo('Bluetooth adapter reset is handled by the Bluetooth mode backend.');
|
||
} else {
|
||
console.info('Bluetooth adapter reset is handled by the Bluetooth mode backend.');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// APRS Functions
|
||
// ============================================
|
||
let aprsMap = null;
|
||
let aprsMarkers = {};
|
||
let aprsEventSource = null;
|
||
let isAprsRunning = false;
|
||
let aprsPacketCount = 0;
|
||
let aprsStationCount = 0;
|
||
let aprsMeterLastUpdate = 0;
|
||
let aprsMeterCheckInterval = null;
|
||
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
|
||
|
||
// APRS user location (from GPS)
|
||
let aprsUserLocation = { lat: null, lon: null };
|
||
let aprsUserMarker = null;
|
||
|
||
// Calculate distance in miles using Haversine formula
|
||
function aprsCalculateDistanceMi(lat1, lon1, lat2, lon2) {
|
||
const R = 3958.8; // Earth's radius in miles
|
||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
return R * c;
|
||
}
|
||
|
||
function aprsHasValidCoordinates(lat, lon) {
|
||
return lat != null && lon != null &&
|
||
Number.isFinite(Number(lat)) && Number.isFinite(Number(lon));
|
||
}
|
||
|
||
// Update APRS user location from GPS
|
||
function updateAprsUserLocation(position) {
|
||
const lat = Number(position && position.latitude);
|
||
const lon = Number(position && position.longitude);
|
||
if (!aprsHasValidCoordinates(lat, lon)) return;
|
||
|
||
aprsUserLocation.lat = lat;
|
||
aprsUserLocation.lon = lon;
|
||
|
||
// Update user marker on map
|
||
if (aprsMap) {
|
||
if (aprsUserMarker) {
|
||
aprsUserMarker.setLatLng([lat, lon]);
|
||
} else {
|
||
aprsUserMarker = L.marker([lat, lon], {
|
||
icon: L.divIcon({
|
||
className: 'aprs-user-marker',
|
||
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
|
||
iconSize: [14, 14],
|
||
iconAnchor: [7, 7]
|
||
}),
|
||
zIndexOffset: 1000
|
||
}).bindPopup('Your Location (GPS)').addTo(aprsMap);
|
||
}
|
||
|
||
// Center map on first GPS fix
|
||
if (!aprsMap._gpsInitialized) {
|
||
aprsMap.setView([lat, lon], 8);
|
||
aprsMap._gpsInitialized = true;
|
||
}
|
||
}
|
||
|
||
// Show GPS indicator
|
||
const indicator = document.getElementById('aprsGpsIndicator');
|
||
if (indicator) indicator.style.display = 'inline-flex';
|
||
|
||
// Update distances in existing station list
|
||
updateAprsStationDistances();
|
||
}
|
||
|
||
// Update distances for all stations in the list
|
||
function updateAprsStationDistances() {
|
||
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) return;
|
||
|
||
// Update station list items
|
||
const listEl = document.getElementById('aprsStationList');
|
||
if (listEl) {
|
||
listEl.querySelectorAll('[data-callsign]').forEach(stationEl => {
|
||
const lat = parseFloat(stationEl.dataset.lat);
|
||
const lon = parseFloat(stationEl.dataset.lon);
|
||
if (!isNaN(lat) && !isNaN(lon)) {
|
||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
|
||
const distSpan = stationEl.querySelector('.aprs-distance');
|
||
if (distSpan) {
|
||
distSpan.textContent = dist.toFixed(1) + ' mi';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function checkAprsTools() {
|
||
fetch('/aprs/tools')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Update function bar tool indicators
|
||
const direwolfEl = document.getElementById('aprsStripDirewolf');
|
||
const multimonEl = document.getElementById('aprsStripMultimon');
|
||
|
||
if (direwolfEl) {
|
||
direwolfEl.className = 'strip-tool' + (data.direwolf ? ' ok' : '');
|
||
direwolfEl.title = 'direwolf: ' + (data.direwolf ? 'OK' : 'Missing');
|
||
}
|
||
if (multimonEl) {
|
||
multimonEl.className = 'strip-tool' + (data.multimon_ng ? ' ok' : '');
|
||
multimonEl.title = 'multimon-ng: ' + (data.multimon_ng ? 'OK' : 'Missing');
|
||
}
|
||
})
|
||
.catch(() => {
|
||
const direwolfEl = document.getElementById('aprsStripDirewolf');
|
||
const multimonEl = document.getElementById('aprsStripMultimon');
|
||
if (direwolfEl) {
|
||
direwolfEl.className = 'strip-tool';
|
||
direwolfEl.title = 'direwolf: Error';
|
||
}
|
||
if (multimonEl) {
|
||
multimonEl.className = 'strip-tool';
|
||
multimonEl.title = 'multimon-ng: Error';
|
||
}
|
||
});
|
||
}
|
||
|
||
async function initAprsMap() {
|
||
if (aprsMap) return;
|
||
|
||
const mapContainer = document.getElementById('aprsMap');
|
||
if (!mapContainer) return;
|
||
|
||
// Use GPS location if available, otherwise default to center of US
|
||
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
|
||
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
|
||
const hasUserLocation = aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon);
|
||
const hasGpsLocation = aprsHasValidCoordinates(gpsLat, gpsLon);
|
||
|
||
const initialLat = hasUserLocation ? aprsUserLocation.lat : (hasGpsLocation ? gpsLat : 39.8283);
|
||
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
|
||
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
|
||
|
||
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
||
window.aprsMap = aprsMap;
|
||
|
||
// Use settings manager for tile layer (allows runtime changes)
|
||
if (typeof Settings !== 'undefined') {
|
||
// Wait for settings to load from server before applying tiles
|
||
await Settings.init();
|
||
Settings.createTileLayer().addTo(aprsMap);
|
||
Settings.registerMap(aprsMap);
|
||
} else {
|
||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||
maxZoom: 19,
|
||
subdomains: 'abcd',
|
||
className: 'tile-layer-cyan'
|
||
}).addTo(aprsMap);
|
||
}
|
||
|
||
// Add user marker if GPS position is already available
|
||
if (gpsConnected && hasGpsLocation) {
|
||
updateAprsUserLocation({ latitude: gpsLat, longitude: gpsLon });
|
||
aprsMap._gpsInitialized = true;
|
||
}
|
||
|
||
// Update time display (both map header and function bar)
|
||
setInterval(() => {
|
||
const now = new Date();
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
|
||
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
|
||
|
||
const timeEl = document.getElementById('aprsMapTime');
|
||
if (timeEl) timeEl.textContent = timeStr;
|
||
|
||
const stripTimeEl = document.getElementById('aprsStripTime');
|
||
if (stripTimeEl) stripTimeEl.textContent = utcStr;
|
||
}, 1000);
|
||
}
|
||
|
||
function updateAprsStatus(state, freq) {
|
||
// Update function bar status
|
||
const stripDot = document.getElementById('aprsStripDot');
|
||
const stripStatus = document.getElementById('aprsStripStatus');
|
||
const stripFreq = document.getElementById('aprsStripFreq');
|
||
|
||
if (stripDot) {
|
||
stripDot.className = 'status-dot ' + state;
|
||
}
|
||
if (stripStatus) {
|
||
stripStatus.textContent = state.toUpperCase();
|
||
if (state === 'listening') {
|
||
stripStatus.style.color = 'var(--accent-cyan)';
|
||
} else if (state === 'tracking') {
|
||
stripStatus.style.color = 'var(--accent-green)';
|
||
} else if (state === 'error') {
|
||
stripStatus.style.color = 'var(--accent-red)';
|
||
} else {
|
||
stripStatus.style.color = '';
|
||
}
|
||
}
|
||
if (freq && stripFreq) {
|
||
stripFreq.textContent = freq;
|
||
}
|
||
}
|
||
|
||
// APRS mode polling timer for agent mode
|
||
let aprsPollTimer = null;
|
||
let aprsCurrentAgent = null;
|
||
const aprsAgentStationSignatures = new Map();
|
||
|
||
function resetAprsAgentStationTracking() {
|
||
aprsAgentStationSignatures.clear();
|
||
}
|
||
|
||
function extractAprsStationsFromPayload(payload) {
|
||
if (!payload) return [];
|
||
if (Array.isArray(payload)) return payload;
|
||
if (Array.isArray(payload.stations)) return payload.stations;
|
||
if (Array.isArray(payload.data)) return payload.data;
|
||
if (payload.data && Array.isArray(payload.data.stations)) return payload.data.stations;
|
||
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data;
|
||
if (payload.result && Array.isArray(payload.result.stations)) return payload.result.stations;
|
||
if (payload.result && Array.isArray(payload.result.data)) return payload.result.data;
|
||
if (payload.data && payload.data.result && Array.isArray(payload.data.result.stations)) {
|
||
return payload.data.result.stations;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function getAprsStationSignature(station) {
|
||
if (!station || typeof station !== 'object') return '';
|
||
const receivedAt = station.received_at || station.last_seen || station.timestamp || '';
|
||
const lat = station.lat ?? station.latitude ?? '';
|
||
const lon = station.lon ?? station.longitude ?? '';
|
||
const payloadHint = station.raw || station.comment || station.path || '';
|
||
return `${receivedAt}|${lat},${lon}|${payloadHint}`;
|
||
}
|
||
|
||
function processAprsAgentStations(stations, agentName) {
|
||
if (!Array.isArray(stations) || stations.length === 0) return;
|
||
|
||
stations.forEach((station) => {
|
||
const callsign = String(station && station.callsign ? station.callsign : '').trim();
|
||
if (!callsign) return;
|
||
const lat = station.lat ?? station.latitude ?? null;
|
||
const lon = station.lon ?? station.longitude ?? null;
|
||
|
||
const signature = getAprsStationSignature(station);
|
||
if (aprsAgentStationSignatures.get(callsign) === signature) return;
|
||
aprsAgentStationSignatures.set(callsign, signature);
|
||
|
||
aprsPacketCount++;
|
||
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
|
||
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
|
||
|
||
const dot = document.getElementById('aprsStripDot');
|
||
if (dot && !dot.classList.contains('tracking')) {
|
||
updateAprsStatus('tracking');
|
||
}
|
||
|
||
processAprsPacket({
|
||
type: 'aprs',
|
||
...station,
|
||
lat,
|
||
lon,
|
||
callsign,
|
||
agent_name: station.agent_name || agentName || 'Remote Agent'
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadAprsStationSnapshot(isAgentMode = false) {
|
||
try {
|
||
const endpoint = (isAgentMode && aprsCurrentAgent)
|
||
? `/controller/agents/${aprsCurrentAgent}/aprs/data`
|
||
: '/aprs/stations';
|
||
const response = await fetch(endpoint);
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
const stations = extractAprsStationsFromPayload(payload);
|
||
if (!Array.isArray(stations) || stations.length === 0) return;
|
||
if (isAgentMode) {
|
||
processAprsAgentStations(stations, payload.agent_name);
|
||
return;
|
||
}
|
||
|
||
stations.forEach((station) => {
|
||
const callsign = String(station && station.callsign ? station.callsign : '').trim();
|
||
if (!callsign) return;
|
||
const packet = {
|
||
type: 'aprs',
|
||
...station,
|
||
callsign,
|
||
lat: station.lat ?? station.latitude ?? null,
|
||
lon: station.lon ?? station.longitude ?? null,
|
||
packet_type: station.packet_type || 'position',
|
||
};
|
||
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
|
||
updateAprsMarker(packet);
|
||
}
|
||
updateAprsStationList(packet);
|
||
});
|
||
} catch (err) {
|
||
console.debug('APRS snapshot load failed:', err);
|
||
}
|
||
}
|
||
|
||
function startAprs() {
|
||
// Get values from function bar controls
|
||
const region = document.getElementById('aprsStripRegion').value;
|
||
const device = getSelectedDevice();
|
||
const gain = document.getElementById('aprsStripGain').value;
|
||
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
|
||
|
||
// Check if using agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
aprsCurrentAgent = isAgentMode ? currentAgent : null;
|
||
|
||
// Build request body
|
||
const requestBody = {
|
||
region,
|
||
device: parseInt(device),
|
||
gain: parseInt(gain),
|
||
sdr_type: sdrType
|
||
};
|
||
|
||
// Add custom frequency if selected
|
||
if (region === 'custom') {
|
||
const customFreq = document.getElementById('aprsStripCustomFreq').value;
|
||
if (!customFreq) {
|
||
alert('Please enter a custom frequency');
|
||
return;
|
||
}
|
||
requestBody.frequency = customFreq;
|
||
}
|
||
|
||
// Determine endpoint based on agent mode
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/aprs/start`
|
||
: '/aprs/start';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(requestBody)
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Handle controller proxy response format
|
||
const scanResult = isAgentMode && data.result ? data.result : data;
|
||
|
||
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
||
isAprsRunning = true;
|
||
aprsPacketCount = 0;
|
||
aprsStationCount = 0;
|
||
resetAprsAgentStationTracking();
|
||
|
||
if (aprsMap) {
|
||
Object.values(aprsMarkers).forEach((marker) => {
|
||
try {
|
||
aprsMap.removeLayer(marker);
|
||
} catch (_) {}
|
||
});
|
||
}
|
||
aprsMarkers = {};
|
||
|
||
// Initialize APRS filter bar and clear history
|
||
const filterContainer = document.getElementById('aprsFilterBarContainer');
|
||
const stationList = document.getElementById('aprsStationList');
|
||
if (filterContainer && !document.getElementById('aprsFilterBar')) {
|
||
const filterBar = SignalCards.createAprsFilterBar(stationList);
|
||
filterContainer.appendChild(filterBar);
|
||
}
|
||
SignalCards.clearAddressHistory('aprs');
|
||
|
||
// Clear existing station cards
|
||
stationList.innerHTML = '<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">Waiting for stations...</div>';
|
||
const packetLog = document.getElementById('aprsPacketLog');
|
||
if (packetLog) {
|
||
packetLog.innerHTML = '<div style="color: var(--text-muted);">Waiting for packets...</div>';
|
||
}
|
||
document.getElementById('aprsPacketCount').textContent = '0';
|
||
document.getElementById('aprsStationCount').textContent = '0';
|
||
|
||
// Update function bar buttons
|
||
document.getElementById('aprsStripStartBtn').style.display = 'none';
|
||
document.getElementById('aprsStripStopBtn').style.display = 'inline-block';
|
||
// Update map status
|
||
document.getElementById('aprsMapStatus').textContent = 'TRACKING';
|
||
document.getElementById('aprsMapStatus').style.color = 'var(--accent-green)';
|
||
// Update function bar status
|
||
updateAprsStatus('listening', scanResult.frequency);
|
||
// Reset function bar stats
|
||
document.getElementById('aprsStripStations').textContent = '0';
|
||
document.getElementById('aprsStripPackets').textContent = '0';
|
||
document.getElementById('aprsStripSignal').textContent = '--';
|
||
// Disable controls while running
|
||
document.getElementById('aprsStripRegion').disabled = true;
|
||
document.getElementById('aprsStripGain').disabled = true;
|
||
const customFreqInput = document.getElementById('aprsStripCustomFreq');
|
||
if (customFreqInput) customFreqInput.disabled = true;
|
||
startAprsMeterCheck();
|
||
startAprsStream(isAgentMode);
|
||
// Backfill current stations in case position packets arrived before
|
||
// map initialization or SSE attachment.
|
||
loadAprsStationSnapshot(isAgentMode);
|
||
} else {
|
||
alert('APRS Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
|
||
updateAprsStatus('error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
alert('APRS Error: ' + err);
|
||
updateAprsStatus('error');
|
||
});
|
||
}
|
||
|
||
async function stopAprs() {
|
||
const isAgentMode = aprsCurrentAgent !== null;
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${aprsCurrentAgent}/aprs/stop`
|
||
: '/aprs/stop';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
isAprsRunning = false;
|
||
aprsCurrentAgent = null;
|
||
resetAprsAgentStationTracking();
|
||
document.getElementById('aprsStripStopBtn').style.display = 'none';
|
||
document.getElementById('aprsMapStatus').textContent = 'STOPPING';
|
||
document.getElementById('aprsMapStatus').style.color = '';
|
||
updateAprsStatus('standby');
|
||
document.getElementById('aprsStripFreq').textContent = '--';
|
||
document.getElementById('aprsStripSignal').textContent = '--';
|
||
document.getElementById('aprsStripRegion').disabled = false;
|
||
document.getElementById('aprsStripGain').disabled = false;
|
||
const customFreqInput = document.getElementById('aprsStripCustomFreq');
|
||
if (customFreqInput) customFreqInput.disabled = false;
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
if (signalStat) {
|
||
signalStat.classList.remove('good', 'warning', 'poor');
|
||
}
|
||
stopAprsMeterCheck();
|
||
if (aprsEventSource) {
|
||
aprsEventSource.close();
|
||
aprsEventSource = null;
|
||
}
|
||
if (aprsPollTimer) {
|
||
clearInterval(aprsPollTimer);
|
||
aprsPollTimer = null;
|
||
}
|
||
|
||
await postStopRequest(endpoint, timeoutMs);
|
||
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
|
||
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
|
||
}
|
||
|
||
function startAprsStream(isAgentMode = false) {
|
||
if (aprsEventSource) aprsEventSource.close();
|
||
|
||
// Use different stream endpoint for agent mode
|
||
const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream';
|
||
aprsEventSource = new EventSource(streamUrl + (streamUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
|
||
|
||
aprsEventSource.onmessage = function (e) {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (isAgentMode) {
|
||
// Handle multi-agent stream format
|
||
if (data.scan_type === 'aprs' && data.payload) {
|
||
const payload = data.payload;
|
||
if (payload.type === 'aprs') {
|
||
aprsPacketCount++;
|
||
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
|
||
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
|
||
const dot = document.getElementById('aprsStripDot');
|
||
if (dot && !dot.classList.contains('tracking')) {
|
||
updateAprsStatus('tracking');
|
||
}
|
||
// Add agent info
|
||
payload.agent_name = data.agent_name;
|
||
processAprsPacket(payload);
|
||
} else if (payload.type === 'meter') {
|
||
updateAprsMeter(payload.level);
|
||
} else {
|
||
const stations = extractAprsStationsFromPayload(payload);
|
||
processAprsAgentStations(stations, data.agent_name);
|
||
}
|
||
}
|
||
} else {
|
||
// Local stream format
|
||
if (data.type === 'aprs') {
|
||
aprsPacketCount++;
|
||
// Update map footer and function bar
|
||
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
|
||
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
|
||
// Switch to tracking state on first packet
|
||
const dot = document.getElementById('aprsStripDot');
|
||
if (dot && !dot.classList.contains('tracking')) {
|
||
updateAprsStatus('tracking');
|
||
}
|
||
processAprsPacket(data);
|
||
} else if (data.type === 'meter') {
|
||
// Update signal indicator in function bar
|
||
updateAprsMeter(data.level);
|
||
}
|
||
}
|
||
};
|
||
|
||
aprsEventSource.onerror = function () {
|
||
console.error('APRS stream error');
|
||
updateAprsStatus('error');
|
||
};
|
||
|
||
// Start polling fallback for agent mode
|
||
if (isAgentMode) {
|
||
startAprsPolling();
|
||
}
|
||
}
|
||
|
||
function startAprsPolling() {
|
||
if (aprsPollTimer) return;
|
||
resetAprsAgentStationTracking();
|
||
|
||
const pollInterval = 2000;
|
||
aprsPollTimer = setInterval(async () => {
|
||
if (!isAprsRunning || !aprsCurrentAgent) {
|
||
clearInterval(aprsPollTimer);
|
||
aprsPollTimer = null;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/controller/agents/${aprsCurrentAgent}/aprs/data`);
|
||
if (!response.ok) return;
|
||
|
||
const payload = await response.json();
|
||
const stations = extractAprsStationsFromPayload(payload);
|
||
const agentName = payload.agent_name ||
|
||
(payload.data && payload.data.agent_name) ||
|
||
'Remote Agent';
|
||
processAprsAgentStations(stations, agentName);
|
||
} catch (err) {
|
||
console.error('APRS polling error:', err);
|
||
}
|
||
}, pollInterval);
|
||
}
|
||
|
||
// Signal Meter Functions
|
||
function resetAprsMeter() {
|
||
aprsMeterLastUpdate = 0;
|
||
// Reset function bar signal indicator
|
||
const signalEl = document.getElementById('aprsStripSignal');
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
if (signalEl) signalEl.textContent = '--';
|
||
if (signalStat) signalStat.classList.remove('good', 'warning', 'poor');
|
||
}
|
||
|
||
function updateAprsMeter(level) {
|
||
aprsMeterLastUpdate = Date.now();
|
||
|
||
// Update function bar signal indicator
|
||
const signalEl = document.getElementById('aprsStripSignal');
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
|
||
if (signalEl) {
|
||
// Show signal level as bars
|
||
if (level >= 60) {
|
||
signalEl.textContent = '●●●';
|
||
} else if (level >= 30) {
|
||
signalEl.textContent = '●●○';
|
||
} else if (level >= 10) {
|
||
signalEl.textContent = '●○○';
|
||
} else {
|
||
signalEl.textContent = '○○○';
|
||
}
|
||
}
|
||
|
||
if (signalStat) {
|
||
signalStat.classList.remove('good', 'warning', 'poor');
|
||
if (level >= 60) {
|
||
signalStat.classList.add('good');
|
||
} else if (level >= 30) {
|
||
signalStat.classList.add('warning');
|
||
} else {
|
||
signalStat.classList.add('poor');
|
||
}
|
||
}
|
||
}
|
||
|
||
function startAprsMeterCheck() {
|
||
// Check for no-signal state every second
|
||
aprsMeterCheckInterval = setInterval(function () {
|
||
if (aprsMeterLastUpdate > 0 && (Date.now() - aprsMeterLastUpdate) > APRS_METER_TIMEOUT) {
|
||
// No meter updates for 5 seconds - show no-signal state
|
||
const signalEl = document.getElementById('aprsStripSignal');
|
||
const signalStat = document.getElementById('aprsStripSignalStat');
|
||
if (signalEl) signalEl.textContent = '○○○';
|
||
if (signalStat) {
|
||
signalStat.classList.remove('good', 'warning');
|
||
signalStat.classList.add('poor');
|
||
}
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function stopAprsMeterCheck() {
|
||
if (aprsMeterCheckInterval) {
|
||
clearInterval(aprsMeterCheckInterval);
|
||
aprsMeterCheckInterval = null;
|
||
}
|
||
}
|
||
|
||
// Handle region selection changes to show/hide custom frequency input
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const regionSelect = document.getElementById('aprsStripRegion');
|
||
const customFreqControl = document.getElementById('aprsStripCustomFreqControl');
|
||
|
||
if (regionSelect && customFreqControl) {
|
||
regionSelect.addEventListener('change', function() {
|
||
if (this.value === 'custom') {
|
||
customFreqControl.style.display = 'flex';
|
||
} else {
|
||
customFreqControl.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
function processAprsPacket(packet) {
|
||
// Update packet log
|
||
const logEl = document.getElementById('aprsPacketLog');
|
||
const logEntry = document.createElement('div');
|
||
logEntry.style.cssText = 'padding: 3px 0; border-bottom: 1px solid var(--border-color);';
|
||
|
||
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
const callsign = packet.callsign || 'UNKNOWN';
|
||
const packetType = packet.packet_type || 'unknown';
|
||
|
||
logEntry.innerHTML = `<span style="color: var(--text-muted);">${time}</span> <span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span> <span style="color: var(--accent-green);">[${packetType}]</span>`;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = logEl.querySelector('div[style*="color: var(--text-muted)"]');
|
||
if (placeholder && placeholder.textContent.includes('Waiting')) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
logEl.insertBefore(logEntry, logEl.firstChild);
|
||
|
||
// Keep log manageable
|
||
while (logEl.children.length > 100) {
|
||
logEl.removeChild(logEl.lastChild);
|
||
}
|
||
|
||
// Update map if position data
|
||
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
|
||
updateAprsMarker(packet);
|
||
}
|
||
|
||
// Update station list
|
||
updateAprsStationList(packet);
|
||
}
|
||
|
||
function getAprsMarkerCategory(packet) {
|
||
const symbolCode = (packet.symbol && packet.symbol.length > 1) ? packet.symbol[1] : '';
|
||
const speed = parseFloat(packet.speed || 0);
|
||
const vehicleSymbols = new Set(['>', 'k', 'u', 'v', '[', '<', 's', 'b', 'j']);
|
||
|
||
if ((Number.isFinite(speed) && speed > 2) || vehicleSymbols.has(symbolCode)) {
|
||
return 'vehicle';
|
||
}
|
||
return 'tower';
|
||
}
|
||
|
||
function getAprsMarkerSvg(category) {
|
||
if (category === 'vehicle') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 14l2-5a2 2 0 0 1 2-1h10a2 2 0 0 1 2 1l2 5v4h-2a2 2 0 0 1-4 0H9a2 2 0 0 1-4 0H3v-4z"/><circle cx="7" cy="18" r="1.7"/><circle cx="17" cy="18" r="1.7"/></svg>';
|
||
}
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l3 7h-2l1 3h-2l1 8h-2l1-8h-2l1-3H9l3-7z"/><path d="M5 21h14" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
|
||
}
|
||
|
||
function buildAprsMarkerIcon(packet) {
|
||
const category = getAprsMarkerCategory(packet);
|
||
const callsign = packet.callsign || 'UNKNOWN';
|
||
const html = `
|
||
<div class="aprs-map-marker ${category}">
|
||
<span class="aprs-map-marker-icon">${getAprsMarkerSvg(category)}</span>
|
||
<span class="aprs-map-marker-label">${callsign}</span>
|
||
</div>
|
||
`;
|
||
return L.divIcon({
|
||
className: 'aprs-map-marker-wrap',
|
||
html,
|
||
iconSize: [110, 24],
|
||
iconAnchor: [55, 12]
|
||
});
|
||
}
|
||
|
||
function updateAprsMarker(packet) {
|
||
const callsign = packet.callsign;
|
||
const lat = Number(packet.lat);
|
||
const lon = Number(packet.lon);
|
||
if (!aprsHasValidCoordinates(lat, lon)) {
|
||
return;
|
||
}
|
||
|
||
// Calculate distance if user location available
|
||
let distStr = '';
|
||
if (aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
|
||
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
|
||
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
|
||
}
|
||
|
||
if (aprsMarkers[callsign]) {
|
||
// Update existing marker position and popup
|
||
aprsMarkers[callsign].setLatLng([lat, lon]);
|
||
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
|
||
aprsMarkers[callsign].setPopupContent(`
|
||
<div style="font-family: monospace;">
|
||
<strong>${callsign}</strong><br>
|
||
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
|
||
${distStr}
|
||
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
|
||
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
|
||
${packet.course ? `Course: ${packet.course}°<br>` : ''}
|
||
</div>
|
||
`);
|
||
} else {
|
||
// Create new marker
|
||
aprsStationCount++;
|
||
// Update map footer and function bar
|
||
document.getElementById('aprsStationCount').textContent = aprsStationCount;
|
||
document.getElementById('aprsStripStations').textContent = aprsStationCount;
|
||
|
||
const marker = L.marker([lat, lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
|
||
|
||
marker.bindPopup(`
|
||
<div style="font-family: monospace;">
|
||
<strong>${callsign}</strong><br>
|
||
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
|
||
${distStr}
|
||
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
|
||
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
|
||
${packet.course ? `Course: ${packet.course}°<br>` : ''}
|
||
</div>
|
||
`);
|
||
|
||
aprsMarkers[callsign] = marker;
|
||
}
|
||
}
|
||
|
||
function updateAprsStationList(packet) {
|
||
const listEl = document.getElementById('aprsStationList');
|
||
const callsign = packet.callsign;
|
||
|
||
// Remove placeholder if present
|
||
const placeholder = listEl.querySelector('.signal-cards-placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Calculate distance if user location available
|
||
let distance = null;
|
||
const hasPos = aprsHasValidCoordinates(packet.lat, packet.lon);
|
||
const lat = hasPos ? Number(packet.lat) : null;
|
||
const lon = hasPos ? Number(packet.lon) : null;
|
||
if (hasPos && aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
|
||
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
|
||
}
|
||
|
||
// Check if station already exists
|
||
let stationEl = listEl.querySelector(`[data-callsign="${callsign}"]`);
|
||
const isExisting = !!stationEl;
|
||
|
||
// Prepare message object for card creation
|
||
const msg = {
|
||
callsign: callsign,
|
||
packet_type: packet.packet_type || 'unknown',
|
||
latitude: lat,
|
||
longitude: lon,
|
||
altitude: packet.altitude,
|
||
speed: packet.speed,
|
||
course: packet.course,
|
||
comment: packet.comment,
|
||
symbol: packet.symbol,
|
||
path: packet.path,
|
||
raw: packet.raw,
|
||
timestamp: new Date().toISOString(),
|
||
distance: distance
|
||
};
|
||
|
||
// Create or update the card
|
||
const newCard = SignalCards.createAprsCard(msg, { compact: true });
|
||
newCard.dataset.callsign = callsign;
|
||
|
||
// Store position for distance updates
|
||
if (hasPos) {
|
||
newCard.dataset.lat = lat;
|
||
newCard.dataset.lon = lon;
|
||
}
|
||
|
||
// Add click handler to focus map
|
||
newCard.style.cursor = 'pointer';
|
||
newCard.addEventListener('click', (e) => {
|
||
// Don't trigger if clicking on buttons
|
||
if (e.target.closest('button')) return;
|
||
if (aprsMarkers[callsign] && aprsMap) {
|
||
aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10);
|
||
aprsMarkers[callsign].openPopup();
|
||
}
|
||
});
|
||
|
||
if (isExisting) {
|
||
// Replace existing card
|
||
stationEl.replaceWith(newCard);
|
||
} else {
|
||
// Insert new card at top
|
||
listEl.insertBefore(newCard, listEl.firstChild);
|
||
}
|
||
|
||
// Keep list manageable (use live childElementCount, not static NodeList)
|
||
const MAX_APRS_STATION_CARDS = 200;
|
||
while (listEl.childElementCount > MAX_APRS_STATION_CARDS && listEl.lastElementChild) {
|
||
listEl.removeChild(listEl.lastElementChild);
|
||
}
|
||
|
||
// Update filter counts if filter bar exists
|
||
SignalCards.updateCounts(listEl);
|
||
}
|
||
|
||
|
||
// ============================================
|
||
// SATELLITE MODE FUNCTIONS
|
||
// ============================================
|
||
|
||
function getLocation() {
|
||
if (navigator.geolocation) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
position => {
|
||
const lat = position.coords.latitude;
|
||
const lon = position.coords.longitude;
|
||
document.getElementById('obsLat').value = lat.toFixed(4);
|
||
document.getElementById('obsLon').value = lon.toFixed(4);
|
||
observerLocation.lat = lat;
|
||
observerLocation.lon = lon;
|
||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||
ObserverLocation.setShared({ lat, lon });
|
||
}
|
||
showInfo('Location updated!');
|
||
},
|
||
error => {
|
||
alert('Could not get location: ' + error.message);
|
||
}
|
||
);
|
||
} else {
|
||
alert('Geolocation not supported by browser');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// GPS FUNCTIONS (gpsd auto-connect)
|
||
// ============================================
|
||
|
||
async function autoConnectGps() {
|
||
// Automatically try to connect to gpsd on page load
|
||
try {
|
||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'connected') {
|
||
gpsConnected = true;
|
||
startGpsStream();
|
||
showGpsIndicator(true);
|
||
console.log('GPS: Auto-connected to gpsd');
|
||
if (data.position) {
|
||
updateLocationFromGps(data.position);
|
||
}
|
||
} else {
|
||
console.log('GPS: gpsd not available -', data.message);
|
||
}
|
||
} catch (e) {
|
||
console.log('GPS: Auto-connect failed -', e.message);
|
||
}
|
||
}
|
||
|
||
let gpsReconnectTimeout = null;
|
||
|
||
// GPS subscriber callbacks - modules can register to receive GPS stream data
|
||
const gpsStreamSubscribers = [];
|
||
|
||
function addGpsStreamSubscriber(fn) {
|
||
if (!gpsStreamSubscribers.includes(fn)) {
|
||
gpsStreamSubscribers.push(fn);
|
||
}
|
||
}
|
||
|
||
function removeGpsStreamSubscriber(fn) {
|
||
const idx = gpsStreamSubscribers.indexOf(fn);
|
||
if (idx !== -1) gpsStreamSubscribers.splice(idx, 1);
|
||
}
|
||
|
||
function startGpsStream() {
|
||
if (gpsEventSource) {
|
||
gpsEventSource.close();
|
||
}
|
||
if (gpsReconnectTimeout) {
|
||
clearTimeout(gpsReconnectTimeout);
|
||
gpsReconnectTimeout = null;
|
||
}
|
||
|
||
gpsEventSource = new EventSource('/gps/stream');
|
||
gpsEventSource.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'position') {
|
||
gpsLastPosition = data;
|
||
updateLocationFromGps(data);
|
||
}
|
||
// Dispatch to all subscribers (e.g. GPS mode UI)
|
||
gpsStreamSubscribers.forEach(fn => fn(data));
|
||
} catch (e) {
|
||
console.error('GPS parse error:', e);
|
||
}
|
||
};
|
||
gpsEventSource.onerror = (e) => {
|
||
// Don't log every error - connection suspends are normal
|
||
if (gpsEventSource) {
|
||
gpsEventSource.close();
|
||
gpsEventSource = null;
|
||
}
|
||
// Auto-reconnect after 5 seconds if still connected
|
||
if (gpsConnected && !gpsReconnectTimeout) {
|
||
gpsReconnectTimeout = setTimeout(() => {
|
||
gpsReconnectTimeout = null;
|
||
if (gpsConnected) {
|
||
startGpsStream();
|
||
}
|
||
}, 5000);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Reconnect GPS stream when tab becomes visible
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden && gpsConnected && !gpsEventSource) {
|
||
startGpsStream();
|
||
}
|
||
});
|
||
|
||
function updateLocationFromGps(position) {
|
||
if (!position || !position.latitude || !position.longitude) {
|
||
return;
|
||
}
|
||
|
||
// Update satellite observer location
|
||
const satLatInput = document.getElementById('obsLat');
|
||
const satLonInput = document.getElementById('obsLon');
|
||
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
|
||
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
|
||
|
||
// Update observerLocation
|
||
observerLocation.lat = position.latitude;
|
||
observerLocation.lon = position.longitude;
|
||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
|
||
}
|
||
|
||
// Update APRS user location
|
||
updateAprsUserLocation(position);
|
||
}
|
||
|
||
function showGpsIndicator(show) {
|
||
// Show/hide all GPS indicators (by class and by ID)
|
||
document.querySelectorAll('.gps-indicator').forEach(el => {
|
||
el.style.display = show ? 'inline-flex' : 'none';
|
||
});
|
||
// Also target specific IDs in case class selector doesn't work
|
||
['satGpsIndicator', 'aprsGpsIndicator'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = show ? 'inline-flex' : 'none';
|
||
});
|
||
}
|
||
|
||
function initPolarPlot() {
|
||
const canvas = document.getElementById('polarPlotCanvas');
|
||
if (!canvas) return;
|
||
const container = canvas.parentElement;
|
||
const size = Math.min(container.offsetWidth, 400);
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
drawPolarPlot();
|
||
}
|
||
|
||
function drawPolarPlot(pass = null) {
|
||
const canvas = document.getElementById('polarPlotCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const size = canvas.width;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = size / 2 - 30;
|
||
|
||
// Clear
|
||
ctx.fillStyle = '#0a0a0a';
|
||
ctx.fillRect(0, 0, size, size);
|
||
|
||
// Draw elevation rings
|
||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
|
||
ctx.lineWidth = 1;
|
||
for (let el = 0; el <= 90; el += 30) {
|
||
const r = radius * (90 - el) / 90;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// Label
|
||
if (el > 0) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '10px Roboto Condensed';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(el + '°', cx, cy - r + 12);
|
||
}
|
||
}
|
||
|
||
// Draw azimuth lines
|
||
for (let az = 0; az < 360; az += 45) {
|
||
const rad = az * Math.PI / 180;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.lineTo(cx + Math.sin(rad) * radius, cy - Math.cos(rad) * radius);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Draw cardinal directions
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.font = 'bold 14px Rajdhani';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('N', cx, cy - radius - 8);
|
||
ctx.fillStyle = '#888';
|
||
ctx.fillText('S', cx, cy + radius + 16);
|
||
ctx.fillText('E', cx + radius + 12, cy + 4);
|
||
ctx.fillText('W', cx - radius - 12, cy + 4);
|
||
|
||
// Draw zenith
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Draw selected pass trajectory
|
||
if (pass && pass.trajectory) {
|
||
ctx.strokeStyle = pass.color || '#00ff00';
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([5, 3]);
|
||
ctx.beginPath();
|
||
|
||
pass.trajectory.forEach((point, i) => {
|
||
// Backend returns 'el' and 'az' properties
|
||
const el = point.el !== undefined ? point.el : point.elevation;
|
||
const az = point.az !== undefined ? point.az : point.azimuth;
|
||
const r = radius * (90 - el) / 90;
|
||
const rad = az * Math.PI / 180;
|
||
const x = cx + Math.sin(rad) * r;
|
||
const y = cy - Math.cos(rad) * r;
|
||
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Draw max elevation point
|
||
const maxPoint = pass.trajectory.reduce((max, p) => {
|
||
const pEl = p.el !== undefined ? p.el : p.elevation;
|
||
const maxEl = max.el !== undefined ? max.el : max.elevation;
|
||
return pEl > maxEl ? p : max;
|
||
}, { el: 0, elevation: 0 });
|
||
const maxEl = maxPoint.el !== undefined ? maxPoint.el : maxPoint.elevation;
|
||
const maxAz = maxPoint.az !== undefined ? maxPoint.az : maxPoint.azimuth;
|
||
const maxR = radius * (90 - maxEl) / 90;
|
||
const maxRad = maxAz * Math.PI / 180;
|
||
const maxX = cx + Math.sin(maxRad) * maxR;
|
||
const maxY = cy - Math.cos(maxRad) * maxR;
|
||
|
||
ctx.fillStyle = pass.color || '#00ff00';
|
||
ctx.beginPath();
|
||
ctx.arc(maxX, maxY, 6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Label
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '11px Roboto Condensed';
|
||
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
|
||
}
|
||
}
|
||
|
||
// Satellite mode agent state
|
||
let satelliteCurrentAgent = null;
|
||
|
||
function calculatePasses() {
|
||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||
const hours = parseInt(document.getElementById('predictionHours').value);
|
||
const minEl = parseInt(document.getElementById('minElevation').value);
|
||
|
||
const satellites = getSelectedSatellites();
|
||
|
||
if (satellites.length === 0) {
|
||
alert('Please select at least one satellite to track');
|
||
return;
|
||
}
|
||
|
||
// Check if using agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
satelliteCurrentAgent = isAgentMode ? currentAgent : null;
|
||
|
||
// Determine endpoint based on agent mode
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/satellite/predict`
|
||
: '/satellite/predict';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ lat, lon, hours, minEl, satellites })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Handle controller proxy response format
|
||
const result = isAgentMode && data.result ? data.result : data;
|
||
|
||
if (result.status === 'success') {
|
||
satellitePasses = result.passes;
|
||
renderPassList();
|
||
document.getElementById('passCount').textContent = result.passes.length;
|
||
if (result.passes.length > 0) {
|
||
selectPass(0);
|
||
document.getElementById('satelliteCountdown').style.display = 'block';
|
||
updateSatelliteCountdown();
|
||
startCountdownTimer();
|
||
} else {
|
||
document.getElementById('satelliteCountdown').style.display = 'none';
|
||
}
|
||
} else {
|
||
alert('Error: ' + (result.message || result.error || 'Failed to predict passes'));
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderPassList() {
|
||
const container = document.getElementById('passList');
|
||
container.innerHTML = '';
|
||
|
||
if (satellitePasses.length === 0) {
|
||
container.innerHTML = '<div style="color: #666; text-align: center; padding: 30px;">No passes found for selected criteria.</div>';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('passListCount').textContent = satellitePasses.length + ' passes';
|
||
|
||
satellitePasses.forEach((pass, index) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'pass-card' + (index === 0 ? ' active' : '');
|
||
card.onclick = () => selectPass(index);
|
||
|
||
const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair';
|
||
|
||
card.innerHTML = `
|
||
<div class="pass-satellite">${pass.satellite}</div>
|
||
<div class="pass-time">${pass.startTime}</div>
|
||
<div class="pass-details">
|
||
<div>Max El: <span>${pass.maxEl}°</span></div>
|
||
<div>Duration: <span>${pass.duration}m</span></div>
|
||
<div class="pass-quality ${quality}">${quality.toUpperCase()}</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function selectPass(index) {
|
||
selectedPass = satellitePasses[index];
|
||
selectedPassIndex = index;
|
||
document.querySelectorAll('.pass-card').forEach((card, i) => {
|
||
card.classList.toggle('active', i === index);
|
||
});
|
||
drawPolarPlot(selectedPass);
|
||
updateGroundTrack(selectedPass);
|
||
// Update countdown to show selected pass
|
||
updateSatelliteCountdown();
|
||
// Start real-time position updates for full orbit track
|
||
startSatellitePositionUpdates();
|
||
// Fetch position immediately
|
||
updateRealTimePosition();
|
||
}
|
||
|
||
// Ground Track Map
|
||
let groundTrackMap = null;
|
||
let groundTrackLine = null;
|
||
let satMarker = null;
|
||
let observerMarker = null;
|
||
let satPositionInterval = null;
|
||
|
||
async function initGroundTrackMap() {
|
||
const mapContainer = document.getElementById('groundTrackMap');
|
||
if (!mapContainer || groundTrackMap) return;
|
||
|
||
groundTrackMap = L.map('groundTrackMap', {
|
||
center: [20, 0],
|
||
zoom: 1,
|
||
zoomControl: true,
|
||
attributionControl: false
|
||
});
|
||
window.groundTrackMap = groundTrackMap;
|
||
|
||
// Use settings manager for tile layer (allows runtime changes)
|
||
if (typeof Settings !== 'undefined') {
|
||
// Wait for settings to load from server before applying tiles
|
||
await Settings.init();
|
||
Settings.createTileLayer().addTo(groundTrackMap);
|
||
Settings.registerMap(groundTrackMap);
|
||
} else {
|
||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||
maxZoom: 19,
|
||
subdomains: 'abcd',
|
||
className: 'tile-layer-cyan'
|
||
}).addTo(groundTrackMap);
|
||
}
|
||
|
||
// Add observer marker
|
||
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
|
||
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
|
||
observerMarker = L.circleMarker([lat, lon], {
|
||
radius: 8,
|
||
fillColor: '#ff6600',
|
||
color: '#fff',
|
||
weight: 2,
|
||
fillOpacity: 1
|
||
}).addTo(groundTrackMap).bindPopup('Observer Location');
|
||
}
|
||
|
||
function updateGroundTrack(pass) {
|
||
if (!groundTrackMap) initGroundTrackMap();
|
||
if (!pass || !pass.groundTrack) return;
|
||
|
||
// Remove old track and marker
|
||
if (groundTrackLine) {
|
||
groundTrackMap.removeLayer(groundTrackLine);
|
||
groundTrackLine = null;
|
||
}
|
||
if (satMarker) {
|
||
groundTrackMap.removeLayer(satMarker);
|
||
satMarker = null;
|
||
}
|
||
if (orbitTrackLine) {
|
||
groundTrackMap.removeLayer(orbitTrackLine);
|
||
orbitTrackLine = null;
|
||
}
|
||
if (pastOrbitLine) {
|
||
groundTrackMap.removeLayer(pastOrbitLine);
|
||
pastOrbitLine = null;
|
||
}
|
||
|
||
// Split ground track only at true antimeridian crossings (±180° line)
|
||
const segments = [];
|
||
let currentSegment = [];
|
||
for (let i = 0; i < pass.groundTrack.length; i++) {
|
||
const p = pass.groundTrack[i];
|
||
if (currentSegment.length > 0) {
|
||
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||
// Only split when crossing the antimeridian (one side > 90, other < -90)
|
||
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
|
||
if (crossesAntimeridian) {
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
currentSegment = [];
|
||
}
|
||
}
|
||
currentSegment.push([p.lat, p.lon]);
|
||
}
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
|
||
// Draw ground track segments
|
||
groundTrackLine = L.layerGroup();
|
||
const allCoords = [];
|
||
segments.forEach(seg => {
|
||
L.polyline(seg, {
|
||
color: pass.color || '#00ff00',
|
||
weight: 2,
|
||
opacity: 0.8,
|
||
dashArray: '5, 5'
|
||
}).addTo(groundTrackLine);
|
||
allCoords.push(...seg);
|
||
});
|
||
groundTrackLine.addTo(groundTrackMap);
|
||
|
||
// Add current position marker
|
||
if (pass.currentPosition) {
|
||
satMarker = L.marker([pass.currentPosition.lat, pass.currentPosition.lon], {
|
||
icon: L.divIcon({
|
||
className: 'sat-marker',
|
||
html: '<div style="background:#ffff00;width:12px;height:12px;border-radius:50%;border:2px solid #000;box-shadow:0 0 10px #ffff00;"></div>',
|
||
iconSize: [12, 12],
|
||
iconAnchor: [6, 6]
|
||
})
|
||
}).addTo(groundTrackMap).bindPopup(pass.satellite);
|
||
}
|
||
|
||
// Update observer marker position
|
||
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
|
||
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
|
||
if (observerMarker) {
|
||
observerMarker.setLatLng([lat, lon]);
|
||
}
|
||
|
||
// Fit bounds to show track
|
||
if (allCoords.length > 0) {
|
||
groundTrackMap.fitBounds(L.latLngBounds(allCoords), { padding: [20, 20] });
|
||
}
|
||
}
|
||
|
||
function toggleGroundTrack() {
|
||
const show = document.getElementById('showGroundTrack').checked;
|
||
document.getElementById('groundTrackMap').style.display = show ? 'block' : 'none';
|
||
if (show && groundTrackMap) {
|
||
groundTrackMap.invalidateSize();
|
||
}
|
||
}
|
||
|
||
function startSatellitePositionUpdates() {
|
||
if (satPositionInterval) clearInterval(satPositionInterval);
|
||
satPositionInterval = setInterval(() => {
|
||
if (selectedPass) {
|
||
updateRealTimePosition();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function updateRealTimePosition() {
|
||
let satellites = getSelectedSatellites();
|
||
|
||
// Ensure selected pass's satellite is included in the request
|
||
if (selectedPass && selectedPass.satellite) {
|
||
if (!satellites.includes(selectedPass.satellite)) {
|
||
satellites = [selectedPass.satellite, ...satellites];
|
||
}
|
||
}
|
||
|
||
if (satellites.length === 0) return;
|
||
|
||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||
|
||
// Check if using agent mode
|
||
const isAgentMode = satelliteCurrentAgent !== null;
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${satelliteCurrentAgent}/satellite/position`
|
||
: '/satellite/position';
|
||
|
||
fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ lat, lon, satellites, includeTrack: true })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Handle controller proxy response format
|
||
const result = isAgentMode && data.result ? data.result : data;
|
||
|
||
if (result.status === 'success' && result.positions) {
|
||
updateRealTimeIndicators(result.positions);
|
||
}
|
||
});
|
||
}
|
||
|
||
let orbitTrackLine = null;
|
||
let pastOrbitLine = null;
|
||
|
||
function updateRealTimeIndicators(positions) {
|
||
// Update ground track map markers
|
||
positions.forEach(pos => {
|
||
if (selectedPass && pos.satellite === selectedPass.satellite) {
|
||
// Update satellite marker position
|
||
if (satMarker) {
|
||
satMarker.setLatLng([pos.lat, pos.lon]);
|
||
satMarker.setPopupContent(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km<br>El: ' + pos.elevation.toFixed(1) + '°');
|
||
} else if (groundTrackMap) {
|
||
satMarker = L.marker([pos.lat, pos.lon], {
|
||
icon: L.divIcon({
|
||
className: 'sat-marker',
|
||
html: '<div style="background:#ffff00;width:14px;height:14px;border-radius:50%;border:2px solid #000;box-shadow:0 0 15px #ffff00;animation:pulse-sat 1s infinite;"></div>',
|
||
iconSize: [14, 14],
|
||
iconAnchor: [7, 7]
|
||
})
|
||
}).addTo(groundTrackMap).bindPopup(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km');
|
||
}
|
||
|
||
// Draw full orbit track from position endpoint
|
||
// Backend returns 'track' property
|
||
const orbitData = pos.track || pos.orbitTrack;
|
||
if (orbitData && orbitData.length > 0 && groundTrackMap) {
|
||
// Split into past and future, handling antimeridian crossings
|
||
const pastPoints = orbitData.filter(p => p.past);
|
||
const futurePoints = orbitData.filter(p => !p.past);
|
||
|
||
// Helper to split coords only at true antimeridian crossings (±180° line)
|
||
function splitAtAntimeridian(points) {
|
||
const segments = [];
|
||
let currentSegment = [];
|
||
for (let i = 0; i < points.length; i++) {
|
||
const p = points[i];
|
||
if (currentSegment.length > 0) {
|
||
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||
// Only split when crossing the antimeridian (one side > 90, other < -90)
|
||
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
|
||
if (crossesAntimeridian) {
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
currentSegment = [];
|
||
}
|
||
}
|
||
currentSegment.push([p.lat, p.lon]);
|
||
}
|
||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||
return segments;
|
||
}
|
||
|
||
// Remove old lines
|
||
if (orbitTrackLine) groundTrackMap.removeLayer(orbitTrackLine);
|
||
if (pastOrbitLine) groundTrackMap.removeLayer(pastOrbitLine);
|
||
|
||
// Draw past track segments (dimmer)
|
||
const pastSegments = splitAtAntimeridian(pastPoints);
|
||
if (pastSegments.length > 0) {
|
||
pastOrbitLine = L.layerGroup();
|
||
pastSegments.forEach(seg => {
|
||
L.polyline(seg, {
|
||
color: '#666666',
|
||
weight: 2,
|
||
opacity: 0.5,
|
||
dashArray: '3, 6'
|
||
}).addTo(pastOrbitLine);
|
||
});
|
||
pastOrbitLine.addTo(groundTrackMap);
|
||
}
|
||
|
||
// Draw future track segments (brighter)
|
||
const futureSegments = splitAtAntimeridian(futurePoints);
|
||
if (futureSegments.length > 0) {
|
||
orbitTrackLine = L.layerGroup();
|
||
futureSegments.forEach(seg => {
|
||
L.polyline(seg, {
|
||
color: selectedPass.color || '#00ff00',
|
||
weight: 3,
|
||
opacity: 0.8
|
||
}).addTo(orbitTrackLine);
|
||
});
|
||
orbitTrackLine.addTo(groundTrackMap);
|
||
}
|
||
}
|
||
|
||
// Update polar plot with pass trajectory and real-time position
|
||
if (selectedPass) {
|
||
drawPolarPlot(selectedPass);
|
||
// Draw current position on top if satellite is visible
|
||
if (pos.elevation > 0) {
|
||
drawRealTimePositionOnPolar(pos);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function drawRealTimePositionOnPolar(pos) {
|
||
const canvas = document.getElementById('polarPlotCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const size = canvas.width;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = size / 2 - 30;
|
||
|
||
// Draw pulsing indicator for current position
|
||
const r = radius * (90 - pos.elevation) / 90;
|
||
const rad = pos.azimuth * Math.PI / 180;
|
||
const x = cx + Math.sin(rad) * r;
|
||
const y = cy - Math.cos(rad) * r;
|
||
|
||
ctx.fillStyle = '#ffff00';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.strokeStyle = '#ffff00';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 12, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function updateTLE() {
|
||
fetch('/satellite/update-tle', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
showInfo('TLE data updated!');
|
||
} else {
|
||
alert('Error updating TLE: ' + data.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Satellite management
|
||
let trackedSatellites = [];
|
||
|
||
function renderSatelliteList() {
|
||
const list = document.getElementById('satTrackingList');
|
||
if (!list) return;
|
||
|
||
list.innerHTML = trackedSatellites.map((sat, idx) => `
|
||
<div class="sat-item ${sat.builtin ? 'builtin' : ''}">
|
||
<label>
|
||
<input type="checkbox" ${sat.checked ? 'checked' : ''} onchange="toggleSatellite(${idx})">
|
||
<span class="sat-name">${sat.name}</span>
|
||
<span class="sat-norad">#${sat.norad}</span>
|
||
</label>
|
||
<button class="sat-remove" onclick="removeSatellite(${idx})" title="Remove">✕</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function toggleSatellite(idx) {
|
||
const sat = trackedSatellites[idx];
|
||
sat.checked = !sat.checked;
|
||
fetch(`/satellite/tracked/${sat.norad}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: sat.checked })
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function removeSatellite(idx) {
|
||
const sat = trackedSatellites[idx];
|
||
if (sat.builtin) return;
|
||
fetch(`/satellite/tracked/${sat.norad}`, { method: 'DELETE' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
trackedSatellites.splice(idx, 1);
|
||
renderSatelliteList();
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function getSelectedSatellites() {
|
||
return trackedSatellites.filter(s => s.checked).map(s => s.id);
|
||
}
|
||
|
||
function showAddSatelliteModal() {
|
||
document.getElementById('satModal').classList.add('active');
|
||
}
|
||
|
||
function closeSatModal() {
|
||
document.getElementById('satModal').classList.remove('active');
|
||
}
|
||
|
||
function switchSatModalTab(tab) {
|
||
document.querySelectorAll('.sat-modal-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.sat-modal-section').forEach(s => s.classList.remove('active'));
|
||
|
||
if (tab === 'tle') {
|
||
document.querySelector('.sat-modal-tab:first-child').classList.add('active');
|
||
document.getElementById('tleSection').classList.add('active');
|
||
} else {
|
||
document.querySelector('.sat-modal-tab:last-child').classList.add('active');
|
||
document.getElementById('celestrakSection').classList.add('active');
|
||
}
|
||
}
|
||
|
||
function addFromTLE() {
|
||
const tleText = document.getElementById('tleInput').value.trim();
|
||
if (!tleText) {
|
||
alert('Please paste TLE data');
|
||
return;
|
||
}
|
||
|
||
const lines = tleText.split(/\r?\n/).map(l => l.trim()).filter(l => l);
|
||
const toAdd = [];
|
||
|
||
for (let i = 0; i < lines.length; i += 3) {
|
||
if (i + 2 < lines.length) {
|
||
const name = lines[i];
|
||
const line1 = lines[i + 1];
|
||
const line2 = lines[i + 2];
|
||
|
||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||
const norad = line1.substring(2, 7).trim();
|
||
if (!trackedSatellites.find(s => s.norad === norad)) {
|
||
toAdd.push({ norad_id: norad, name: name, tle1: line1, tle2: line2, enabled: true });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (toAdd.length === 0) {
|
||
alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)');
|
||
return;
|
||
}
|
||
|
||
fetch('/satellite/tracked', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(toAdd)
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
_loadSatellitesFromAPI();
|
||
document.getElementById('tleInput').value = '';
|
||
closeSatModal();
|
||
showInfo(`Added ${data.added} satellite(s)`);
|
||
}
|
||
})
|
||
.catch(() => alert('Failed to save satellites'));
|
||
}
|
||
|
||
function fetchCelestrak() {
|
||
showAddSatelliteModal();
|
||
switchSatModalTab('celestrak');
|
||
}
|
||
|
||
function fetchCelestrakCategory(category) {
|
||
const status = document.getElementById('celestrakStatus');
|
||
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
|
||
|
||
fetch('/satellite/celestrak/' + category)
|
||
.then(r => r.json())
|
||
.then(async data => {
|
||
if (data.status === 'success' && data.satellites) {
|
||
const toAdd = data.satellites
|
||
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
|
||
.map(sat => ({
|
||
norad_id: String(sat.norad),
|
||
name: sat.name,
|
||
tle1: sat.tle1,
|
||
tle2: sat.tle2,
|
||
enabled: false
|
||
}));
|
||
|
||
if (toAdd.length === 0) {
|
||
status.innerHTML = `<span style="color: var(--accent-green);">All ${data.satellites.length} satellites already tracked</span>`;
|
||
return;
|
||
}
|
||
|
||
const batchSize = 250;
|
||
let addedTotal = 0;
|
||
|
||
for (let i = 0; i < toAdd.length; i += batchSize) {
|
||
const batch = toAdd.slice(i, i + batchSize);
|
||
const completed = Math.min(i + batch.length, toAdd.length);
|
||
status.innerHTML = `<span style="color: var(--accent-cyan);">Importing ${completed}/${toAdd.length} from ${category}...</span>`;
|
||
|
||
const resp = await fetch('/satellite/tracked', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(batch)
|
||
});
|
||
const result = await resp.json().catch(() => ({}));
|
||
|
||
if (!resp.ok || result.status !== 'success') {
|
||
throw new Error(result.message || result.error || `HTTP ${resp.status}`);
|
||
}
|
||
addedTotal += Number(result.added || 0);
|
||
}
|
||
|
||
_loadSatellitesFromAPI();
|
||
status.innerHTML = `<span style="color: var(--accent-green);">Added ${addedTotal} satellites (${data.satellites.length} total in category)</span>`;
|
||
} else {
|
||
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
const msg = err && err.message ? err.message : 'Network error';
|
||
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
|
||
});
|
||
}
|
||
|
||
function _loadSatellitesFromAPI() {
|
||
fetch('/satellite/tracked')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.satellites) {
|
||
trackedSatellites = data.satellites.map(sat => ({
|
||
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
|
||
name: sat.name,
|
||
norad: sat.norad_id,
|
||
builtin: sat.builtin,
|
||
checked: sat.enabled,
|
||
tle: sat.tle_line1 ? [sat.name, sat.tle_line1, sat.tle_line2] : null
|
||
}));
|
||
renderSatelliteList();
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Fallback to hardcoded defaults if API fails
|
||
if (trackedSatellites.length === 0) {
|
||
trackedSatellites = [
|
||
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
|
||
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
|
||
];
|
||
renderSatelliteList();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Initialize satellite list when satellite mode is loaded
|
||
function initSatelliteList() {
|
||
_loadSatellitesFromAPI();
|
||
}
|
||
|
||
// Utility function
|
||
function showInfo(message) {
|
||
// Simple notification - could be enhanced
|
||
const existing = document.querySelector('.info-toast');
|
||
if (existing) existing.remove();
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = 'info-toast';
|
||
toast.textContent = message;
|
||
toast.style.cssText = 'position: fixed; bottom: 20px; right: 20px; background: var(--accent-cyan); color: #000; padding: 10px 20px; border-radius: 4px; z-index: 10001; font-size: 12px;';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 3000);
|
||
}
|
||
|
||
// Theme toggle functions
|
||
function toggleTheme() {
|
||
const html = document.documentElement;
|
||
const currentTheme = html.getAttribute('data-theme') || 'dark';
|
||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||
|
||
html.setAttribute('data-theme', newTheme);
|
||
|
||
// Save to localStorage for instant load on next visit
|
||
localStorage.setItem('intercept-theme', newTheme);
|
||
|
||
// Persist to server for cross-device sync
|
||
fetch('/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ theme: newTheme })
|
||
}).catch(err => console.warn('Failed to save theme to server:', err));
|
||
}
|
||
|
||
// Animation toggle functions
|
||
function toggleAnimations() {
|
||
const html = document.documentElement;
|
||
const currentState = html.getAttribute('data-animations');
|
||
const newState = currentState === 'off' ? 'on' : 'off';
|
||
|
||
if (newState === 'on') {
|
||
html.removeAttribute('data-animations');
|
||
} else {
|
||
html.setAttribute('data-animations', newState);
|
||
}
|
||
|
||
// Save to localStorage for persistence
|
||
localStorage.setItem('intercept-animations', newState);
|
||
}
|
||
|
||
// Load saved theme and animations on page load
|
||
(function () {
|
||
// First apply localStorage theme for instant load (no flash)
|
||
const localTheme = localStorage.getItem('intercept-theme') || 'dark';
|
||
document.documentElement.setAttribute('data-theme', localTheme);
|
||
|
||
// Apply animations preference
|
||
const localAnimations = localStorage.getItem('intercept-animations');
|
||
if (localAnimations === 'off') {
|
||
document.documentElement.setAttribute('data-animations', 'off');
|
||
}
|
||
|
||
// Then fetch from server to sync (in case changed on another device)
|
||
fetch('/settings/theme')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success' && data.value) {
|
||
const serverTheme = data.value;
|
||
if (serverTheme !== localTheme) {
|
||
// Server has different theme, apply it
|
||
document.documentElement.setAttribute('data-theme', serverTheme);
|
||
localStorage.setItem('intercept-theme', serverTheme);
|
||
}
|
||
}
|
||
})
|
||
.catch(() => { }); // Ignore errors, localStorage is fallback
|
||
})();
|
||
|
||
// Help modal functions
|
||
function showHelp() {
|
||
document.getElementById('helpModal').classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function hideHelp() {
|
||
document.getElementById('helpModal').classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function switchHelpTab(tab) {
|
||
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
|
||
document.querySelector(`.help-tab[data-tab="${tab}"]`).classList.add('active');
|
||
document.getElementById(`help-${tab}`).classList.add('active');
|
||
}
|
||
|
||
// Keyboard shortcuts for help
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') hideHelp();
|
||
// Open help with F1 or ? key (when not typing in an input)
|
||
if ((e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !document.getElementById('helpModal').classList.contains('active')) {
|
||
e.preventDefault();
|
||
showHelp();
|
||
}
|
||
});
|
||
|
||
// Scanner and receiver logic are handled by Waterfall mode.
|
||
|
||
// ============================================
|
||
// TSCM (Counter-Surveillance) Functions
|
||
// ============================================
|
||
let isTscmRunning = false;
|
||
let tscmEventSource = null;
|
||
let tscmThreats = [];
|
||
let tscmWifiDevices = [];
|
||
let tscmWifiClients = [];
|
||
let tscmBtDevices = [];
|
||
let tscmBaselineComparison = null;
|
||
let tscmIdentityClusters = [];
|
||
let tscmIdentitySummary = null;
|
||
let tscmCaseLinkContext = null;
|
||
let tscmLastSweepId = null;
|
||
let tscmLastMeetingId = null;
|
||
const tscmFilters = {
|
||
protocol: 'all',
|
||
risk: 'all',
|
||
status: 'all',
|
||
known: 'all',
|
||
};
|
||
let isRecordingBaseline = false;
|
||
let tscmSweepStartTime = null;
|
||
let tscmSweepEndTime = null;
|
||
|
||
async function refreshTscmDevices() {
|
||
// Fetch available interfaces for TSCM scanning
|
||
// Check if agent is selected and route accordingly
|
||
try {
|
||
let response;
|
||
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
|
||
// Fetch devices from agent capabilities
|
||
response = await fetch(`/controller/agents/${currentAgent}?refresh=true`);
|
||
} else {
|
||
response = await fetch('/tscm/devices');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// Handle both local (/tscm/devices) and agent response formats
|
||
let devices;
|
||
const isAgentResponse = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
if (isAgentResponse && data.agent) {
|
||
// Agent response format - extract from capabilities/interfaces
|
||
const agentInterfaces = data.agent.interfaces || {};
|
||
const agentCapabilities = data.agent.capabilities || {};
|
||
devices = {
|
||
wifi_interfaces: agentInterfaces.wifi_interfaces || [],
|
||
bt_adapters: agentInterfaces.bt_adapters || [],
|
||
sdr_devices: agentCapabilities.devices || agentInterfaces.sdr_devices || []
|
||
};
|
||
} else {
|
||
devices = data.devices || {};
|
||
}
|
||
|
||
// Populate WiFi interfaces
|
||
const wifiSelect = document.getElementById('tscmWifiInterface');
|
||
wifiSelect.innerHTML = '<option value="">Select WiFi interface...</option>';
|
||
if (devices.wifi_interfaces && devices.wifi_interfaces.length > 0) {
|
||
devices.wifi_interfaces.forEach(iface => {
|
||
const opt = document.createElement('option');
|
||
opt.value = iface.name;
|
||
opt.textContent = iface.display_name || iface.name;
|
||
wifiSelect.appendChild(opt);
|
||
});
|
||
// Auto-select first interface
|
||
if (devices.wifi_interfaces.length > 0) {
|
||
wifiSelect.value = devices.wifi_interfaces[0].name;
|
||
}
|
||
} else {
|
||
if (isAgentResponse) {
|
||
wifiSelect.innerHTML = '<option value="">Agent manages WiFi</option>';
|
||
} else {
|
||
wifiSelect.innerHTML = '<option value="">No WiFi interfaces found</option>';
|
||
}
|
||
}
|
||
|
||
// Populate Bluetooth adapters
|
||
const btSelect = document.getElementById('tscmBtInterface');
|
||
btSelect.innerHTML = '<option value="">Select Bluetooth adapter...</option>';
|
||
if (devices.bt_adapters && devices.bt_adapters.length > 0) {
|
||
devices.bt_adapters.forEach(adapter => {
|
||
const opt = document.createElement('option');
|
||
opt.value = adapter.name;
|
||
opt.textContent = adapter.display_name || adapter.name;
|
||
btSelect.appendChild(opt);
|
||
});
|
||
// Auto-select first adapter
|
||
if (devices.bt_adapters.length > 0) {
|
||
btSelect.value = devices.bt_adapters[0].name;
|
||
}
|
||
} else {
|
||
if (isAgentResponse) {
|
||
btSelect.innerHTML = '<option value="">Agent manages Bluetooth</option>';
|
||
} else {
|
||
btSelect.innerHTML = '<option value="">No Bluetooth adapters found</option>';
|
||
}
|
||
}
|
||
|
||
// Populate SDR devices
|
||
const sdrSelect = document.getElementById('tscmSdrDevice');
|
||
sdrSelect.innerHTML = '<option value="">Select SDR device...</option>';
|
||
if (devices.sdr_devices && devices.sdr_devices.length > 0) {
|
||
devices.sdr_devices.forEach(dev => {
|
||
const opt = document.createElement('option');
|
||
opt.value = dev.index !== undefined ? dev.index : 0;
|
||
opt.textContent = dev.display_name || dev.name || 'SDR Device';
|
||
sdrSelect.appendChild(opt);
|
||
});
|
||
// Auto-select first SDR if available
|
||
if (devices.sdr_devices.length > 0) {
|
||
sdrSelect.value = devices.sdr_devices[0].index !== undefined ? devices.sdr_devices[0].index : 0;
|
||
}
|
||
} else {
|
||
if (isAgentResponse) {
|
||
sdrSelect.innerHTML = '<option value="">Agent manages SDR</option>';
|
||
} else {
|
||
sdrSelect.innerHTML = '<option value="">No SDR devices found</option>';
|
||
}
|
||
}
|
||
|
||
// Show warnings (e.g., not running as root)
|
||
const warningsDiv = document.getElementById('tscmDeviceWarnings');
|
||
if (data.warnings && data.warnings.length > 0) {
|
||
warningsDiv.innerHTML = data.warnings.map(w =>
|
||
`<div class="tscm-privilege-warning">
|
||
<span class="warning-icon">⚠️</span>
|
||
<div>
|
||
<strong>${escapeHtml(w.message)}</strong>
|
||
${w.action ? `<div class="warning-action">${escapeHtml(w.action)}</div>` : ''}
|
||
</div>
|
||
</div>`
|
||
).join('');
|
||
warningsDiv.style.display = 'block';
|
||
} else {
|
||
warningsDiv.style.display = 'none';
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error('Failed to refresh TSCM devices:', e);
|
||
}
|
||
}
|
||
|
||
async function loadTscmBaselines() {
|
||
try {
|
||
const response = await fetch('/tscm/baselines');
|
||
const data = await response.json();
|
||
const select = document.getElementById('tscmBaselineSelect');
|
||
select.innerHTML = '<option value="">No Baseline</option>';
|
||
if (data.baselines) {
|
||
data.baselines.forEach(b => {
|
||
const opt = document.createElement('option');
|
||
opt.value = b.id;
|
||
opt.textContent = b.name + (b.is_active ? ' (Active)' : '');
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load baselines:', e);
|
||
}
|
||
}
|
||
|
||
async function startTscmSweep() {
|
||
const sweepType = document.getElementById('tscmSweepType').value;
|
||
const baselineId = document.getElementById('tscmBaselineSelect').value || null;
|
||
const wifiEnabled = document.getElementById('tscmWifiEnabled').checked;
|
||
const btEnabled = document.getElementById('tscmBtEnabled').checked;
|
||
const rfEnabled = document.getElementById('tscmRfEnabled').checked;
|
||
const wifiInterface = document.getElementById('tscmWifiInterface').value;
|
||
const btInterface = document.getElementById('tscmBtInterface').value;
|
||
const sdrDevice = document.getElementById('tscmSdrDevice').value;
|
||
const verboseResults = document.getElementById('tscmVerboseResults').checked;
|
||
|
||
// Clear any previous warnings
|
||
document.getElementById('tscmDeviceWarnings').style.display = 'none';
|
||
document.getElementById('tscmDeviceWarnings').innerHTML = '';
|
||
|
||
// Check for agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
// Check for conflicts if using agent
|
||
if (isAgentMode && typeof checkAgentModeConflict === 'function') {
|
||
if (!checkAgentModeConflict('tscm')) {
|
||
return; // Conflict detected, user cancelled
|
||
}
|
||
}
|
||
|
||
try {
|
||
// Route to agent or local based on selection
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/tscm/start`
|
||
: '/tscm/sweep/start';
|
||
|
||
const response = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
sweep_type: sweepType,
|
||
baseline_id: baselineId ? parseInt(baselineId) : null,
|
||
wifi: wifiEnabled,
|
||
bluetooth: btEnabled,
|
||
rf: rfEnabled,
|
||
wifi_interface: wifiInterface,
|
||
bt_interface: btInterface,
|
||
sdr_device: sdrDevice ? parseInt(sdrDevice) : null,
|
||
verbose_results: verboseResults
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
// Handle controller proxy response (agent response is nested in 'result')
|
||
const scanResult = isAgentMode && data.result ? data.result : data;
|
||
if (scanResult.status === 'success' || scanResult.status === 'started') {
|
||
if (scanResult.sweep_id) {
|
||
tscmLastSweepId = scanResult.sweep_id;
|
||
}
|
||
isTscmRunning = true;
|
||
tscmSweepStartTime = new Date();
|
||
tscmSweepEndTime = null;
|
||
document.getElementById('startTscmBtn').style.display = 'none';
|
||
document.getElementById('stopTscmBtn').style.display = 'block';
|
||
document.getElementById('tscmProgress').style.display = 'flex';
|
||
|
||
// Clear and reset the signal timeline for new sweep
|
||
SignalTimeline.clear();
|
||
document.getElementById('tscmReportBtn').style.display = 'none';
|
||
|
||
// Show warnings if any devices unavailable
|
||
if (scanResult.warnings && scanResult.warnings.length > 0) {
|
||
const warningsDiv = document.getElementById('tscmDeviceWarnings');
|
||
warningsDiv.innerHTML = scanResult.warnings.map(w =>
|
||
`<div style="color: #ff9933; font-size: 10px; margin-bottom: 2px;">⚠ ${w}</div>`
|
||
).join('');
|
||
warningsDiv.style.display = 'block';
|
||
}
|
||
|
||
// Update device indicators
|
||
updateTscmDeviceIndicators(scanResult.devices);
|
||
|
||
// Reset displays
|
||
tscmThreats = [];
|
||
tscmWifiDevices = [];
|
||
tscmWifiClients = [];
|
||
tscmBtDevices = [];
|
||
tscmRfSignals = [];
|
||
tscmRfStatusMessage = null;
|
||
tscmCorrelations = [];
|
||
tscmBaselineComparison = null;
|
||
tscmIdentityClusters = [];
|
||
tscmIdentitySummary = null;
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
|
||
// Update capabilities bar for this sweep
|
||
updateTscmCapabilitiesBar(wifiInterface, btInterface);
|
||
// Update baseline health indicator if baseline selected
|
||
if (baselineId) {
|
||
updateTscmBaselineHealth(baselineId);
|
||
}
|
||
|
||
// Start SSE stream
|
||
startTscmStream();
|
||
} else {
|
||
// Show error with details
|
||
let errorMsg = scanResult.message || 'Failed to start sweep';
|
||
if (scanResult.details && scanResult.details.length > 0) {
|
||
errorMsg += '\n\n' + scanResult.details.join('\n');
|
||
}
|
||
alert(errorMsg);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to start TSCM sweep:', e);
|
||
alert('Failed to start sweep: Network error');
|
||
}
|
||
}
|
||
|
||
function updateTscmDeviceIndicators(devices) {
|
||
const wifiIndicator = document.getElementById('tscmWifiIndicator');
|
||
const btIndicator = document.getElementById('tscmBtIndicator');
|
||
const rfIndicator = document.getElementById('tscmRfIndicator');
|
||
|
||
// Safety check for agent mode which may not return devices
|
||
if (!devices) {
|
||
// Just mark all as active if we don't have device info
|
||
if (wifiIndicator) wifiIndicator.classList.add('active');
|
||
if (btIndicator) btIndicator.classList.add('active');
|
||
if (rfIndicator) rfIndicator.classList.add('active');
|
||
return;
|
||
}
|
||
|
||
if (wifiIndicator) {
|
||
wifiIndicator.classList.toggle('active', devices.wifi);
|
||
wifiIndicator.classList.toggle('inactive', !devices.wifi);
|
||
}
|
||
if (btIndicator) {
|
||
btIndicator.classList.toggle('active', devices.bluetooth);
|
||
btIndicator.classList.toggle('inactive', !devices.bluetooth);
|
||
}
|
||
if (rfIndicator) {
|
||
rfIndicator.classList.toggle('active', devices.rf);
|
||
rfIndicator.classList.toggle('inactive', !devices.rf);
|
||
}
|
||
}
|
||
|
||
async function stopTscmSweep() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/tscm/stop`
|
||
: '/tscm/sweep/stop';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
isTscmRunning = false;
|
||
tscmSweepEndTime = new Date();
|
||
if (tscmEventSource) {
|
||
tscmEventSource.close();
|
||
tscmEventSource = null;
|
||
}
|
||
if (typeof tscmAgentPollInterval !== 'undefined' && tscmAgentPollInterval) {
|
||
clearInterval(tscmAgentPollInterval);
|
||
tscmAgentPollInterval = null;
|
||
}
|
||
|
||
document.getElementById('startTscmBtn').style.display = 'block';
|
||
document.getElementById('stopTscmBtn').style.display = 'none';
|
||
document.getElementById('tscmProgress').style.display = 'none';
|
||
|
||
// Show report button if we have any data
|
||
const hasData = tscmWifiDevices.length > 0 || tscmBtDevices.length > 0 || tscmRfSignals.length > 0;
|
||
document.getElementById('tscmReportBtn').style.display = hasData ? 'block' : 'none';
|
||
|
||
return postStopRequest(endpoint, timeoutMs);
|
||
}
|
||
|
||
function generateTscmReport() {
|
||
// Calculate sweep duration
|
||
const startTime = tscmSweepStartTime || new Date();
|
||
const endTime = tscmSweepEndTime || new Date();
|
||
const durationMs = endTime - startTime;
|
||
const durationMin = Math.floor(durationMs / 60000);
|
||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||
|
||
// Categorize devices by classification
|
||
const allDevices = [
|
||
...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi' })),
|
||
...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth' })),
|
||
...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' }))
|
||
];
|
||
|
||
const highInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6);
|
||
const needsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6));
|
||
const informational = allDevices.filter(d => d.classification === 'informational' || d.score < 3);
|
||
|
||
// Determine overall assessment
|
||
let assessment = 'LOW CONCERN';
|
||
let assessmentClass = 'informational';
|
||
if (highInterest.length > 0) {
|
||
assessment = `ELEVATED CONCERN: ${highInterest.length} high-interest item(s) detected requiring immediate attention`;
|
||
assessmentClass = 'high-interest';
|
||
} else if (needsReview.length > 0) {
|
||
assessment = `MODERATE CONCERN: ${needsReview.length} item(s) requiring further review`;
|
||
assessmentClass = 'needs-review';
|
||
} else {
|
||
assessment = 'LOW CONCERN: No anomalies flagged by automated scan. Manual inspection recommended for comprehensive assessment.';
|
||
}
|
||
|
||
// Helper function to render device row
|
||
const renderDevice = (device) => {
|
||
const scoreClass = device.score >= 6 ? 'high' : (device.score >= 3 ? 'medium' : 'low');
|
||
const indicators = (device.indicators || []).map(i =>
|
||
`<span class="indicator">${i.type}: ${i.desc}</span>`
|
||
).join('');
|
||
const reasons = (device.reasons || []).map(r => `<li>${r}</li>`).join('');
|
||
|
||
let identifier = device.bssid || device.mac || (device.frequency ? `${device.frequency} MHz` : 'Unknown');
|
||
let name = device.essid || device.name || device.band || 'Unknown';
|
||
|
||
return `
|
||
<tr class="device-row ${device.classification || ''}">
|
||
<td><span class="protocol-badge ${device.protocol.toLowerCase()}">${device.protocol}</span></td>
|
||
<td><strong>${name}</strong><br><small class="identifier">${identifier}</small></td>
|
||
<td><span class="score-badge ${scoreClass}">${device.score || 0}</span></td>
|
||
<td>${device.classification || 'unknown'}</td>
|
||
<td>${device.signal || device.rssi || device.power || 'N/A'} dBm</td>
|
||
<td>
|
||
${indicators ? `<div class="indicators">${indicators}</div>` : ''}
|
||
${reasons ? `<ul class="reasons">${reasons}</ul>` : ''}
|
||
</td>
|
||
<td>${device.recommended_action || 'monitor'}</td>
|
||
</tr>
|
||
`;
|
||
};
|
||
|
||
// Generate HTML report
|
||
const reportHtml = `
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>TSCM Sweep Report - ${startTime.toLocaleDateString()}</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: #1a1a2e;
|
||
color: #e8eaed;
|
||
padding: 40px;
|
||
line-height: 1.6;
|
||
}
|
||
.report-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
background: #0f1218;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||
}
|
||
.report-header {
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #0f1218 100%);
|
||
padding: 40px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
.report-title {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #4a9eff;
|
||
margin-bottom: 8px;
|
||
}
|
||
.report-subtitle {
|
||
font-size: 14px;
|
||
color: #9ca3af;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
}
|
||
.report-meta {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
margin-top: 30px;
|
||
padding: 20px;
|
||
background: rgba(0,0,0,0.3);
|
||
border-radius: 8px;
|
||
}
|
||
.meta-item {
|
||
text-align: center;
|
||
}
|
||
.meta-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
.meta-label {
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
.section {
|
||
padding: 30px 40px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
.section:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #4a9eff;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.assessment {
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
.assessment.high-interest {
|
||
background: rgba(255, 51, 51, 0.15);
|
||
border: 1px solid #ff3333;
|
||
color: #ff6666;
|
||
}
|
||
.assessment.needs-review {
|
||
background: rgba(255, 204, 0, 0.15);
|
||
border: 1px solid #ffcc00;
|
||
color: #ffdd44;
|
||
}
|
||
.assessment.informational {
|
||
background: rgba(0, 204, 0, 0.15);
|
||
border: 1px solid #00cc00;
|
||
color: #44dd44;
|
||
}
|
||
.summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.summary-card {
|
||
background: rgba(0,0,0,0.3);
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
border: 1px solid #2a2a4a;
|
||
}
|
||
.summary-card.high-interest { border-color: #ff3333; }
|
||
.summary-card.needs-review { border-color: #ffcc00; }
|
||
.summary-card.informational { border-color: #00cc00; }
|
||
.summary-card .count {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
}
|
||
.summary-card.high-interest .count { color: #ff3333; }
|
||
.summary-card.needs-review .count { color: #ffcc00; }
|
||
.summary-card.informational .count { color: #00cc00; }
|
||
.summary-card .label {
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
margin-top: 4px;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th {
|
||
text-align: left;
|
||
padding: 12px;
|
||
background: rgba(0,0,0,0.4);
|
||
color: #9ca3af;
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
td {
|
||
padding: 12px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
vertical-align: top;
|
||
}
|
||
.device-row.high_interest { background: rgba(255, 51, 51, 0.08); }
|
||
.device-row.review { background: rgba(255, 204, 0, 0.08); }
|
||
.protocol-badge {
|
||
display: inline-block;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
}
|
||
.protocol-badge.wifi { background: #4a9eff; color: #000; }
|
||
.protocol-badge.bluetooth { background: #8b5cf6; color: #fff; }
|
||
.protocol-badge.rf { background: #f59e0b; color: #000; }
|
||
.score-badge {
|
||
display: inline-block;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
.score-badge.high { background: rgba(255,51,51,0.2); color: #ff3333; }
|
||
.score-badge.medium { background: rgba(255,204,0,0.2); color: #ffcc00; }
|
||
.score-badge.low { background: rgba(0,204,0,0.2); color: #00cc00; }
|
||
.identifier {
|
||
color: #6b7280;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
}
|
||
.indicators {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.indicator {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
background: rgba(255,153,51,0.2);
|
||
color: #ff9933;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
}
|
||
.reasons {
|
||
margin: 0;
|
||
padding-left: 16px;
|
||
font-size: 11px;
|
||
color: #9ca3af;
|
||
}
|
||
.reasons li {
|
||
margin-bottom: 2px;
|
||
}
|
||
.category-section {
|
||
margin-bottom: 30px;
|
||
}
|
||
.category-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
}
|
||
.category-title.high-interest { background: rgba(255,51,51,0.15); color: #ff6666; }
|
||
.category-title.needs-review { background: rgba(255,204,0,0.15); color: #ffdd44; }
|
||
.category-title.informational { background: rgba(0,204,0,0.15); color: #44dd44; }
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #6b7280;
|
||
}
|
||
.disclaimer {
|
||
padding: 20px;
|
||
background: rgba(74, 158, 255, 0.1);
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
}
|
||
.disclaimer h4 {
|
||
color: #4a9eff;
|
||
margin-bottom: 10px;
|
||
font-size: 13px;
|
||
}
|
||
.recommendations {
|
||
margin-top: 20px;
|
||
}
|
||
.recommendations ul {
|
||
padding-left: 20px;
|
||
}
|
||
.recommendations li {
|
||
margin-bottom: 8px;
|
||
}
|
||
.report-actions {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
display: flex;
|
||
gap: 10px;
|
||
z-index: 1000;
|
||
}
|
||
.report-btn {
|
||
padding: 12px 24px;
|
||
background: #4a9eff;
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
.report-btn:hover {
|
||
background: #6bb3ff;
|
||
}
|
||
.report-btn.save {
|
||
background: #22c55e;
|
||
}
|
||
.report-btn.save:hover {
|
||
background: #2ecc71;
|
||
}
|
||
@media print {
|
||
body { background: #fff; color: #000; padding: 20px; }
|
||
.report-container { box-shadow: none; }
|
||
.report-header { background: #f8f9fa; }
|
||
.report-title { color: #1a1a2e; }
|
||
.section { border-color: #ddd; }
|
||
.section-title { color: #1a1a2e; }
|
||
th { background: #f0f0f0; color: #333; }
|
||
td { border-color: #ddd; }
|
||
.report-actions { display: none; }
|
||
.device-row.high_interest { background: rgba(255, 51, 51, 0.1); }
|
||
.device-row.review { background: rgba(255, 204, 0, 0.1); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="report-actions">
|
||
<button class="report-btn save" onclick="saveReport()">Save Report</button>
|
||
<button class="report-btn" onclick="window.print()">Print Report</button>
|
||
</div>
|
||
|
||
<div class="report-container">
|
||
<div class="report-header">
|
||
<div class="report-title">TSCM Sweep Report</div>
|
||
<div class="report-subtitle">Technical Surveillance Counter-Measures Analysis</div>
|
||
|
||
<div class="report-meta">
|
||
<div class="meta-item">
|
||
<div class="meta-value">${startTime.toLocaleDateString()}</div>
|
||
<div class="meta-label">Date</div>
|
||
</div>
|
||
<div class="meta-item">
|
||
<div class="meta-value">${startTime.toLocaleTimeString()} - ${endTime.toLocaleTimeString()}</div>
|
||
<div class="meta-label">Time Range</div>
|
||
</div>
|
||
<div class="meta-item">
|
||
<div class="meta-value">${durationMin}m ${durationSec}s</div>
|
||
<div class="meta-label">Duration</div>
|
||
</div>
|
||
<div class="meta-item">
|
||
<div class="meta-value">${allDevices.length}</div>
|
||
<div class="meta-label">Total Devices</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">Executive Summary</div>
|
||
<div class="summary-grid">
|
||
<div class="summary-card high-interest">
|
||
<div class="count">${highInterest.length}</div>
|
||
<div class="label">High Interest</div>
|
||
</div>
|
||
<div class="summary-card needs-review">
|
||
<div class="count">${needsReview.length}</div>
|
||
<div class="label">Needs Review</div>
|
||
</div>
|
||
<div class="summary-card informational">
|
||
<div class="count">${informational.length}</div>
|
||
<div class="label">Informational</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="count" style="color: #4a9eff;">${tscmWifiDevices.length}/${tscmBtDevices.length}/${tscmRfSignals.length}</div>
|
||
<div class="label">WiFi/BT/RF</div>
|
||
</div>
|
||
</div>
|
||
<div class="assessment ${assessmentClass}">
|
||
<strong>Assessment:</strong> ${assessment}
|
||
</div>
|
||
</div>
|
||
|
||
${highInterest.length > 0 ? `
|
||
<div class="section">
|
||
<div class="section-title">High Interest Items</div>
|
||
<div class="category-section">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Device</th>
|
||
<th>Score</th>
|
||
<th>Class</th>
|
||
<th>Signal</th>
|
||
<th>Indicators / Reasons</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${highInterest.map(renderDevice).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${needsReview.length > 0 ? `
|
||
<div class="section">
|
||
<div class="section-title">Items Requiring Review</div>
|
||
<div class="category-section">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Device</th>
|
||
<th>Score</th>
|
||
<th>Class</th>
|
||
<th>Signal</th>
|
||
<th>Indicators / Reasons</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${needsReview.map(renderDevice).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${informational.length > 0 ? `
|
||
<div class="section">
|
||
<div class="section-title">Informational Items</div>
|
||
<div class="category-section">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Device</th>
|
||
<th>Score</th>
|
||
<th>Class</th>
|
||
<th>Signal</th>
|
||
<th>Indicators / Reasons</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${informational.map(renderDevice).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${allDevices.length === 0 ? `
|
||
<div class="section">
|
||
<div class="empty-state">
|
||
<p>No devices were detected during this sweep.</p>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="section">
|
||
<div class="section-title">Recommendations</div>
|
||
<div class="recommendations">
|
||
<ul>
|
||
${highInterest.length > 0 ? `
|
||
<li><strong>Immediate Action Required:</strong> ${highInterest.length} high-interest item(s) detected. These devices exhibit characteristics commonly associated with surveillance equipment and should be physically located and investigated.</li>
|
||
` : ''}
|
||
${needsReview.length > 0 ? `
|
||
<li><strong>Further Investigation Recommended:</strong> ${needsReview.length} item(s) require additional review to determine their purpose and legitimacy.</li>
|
||
` : ''}
|
||
${allDevices.filter(d => d.is_new).length > 0 ? `
|
||
<li><strong>New Devices Detected:</strong> ${allDevices.filter(d => d.is_new).length} device(s) were not present in the baseline. Verify these are authorized.</li>
|
||
` : ''}
|
||
<li><strong>Regular Monitoring:</strong> Consider establishing a baseline of normal wireless activity and conducting periodic sweeps to detect changes.</li>
|
||
<li><strong>Physical Inspection:</strong> For any high-interest items, conduct a thorough physical inspection of the area to locate potential surveillance devices.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">Disclaimer</div>
|
||
<div class="disclaimer">
|
||
<h4>Important Notice</h4>
|
||
<p>This report is generated by automated wireless spectrum analysis software. The findings presented are <strong>indicators only</strong> and do not constitute confirmation of surveillance activity. Many legitimate devices may trigger alerts due to their wireless characteristics.</p>
|
||
<p style="margin-top: 10px;">Professional TSCM services involve specialized equipment and expertise beyond wireless spectrum analysis, including: non-linear junction detection, thermal imaging, physical inspection, and RF spectrum analysis with calibrated equipment.</p>
|
||
<p style="margin-top: 10px;"><strong>No content was intercepted or decoded during this analysis.</strong> This tool only detects the presence and characteristics of wireless transmissions.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<scr` + `ipt>
|
||
function saveReport() {
|
||
const html = document.documentElement.outerHTML;
|
||
const blob = new Blob([html], { type: 'text/html' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'TSCM_Report_${startTime.toISOString().split('T')[0]}.html';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
</scr` + `ipt>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
// Open report in new window
|
||
const reportWindow = window.open('', '_blank');
|
||
reportWindow.document.write(reportHtml);
|
||
reportWindow.document.close();
|
||
}
|
||
|
||
let tscmAgentPollInterval = null;
|
||
|
||
function startTscmStream() {
|
||
if (tscmEventSource) {
|
||
tscmEventSource.close();
|
||
tscmEventSource = null;
|
||
}
|
||
if (tscmAgentPollInterval) {
|
||
clearInterval(tscmAgentPollInterval);
|
||
tscmAgentPollInterval = null;
|
||
}
|
||
|
||
// Check if using agent
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
if (isAgentMode) {
|
||
// For agent mode, poll the agent for TSCM data since push may not be enabled
|
||
console.log('[TSCM] Starting agent polling mode');
|
||
pollAgentTscmData(); // Initial poll
|
||
tscmAgentPollInterval = setInterval(pollAgentTscmData, 2000); // Poll every 2 seconds
|
||
} else {
|
||
// For local mode, use SSE stream
|
||
const streamUrl = '/tscm/sweep/stream';
|
||
tscmEventSource = new EventSource(streamUrl);
|
||
|
||
tscmEventSource.onmessage = function (event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
handleTscmEvent(data);
|
||
} catch (e) {
|
||
console.error('TSCM SSE parse error:', e);
|
||
}
|
||
};
|
||
|
||
tscmEventSource.onerror = function () {
|
||
console.warn('TSCM SSE connection error');
|
||
};
|
||
}
|
||
}
|
||
|
||
async function pollAgentTscmData() {
|
||
if (!isTscmRunning) {
|
||
if (tscmAgentPollInterval) {
|
||
clearInterval(tscmAgentPollInterval);
|
||
tscmAgentPollInterval = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/controller/agents/${currentAgent}/tscm/data`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success' && result.data) {
|
||
// Agent data is nested: result.data.data (controller wraps agent response)
|
||
const data = result.data.data || result.data;
|
||
|
||
// Process WiFi devices
|
||
if (data.wifi_devices) {
|
||
data.wifi_devices.forEach(device => {
|
||
if (!tscmWifiDevices.find(d => d.bssid === device.bssid)) {
|
||
handleTscmEvent({ type: 'wifi_device', ...device });
|
||
}
|
||
});
|
||
}
|
||
if (data.wifi_clients) {
|
||
data.wifi_clients.forEach(client => {
|
||
const clientMac = client.mac || client.address;
|
||
if (!tscmWifiClients.find(d => (d.mac || d.address) === clientMac)) {
|
||
handleTscmEvent({ type: 'wifi_client', ...client });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Process Bluetooth devices
|
||
if (data.bt_devices) {
|
||
data.bt_devices.forEach(device => {
|
||
const deviceMac = device.mac || device.address;
|
||
if (!tscmBtDevices.find(d => (d.mac || d.address) === deviceMac)) {
|
||
handleTscmEvent({ type: 'bt_device', ...device });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Process anomalies/threats
|
||
// Agent now uses same ThreatDetector as local mode, so format matches:
|
||
// threat_type, severity, source, identifier, name, signal_strength
|
||
if (data.anomalies) {
|
||
data.anomalies.forEach(threat => {
|
||
handleTscmEvent({
|
||
type: 'threat_detected',
|
||
...threat
|
||
});
|
||
});
|
||
}
|
||
|
||
// Process RF signals
|
||
if (data.rf_signals) {
|
||
data.rf_signals.forEach(signal => {
|
||
handleTscmEvent({ type: 'rf_signal', ...signal });
|
||
});
|
||
}
|
||
|
||
// Update progress (simple time-based estimate)
|
||
if (tscmSweepStartTime) {
|
||
const elapsed = (Date.now() - tscmSweepStartTime) / 1000;
|
||
const sweepType = document.getElementById('tscmSweepType')?.value || 'standard';
|
||
const durations = { quick: 120, standard: 300, full: 900 };
|
||
const maxDuration = durations[sweepType] || 300;
|
||
const progress = Math.min(95, (elapsed / maxDuration) * 100);
|
||
updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[TSCM] Agent poll error:', e);
|
||
}
|
||
}
|
||
|
||
let tscmCorrelations = [];
|
||
|
||
function handleTscmEvent(data) {
|
||
switch (data.type) {
|
||
case 'sweep_progress':
|
||
updateTscmProgress(data);
|
||
break;
|
||
case 'wifi_device':
|
||
addTscmWifiDevice(data);
|
||
break;
|
||
case 'wifi_client':
|
||
addTscmWifiClient(data);
|
||
break;
|
||
case 'bt_device':
|
||
addTscmBtDevice(data);
|
||
break;
|
||
case 'rf_signal':
|
||
addTscmRfSignal(data);
|
||
break;
|
||
case 'rf_status':
|
||
handleRfStatus(data);
|
||
break;
|
||
case 'threat_detected':
|
||
addTscmThreat(data);
|
||
break;
|
||
case 'correlation_findings':
|
||
handleCorrelationFindings(data);
|
||
break;
|
||
case 'baseline_comparison':
|
||
handleBaselineComparison(data);
|
||
break;
|
||
case 'identity_clusters':
|
||
handleIdentityClusters(data);
|
||
break;
|
||
case 'sweep_completed':
|
||
completeTscmSweep(data);
|
||
break;
|
||
case 'sweep_stopped':
|
||
case 'sweep_error':
|
||
stopTscmSweep();
|
||
break;
|
||
}
|
||
}
|
||
|
||
function handleCorrelationFindings(data) {
|
||
tscmCorrelations = data.correlations || [];
|
||
updateCorrelationsDisplay();
|
||
updateTscmThreatCounts();
|
||
}
|
||
|
||
function handleBaselineComparison(data) {
|
||
tscmBaselineComparison = data || null;
|
||
}
|
||
|
||
function handleIdentityClusters(data) {
|
||
tscmIdentitySummary = {
|
||
total: data.total_clusters || 0,
|
||
high: data.high_risk_count || 0,
|
||
medium: data.medium_risk_count || 0,
|
||
unique_fingerprints: data.unique_fingerprints || 0,
|
||
};
|
||
tscmIdentityClusters = data.clusters || [];
|
||
updateCorrelationsDisplay();
|
||
updateTscmThreatCounts();
|
||
}
|
||
|
||
async function tscmRefreshIdentityClusters() {
|
||
try {
|
||
const [clusterRes, summaryRes] = await Promise.all([
|
||
fetch('/tscm/identity/clusters'),
|
||
fetch('/tscm/identity/summary')
|
||
]);
|
||
|
||
const clusterData = await clusterRes.json();
|
||
if (clusterData.status === 'success') {
|
||
tscmIdentityClusters = clusterData.clusters || [];
|
||
}
|
||
|
||
const summaryData = await summaryRes.json();
|
||
if (summaryData.status === 'success' && summaryData.summary) {
|
||
const stats = summaryData.summary.statistics || {};
|
||
tscmIdentitySummary = {
|
||
total: stats.total_clusters || tscmIdentityClusters.length,
|
||
high: stats.high_risk_count || 0,
|
||
medium: stats.medium_risk_count || 0,
|
||
unique_fingerprints: stats.unique_fingerprints || 0,
|
||
};
|
||
}
|
||
|
||
updateCorrelationsDisplay();
|
||
updateTscmThreatCounts();
|
||
} catch (e) {
|
||
console.error('Failed to refresh identity clusters:', e);
|
||
}
|
||
}
|
||
|
||
function addTscmWifiDevice(device) {
|
||
// Check if already exists
|
||
const exists = tscmWifiDevices.some(d => d.bssid === device.bssid);
|
||
if (!exists) {
|
||
tscmWifiDevices.push(device);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
// Add to findings panel if score >= 3 (review level or higher)
|
||
if (device.score >= 3) {
|
||
addHighInterestDevice(device, 'wifi');
|
||
}
|
||
// Feed to baseline recorder if recording
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/wifi', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(device)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
// Add to signal timeline
|
||
const freq = device.channel <= 14 ? '2400' : '5000';
|
||
const strength = Math.min(5, Math.max(1, Math.ceil((device.signal + 100) / 20)));
|
||
SignalTimeline.addEvent(freq, strength, 2000, device.ssid || 'Hidden WiFi');
|
||
}
|
||
}
|
||
|
||
function addTscmWifiClient(client) {
|
||
const mac = client.mac || client.address || '';
|
||
if (!mac) return;
|
||
const exists = tscmWifiClients.some(d => (d.mac || d.address) === mac);
|
||
if (!exists) {
|
||
if (!client.mac) client.mac = mac;
|
||
client.is_client = true;
|
||
tscmWifiClients.push(client);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
if (client.score >= 3) {
|
||
addHighInterestDevice(client, 'wifi');
|
||
}
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/wifi', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(client)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
}
|
||
}
|
||
|
||
function addTscmBtDevice(device) {
|
||
const mac = device.mac || device.address || '';
|
||
// Check if already exists
|
||
const exists = tscmBtDevices.some(d => (d.mac || d.address) === mac);
|
||
if (!exists) {
|
||
if (!device.mac && mac) device.mac = mac;
|
||
tscmBtDevices.push(device);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
// Add to threats panel if score >= 3 (review level or higher)
|
||
if (device.score >= 3) {
|
||
addHighInterestDevice(device, 'bluetooth');
|
||
}
|
||
// Feed to baseline recorder if recording
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/bluetooth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(device)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
// Add to signal timeline
|
||
const strength = device.rssi ? Math.min(5, Math.max(1, Math.ceil((device.rssi + 100) / 20))) : 3;
|
||
SignalTimeline.addEvent('2450', strength, 1500, device.name || 'Bluetooth Device');
|
||
}
|
||
}
|
||
|
||
let tscmRfSignals = [];
|
||
let tscmRfStatusMessage = null;
|
||
|
||
function addTscmRfSignal(signal) {
|
||
// Clear any error message since we're receiving signals
|
||
tscmRfStatusMessage = null;
|
||
// Check if already exists (within 0.1 MHz)
|
||
const exists = tscmRfSignals.some(s => Math.abs(s.frequency - signal.frequency) < 0.1);
|
||
const powerDbm = signal.power_dbm ?? signal.power ?? signal.level;
|
||
const strength = powerDbm !== undefined && powerDbm !== null
|
||
? Math.min(5, Math.max(1, Math.ceil((powerDbm + 60) / 15)))
|
||
: 3;
|
||
if (!exists) {
|
||
tscmRfSignals.push(signal);
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
// Add to findings panel if score >= 3 (review level or higher)
|
||
if (signal.score >= 3) {
|
||
addHighInterestDevice(signal, 'rf');
|
||
}
|
||
// Feed to baseline recorder if recording
|
||
if (isRecordingBaseline) {
|
||
fetch('/tscm/feed/rf', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(signal)
|
||
}).catch(e => console.error('Baseline feed error:', e));
|
||
}
|
||
// Add to signal timeline
|
||
SignalTimeline.addEvent(String(signal.frequency), strength, 1000, signal.classification || 'RF Signal');
|
||
} else {
|
||
// Update existing signal on timeline (show recurring transmission)
|
||
SignalTimeline.addEvent(String(signal.frequency), strength, 500, signal.classification || 'RF Signal');
|
||
}
|
||
}
|
||
|
||
function handleRfStatus(data) {
|
||
// Store status message to display in RF panel
|
||
tscmRfStatusMessage = data.message;
|
||
updateRfDisplay();
|
||
}
|
||
|
||
function updateRfDisplay() {
|
||
const rfList = document.getElementById('tscmRfList');
|
||
if (!rfList) return;
|
||
|
||
if (tscmRfSignals.length === 0) {
|
||
if (tscmRfStatusMessage) {
|
||
rfList.innerHTML = `<div class="tscm-status-message">${escapeHtml(tscmRfStatusMessage)}</div>`;
|
||
} else {
|
||
rfList.innerHTML = '<div class="tscm-empty">No RF signals detected</div>';
|
||
}
|
||
}
|
||
// If there are signals, updateTscmDisplays() will handle the display
|
||
}
|
||
|
||
// Track high-interest devices for the threats panel
|
||
let tscmHighInterestDevices = [];
|
||
function addHighInterestDevice(device, protocol) {
|
||
const id = device.mac || device.bssid || device.frequency;
|
||
const exists = tscmHighInterestDevices.some(d => d.id === id);
|
||
if (!exists) {
|
||
tscmHighInterestDevices.push({
|
||
id: id,
|
||
protocol: protocol,
|
||
name: device.name || device.essid || device.ssid || (device.frequency ? `${device.frequency.toFixed(3)} MHz` : 'Unknown Device'),
|
||
score: device.score,
|
||
classification: device.classification,
|
||
indicators: device.indicators || [],
|
||
recommended_action: device.recommended_action,
|
||
device: device
|
||
});
|
||
updateHighInterestPanel();
|
||
}
|
||
}
|
||
|
||
function updateHighInterestPanel() {
|
||
const panel = document.getElementById('tscmThreatList');
|
||
if (tscmHighInterestDevices.length === 0) {
|
||
panel.innerHTML = '<div class="tscm-empty"><div class="tscm-empty-primary">Monitoring active — nothing flagged</div><div class="tscm-empty-secondary">Signals are being analyzed against baseline thresholds. This does not rule out passive or dormant devices.</div></div>';
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...tscmHighInterestDevices].sort((a, b) => b.score - a.score);
|
||
panel.innerHTML = '<div class="tscm-threat-list">' + sorted.map(d => {
|
||
const severityClass = d.score >= 6 ? 'critical' : d.score >= 4 ? 'high' : 'medium';
|
||
return `
|
||
<div class="tscm-threat-item ${severityClass}" onclick="showDeviceDetails('${d.id}', '${d.protocol}')">
|
||
<div class="tscm-threat-header">
|
||
<span class="tscm-threat-type">${d.protocol.toUpperCase()}</span>
|
||
<span class="tscm-threat-severity">Score: ${d.score}</span>
|
||
</div>
|
||
<div class="tscm-threat-details">
|
||
<strong>${escapeHtml(d.name)}</strong><br>
|
||
<span style="font-size: 10px; color: var(--text-muted);">
|
||
${d.indicators && d.indicators.length > 0 ? d.indicators.slice(0, 2).map(i => i.desc || i.type).join(' | ') : 'Review recommended'}
|
||
</span>
|
||
</div>
|
||
<div class="tscm-threat-action">${d.recommended_action || 'review'}</div>
|
||
</div>
|
||
`;
|
||
}).join('') + '</div>';
|
||
}
|
||
}
|
||
|
||
function updateTscmProgress(data) {
|
||
// Update percentage text
|
||
document.getElementById('tscmProgressPercent').textContent = data.progress + '%';
|
||
|
||
// Update SVG circle progress (circumference = 2 * PI * 45 = ~283)
|
||
const circumference = 283;
|
||
const offset = circumference - (data.progress / 100) * circumference;
|
||
const circle = document.getElementById('tscmScannerCircle');
|
||
if (circle) {
|
||
circle.style.strokeDashoffset = offset;
|
||
}
|
||
|
||
// Update status text
|
||
let statusText = 'SCANNING...';
|
||
if (data.threats_found > 0) {
|
||
statusText = `THREATS: ${data.threats_found}`;
|
||
} else if (data.status) {
|
||
statusText = data.status;
|
||
} else {
|
||
const parts = [];
|
||
if (data.wifi_count > 0) parts.push(`${data.wifi_count} WiFi`);
|
||
if (data.bt_count > 0) parts.push(`${data.bt_count} BT`);
|
||
if (data.rf_count > 0) parts.push(`${data.rf_count} RF`);
|
||
statusText = parts.length > 0 ? parts.join(' | ') : 'SCANNING...';
|
||
}
|
||
document.getElementById('tscmProgressLabel').textContent = statusText;
|
||
}
|
||
|
||
function addTscmThreat(threat) {
|
||
tscmThreats.unshift(threat);
|
||
|
||
// Update dashboard counts
|
||
updateTscmThreatCounts();
|
||
updateTscmDisplays();
|
||
}
|
||
|
||
function readTscmFilters() {
|
||
const protocolSelect = document.getElementById('tscmFilterProtocol');
|
||
const riskSelect = document.getElementById('tscmFilterRisk');
|
||
const statusSelect = document.getElementById('tscmFilterStatus');
|
||
const knownSelect = document.getElementById('tscmFilterKnown');
|
||
|
||
tscmFilters.protocol = protocolSelect ? protocolSelect.value : 'all';
|
||
tscmFilters.risk = riskSelect ? riskSelect.value : 'all';
|
||
tscmFilters.status = statusSelect ? statusSelect.value : 'all';
|
||
tscmFilters.known = knownSelect ? knownSelect.value : 'all';
|
||
}
|
||
|
||
function updateTscmFilterStatus() {
|
||
const statusEl = document.getElementById('tscmFilterStatusText');
|
||
if (!statusEl) return;
|
||
|
||
const parts = [];
|
||
if (tscmFilters.protocol !== 'all') parts.push(tscmFilters.protocol.toUpperCase());
|
||
if (tscmFilters.risk !== 'all') parts.push(tscmFilters.risk.replace(/_/g, ' ').toUpperCase());
|
||
if (tscmFilters.status !== 'all') parts.push(tscmFilters.status.toUpperCase());
|
||
if (tscmFilters.known !== 'all') parts.push(tscmFilters.known.toUpperCase());
|
||
|
||
statusEl.textContent = parts.length > 0 ? `Filters: ${parts.join(' • ')}` : 'Filters: none';
|
||
}
|
||
|
||
function updateTscmPanelVisibility() {
|
||
const protocol = tscmFilters.protocol;
|
||
const showWifi = protocol === 'all' || protocol === 'wifi';
|
||
const showBt = protocol === 'all' || protocol === 'bluetooth';
|
||
const showRf = protocol === 'all' || protocol === 'rf';
|
||
|
||
const wifiPanel = document.getElementById('tscmWifiPanel');
|
||
const wifiClientPanel = document.getElementById('tscmWifiClientPanel');
|
||
const btPanel = document.getElementById('tscmBtPanel');
|
||
const rfPanel = document.getElementById('tscmRfPanel');
|
||
|
||
if (wifiPanel) wifiPanel.style.display = showWifi ? '' : 'none';
|
||
if (wifiClientPanel) wifiClientPanel.style.display = showWifi ? '' : 'none';
|
||
if (btPanel) btPanel.style.display = showBt ? '' : 'none';
|
||
if (rfPanel) rfPanel.style.display = showRf ? '' : 'none';
|
||
}
|
||
|
||
function matchesTscmFilters(device, protocol, options = {}) {
|
||
if (tscmFilters.protocol !== 'all' && protocol !== tscmFilters.protocol) return false;
|
||
|
||
if (!options.ignoreRisk && tscmFilters.risk !== 'all') {
|
||
if ((device.classification || 'review') !== tscmFilters.risk) return false;
|
||
}
|
||
|
||
if (tscmFilters.status === 'new' && device.is_new !== true) return false;
|
||
if (tscmFilters.status === 'baseline' && device.is_new !== false) return false;
|
||
|
||
if (tscmFilters.known === 'known' && !device.known_device) return false;
|
||
if (tscmFilters.known === 'unknown' && device.known_device) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
function getFilteredDevices(options = {}) {
|
||
const wifi = tscmWifiDevices.filter(d => matchesTscmFilters(d, 'wifi', options));
|
||
const wifi_clients = tscmWifiClients.filter(d => matchesTscmFilters(d, 'wifi', options));
|
||
const bt = tscmBtDevices.filter(d => matchesTscmFilters(d, 'bluetooth', options));
|
||
const rf = tscmRfSignals.filter(d => matchesTscmFilters(d, 'rf', options));
|
||
return {
|
||
wifi,
|
||
wifi_clients,
|
||
bt,
|
||
rf,
|
||
all: [...wifi, ...wifi_clients, ...bt, ...rf],
|
||
};
|
||
}
|
||
|
||
function applyTscmFilters() {
|
||
readTscmFilters();
|
||
updateTscmFilterStatus();
|
||
updateTscmPanelVisibility();
|
||
updateTscmDisplays();
|
||
updateTscmThreatCounts();
|
||
}
|
||
|
||
function updateTscmThreatCounts() {
|
||
// Count devices by new scoring model classification
|
||
const counts = { high_interest: 0, review: 0, informational: 0 };
|
||
|
||
// Count from all device lists
|
||
const filtered = getFilteredDevices();
|
||
filtered.all.forEach(d => {
|
||
const classification = d.classification || 'review';
|
||
if (classification === 'high_interest') counts.high_interest++;
|
||
else if (classification === 'review') counts.review++;
|
||
else counts.informational++;
|
||
});
|
||
|
||
document.getElementById('tscmHighInterestCount').textContent = counts.high_interest;
|
||
document.getElementById('tscmNeedsReviewCount').textContent = counts.review;
|
||
document.getElementById('tscmInformationalCount').textContent = counts.informational;
|
||
document.getElementById('tscmCorrelationsCount').textContent = tscmCorrelations.length;
|
||
document.getElementById('tscmIdentityCount').textContent = tscmIdentityClusters.length;
|
||
|
||
document.getElementById('tscmHighInterestCard').classList.toggle('active', counts.high_interest > 0);
|
||
document.getElementById('tscmNeedsReviewCard').classList.toggle('active', counts.review > 0);
|
||
document.getElementById('tscmInformationalCard').classList.toggle('active', counts.informational > 0);
|
||
document.getElementById('tscmCorrelationsCard').classList.toggle('active', tscmCorrelations.length > 0);
|
||
document.getElementById('tscmIdentityCard').classList.toggle('active', tscmIdentityClusters.length > 0);
|
||
|
||
// Update threat panel count (shows high interest items only)
|
||
document.getElementById('tscmThreatCount').textContent = counts.high_interest;
|
||
}
|
||
|
||
function getClassificationClass(classification) {
|
||
// Map classification to CSS class
|
||
switch (classification) {
|
||
case 'high_interest': return 'classification-red';
|
||
case 'review': return 'classification-yellow';
|
||
case 'informational': return 'classification-green';
|
||
default: return 'classification-yellow';
|
||
}
|
||
}
|
||
|
||
function getClassificationIcon(classification) {
|
||
// Returns CSS class name for colored dot styling instead of emojis
|
||
switch (classification) {
|
||
case 'high_interest': return '<span class="classification-dot high"></span>';
|
||
case 'review': return '<span class="classification-dot review"></span>';
|
||
case 'informational': return '<span class="classification-dot info"></span>';
|
||
default: return '<span class="classification-dot review"></span>';
|
||
}
|
||
}
|
||
|
||
function formatIndicators(indicators) {
|
||
if (!indicators || indicators.length === 0) return '';
|
||
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
|
||
}
|
||
|
||
function getTrackerLabel(device) {
|
||
if (!device) return null;
|
||
return (device.tracker && (device.tracker.name || device.tracker.type)) ||
|
||
device.tracker_type || device.tracker_name || null;
|
||
}
|
||
|
||
function formatTrackerBadge(device) {
|
||
const label = getTrackerLabel(device);
|
||
if (!label) return '';
|
||
return `<span class="tracker-badge" title="Tracker">${escapeHtml(label)}</span>`;
|
||
}
|
||
|
||
function getScoreBadge(score) {
|
||
if (score === undefined || score === null) return '';
|
||
let scoreClass = 'score-low';
|
||
if (score >= 6) scoreClass = 'score-high';
|
||
else if (score >= 3) scoreClass = 'score-medium';
|
||
return `<span class="score-badge ${scoreClass}">Score: ${score}</span>`;
|
||
}
|
||
|
||
// Store all devices for lookup
|
||
function getAllTscmDevices() {
|
||
const devices = {};
|
||
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
|
||
tscmWifiClients.forEach(d => { devices[`wifi:${d.mac}`] = { ...d, protocol: 'wifi' }; });
|
||
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
|
||
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
|
||
return devices;
|
||
}
|
||
|
||
function showDeviceDetails(id, protocol) {
|
||
const devices = getAllTscmDevices();
|
||
const key = `${protocol}:${id}`;
|
||
const device = devices[key];
|
||
|
||
if (!device) {
|
||
console.warn('Device not found:', key);
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
// Build detailed view
|
||
let html = `
|
||
<div class="device-detail-header ${getClassificationClass(device.classification)}">
|
||
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(device.name || device.essid || device.ssid || device.mac || device.bssid || (device.frequency ? device.frequency.toFixed(3) + ' MHz' : 'Unknown'))}</h3>
|
||
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
|
||
</div>
|
||
|
||
<div class="device-detail-score">
|
||
<div class="score-circle ${device.score >= 6 ? 'high' : device.score >= 3 ? 'medium' : 'low'}">
|
||
<span class="score-value">${device.score || 0}</span>
|
||
<span class="score-label">SCORE</span>
|
||
</div>
|
||
<div class="score-breakdown">
|
||
<strong>Risk Level:</strong> ${device.classification === 'high_interest' ? 'HIGH INTEREST' : device.classification === 'review' ? 'NEEDS REVIEW' : 'INFORMATIONAL'}<br>
|
||
<strong>Recommended Action:</strong> ${device.recommended_action || 'Monitor'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="device-detail-section">
|
||
<h4>Device Information</h4>
|
||
<table class="device-detail-table">
|
||
`;
|
||
|
||
// Add device-specific fields
|
||
if (protocol === 'wifi') {
|
||
if (device.is_client) {
|
||
html += `
|
||
<tr><td>Client MAC</td><td>${device.mac || 'Unknown'}</td></tr>
|
||
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
|
||
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
|
||
<tr><td>Associated BSSID</td><td>${device.associated_bssid || 'Unassociated'}</td></tr>
|
||
<tr><td>Probed SSIDs</td><td>${device.probe_count || (device.probed_ssids ? device.probed_ssids.length : 0)}</td></tr>
|
||
`;
|
||
} else {
|
||
html += `
|
||
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
|
||
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
|
||
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
|
||
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
|
||
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
|
||
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
|
||
`;
|
||
}
|
||
} else if (protocol === 'bluetooth') {
|
||
const trackerLabel = getTrackerLabel(device);
|
||
html += `
|
||
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
|
||
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
|
||
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
|
||
<tr><td>Manufacturer</td><td>${escapeHtml(device.manufacturer || 'Unknown')}</td></tr>
|
||
<tr><td>Tracker</td><td>${trackerLabel ? escapeHtml(trackerLabel) : 'No'}</td></tr>
|
||
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
|
||
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
|
||
`;
|
||
} else if (protocol === 'rf') {
|
||
html += `
|
||
<tr><td>Frequency</td><td>${device.frequency?.toFixed(3) || 'Unknown'} MHz</td></tr>
|
||
<tr><td>Band</td><td>${device.band || 'Unknown'}</td></tr>
|
||
<tr><td>Power</td><td>${device.power?.toFixed(1) || '--'} dBm</td></tr>
|
||
<tr><td>Signal Strength</td><td>+${(device.signal_strength || 0).toFixed(1)} dB above noise</td></tr>
|
||
`;
|
||
}
|
||
|
||
if (device.known_device) {
|
||
const knownLabel = device.known_device_name ? `Yes (${escapeHtml(device.known_device_name)})` : 'Yes';
|
||
html += `<tr><td>Known Device</td><td>${knownLabel}</td></tr>`;
|
||
}
|
||
if (device.score_modifier && device.score_modifier !== 0) {
|
||
const modLabel = `${device.score_modifier > 0 ? '+' : ''}${device.score_modifier}`;
|
||
html += `<tr><td>Score Modifier</td><td>${modLabel}</td></tr>`;
|
||
}
|
||
html += `</table></div>`;
|
||
|
||
// Actions section - always show with "Add to Known Devices"
|
||
const deviceIdentifier = device.bssid || device.mac || (device.frequency ? device.frequency.toString() : id);
|
||
const deviceName = device.name || device.ssid || device.essid || (device.frequency ? device.frequency + ' MHz' : 'Unknown');
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Actions</h4>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
`;
|
||
|
||
// Add "Listen" button for RF signals
|
||
if (protocol === 'rf' && device.frequency) {
|
||
const freq = device.frequency;
|
||
html += `
|
||
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'fm')">
|
||
Listen (FM)
|
||
</button>
|
||
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'am')">
|
||
Listen (AM)
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
// Add "Add to Known Devices" button for all device types
|
||
html += `
|
||
<button class="tscm-action-btn" style="background: var(--accent-cyan);" onclick="tscmAddToKnownDevices('${escapeHtml(deviceIdentifier)}', '${escapeHtml(deviceName)}', '${protocol}')">
|
||
Add to Known Devices
|
||
</button>
|
||
<button class="tscm-action-btn" style="background: var(--accent-orange);" onclick="tscmShowInvestigateById('${escapeHtml(deviceIdentifier)}', '${protocol}')">
|
||
Investigate
|
||
</button>
|
||
</div>
|
||
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;">
|
||
${protocol === 'rf' ? 'Listen buttons open Spectrum Waterfall. ' : ''}Known devices are excluded from threat scoring in future sweeps.
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Timeline section (loaded async)
|
||
html += `
|
||
<div class="device-detail-section" id="tscmTimelineSection">
|
||
<h4>Timeline</h4>
|
||
<div class="tscm-empty">Loading timeline...</div>
|
||
</div>
|
||
`;
|
||
|
||
if (protocol === 'wifi') {
|
||
html += `
|
||
<div class="device-detail-section" id="tscmWifiAdvancedSection">
|
||
<h4>WiFi Advanced Indicators</h4>
|
||
<div class="tscm-empty">Analyzing network...</div>
|
||
</div>
|
||
`;
|
||
} else if (protocol === 'bluetooth') {
|
||
html += `
|
||
<div class="device-detail-section" id="tscmBleExplainSection">
|
||
<h4>Bluetooth Risk Explanation</h4>
|
||
<div class="tscm-empty">Analyzing device...</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Add indicators section
|
||
if (device.indicators && device.indicators.length > 0) {
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Risk Indicators (Why This Score)</h4>
|
||
<div class="indicator-list">
|
||
${device.indicators.map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${i.type}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.desc || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Add reasons section
|
||
if (device.reasons && device.reasons.length > 0) {
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Detection Notes</h4>
|
||
<ul class="device-reasons-list">
|
||
${device.reasons.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Signal Timeline Chart
|
||
html += `
|
||
<div class="device-detail-section">
|
||
<h4>Signal Timeline</h4>
|
||
<canvas id="deviceTimelineChart" width="600" height="180" style="width: 100%; max-height: 180px;"></canvas>
|
||
<div id="deviceTimelineMetrics" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;"></div>
|
||
</div>
|
||
`;
|
||
|
||
// Playbook section
|
||
html += `
|
||
<div class="device-detail-section" id="devicePlaybookSection" style="display: none;">
|
||
<h4>Recommended Playbook</h4>
|
||
<div id="devicePlaybookContent"></div>
|
||
</div>
|
||
`;
|
||
|
||
// Add disclaimer
|
||
html += `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Disclaimer:</strong> This analysis identifies indicators and anomalies.
|
||
It does NOT confirm surveillance activity. Professional verification required.
|
||
</div>
|
||
`;
|
||
|
||
content.innerHTML = html;
|
||
modal.style.display = 'flex';
|
||
|
||
const timelineIdentifier = tscmNormalizeIdentifier(id, protocol, device);
|
||
loadTscmTimeline(timelineIdentifier, protocol);
|
||
loadTscmAdvancedAnalysis(device, protocol);
|
||
|
||
// Load timeline chart
|
||
fetchDeviceTimelineChart(timelineIdentifier, protocol);
|
||
|
||
// Load playbook for this device
|
||
fetchDevicePlaybook(timelineIdentifier).then(playbook => {
|
||
if (playbook) {
|
||
const section = document.getElementById('devicePlaybookSection');
|
||
const pbContent = document.getElementById('devicePlaybookContent');
|
||
if (section && pbContent) {
|
||
section.style.display = 'block';
|
||
pbContent.innerHTML = renderPlaybook(playbook);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function tscmNormalizeIdentifier(identifier, protocol, device) {
|
||
let value = identifier;
|
||
if ((value === undefined || value === null || value === '') && device) {
|
||
value = device.bssid || device.mac || device.frequency || '';
|
||
}
|
||
|
||
if (protocol === 'rf') {
|
||
const freq = device && device.frequency !== undefined ? device.frequency : parseFloat(value);
|
||
if (!isNaN(freq)) return freq.toFixed(3);
|
||
return String(value || '');
|
||
}
|
||
|
||
if (value === undefined || value === null) return '';
|
||
return String(value).toUpperCase();
|
||
}
|
||
|
||
function tscmShowInvestigateById(id, protocol) {
|
||
const devices = getAllTscmDevices();
|
||
const key = `${protocol}:${id}`;
|
||
let device = devices[key];
|
||
|
||
if (!device && protocol === 'rf') {
|
||
const freq = parseFloat(id);
|
||
if (!isNaN(freq)) {
|
||
const all = Object.values(devices);
|
||
device = all.find(d => d.protocol === 'rf' && d.frequency && Math.abs(d.frequency - freq) < 0.01);
|
||
}
|
||
}
|
||
|
||
if (!device) {
|
||
const normalized = tscmNormalizeIdentifier(id, protocol);
|
||
const all = Object.values(devices);
|
||
device = all.find(d => tscmNormalizeIdentifier(null, protocol, d) === normalized);
|
||
}
|
||
|
||
if (!device) {
|
||
console.warn('Investigate device not found:', key);
|
||
return;
|
||
}
|
||
|
||
tscmShowInvestigate(device, protocol);
|
||
}
|
||
|
||
async function tscmShowInvestigate(device, protocol) {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
const identifier = tscmNormalizeIdentifier(null, protocol, device);
|
||
const displayName = device.name || device.essid || device.ssid || device.mac || device.bssid ||
|
||
(device.frequency ? `${device.frequency.toFixed(3)} MHz` : 'Unknown Device');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading triage sheet...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
let cases = [];
|
||
try {
|
||
const response = await fetch('/tscm/cases');
|
||
const data = await response.json();
|
||
cases = data.cases || [];
|
||
} catch (e) {
|
||
console.warn('Failed to load cases for triage:', e);
|
||
}
|
||
|
||
const caseOptions = cases.length
|
||
? `<option value="">Select a case...</option>` +
|
||
cases.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('')
|
||
: '<option value="">No cases available</option>';
|
||
|
||
const noteTemplate = [
|
||
`Device: ${displayName}`,
|
||
`Protocol: ${protocol.toUpperCase()}`,
|
||
`Identifier: ${identifier}`,
|
||
`Score: ${device.score || 0}`,
|
||
`Recommended Action: ${device.recommended_action || 'monitor'}`,
|
||
'Notes:'
|
||
].join('\n');
|
||
|
||
const indicatorList = (device.indicators || []).map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.desc || i.description || '')}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${getClassificationClass(device.classification)}">
|
||
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(displayName)}</h3>
|
||
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
|
||
</div>
|
||
<div class="device-detail-score">
|
||
<div class="score-circle ${device.score >= 6 ? 'high' : device.score >= 3 ? 'medium' : 'low'}">
|
||
<span class="score-value">${device.score || 0}</span>
|
||
<span class="score-label">SCORE</span>
|
||
</div>
|
||
<div class="score-breakdown">
|
||
<strong>Risk Level:</strong> ${device.classification === 'high_interest' ? 'HIGH INTEREST' : device.classification === 'review' ? 'NEEDS REVIEW' : 'INFORMATIONAL'}<br>
|
||
<strong>Recommended Action:</strong> ${device.recommended_action || 'Monitor'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="device-detail-section">
|
||
<h4>Device Profile</h4>
|
||
<div id="tscmTriageProfileSection" class="tscm-empty">Loading profile...</div>
|
||
</div>
|
||
|
||
<div class="device-detail-section" id="tscmTriageTimelineSection">
|
||
<h4>Timeline</h4>
|
||
<div class="tscm-empty">Loading timeline...</div>
|
||
</div>
|
||
|
||
${indicatorList ? `
|
||
<div class="device-detail-section">
|
||
<h4>Indicators</h4>
|
||
<div class="indicator-list">${indicatorList}</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="device-detail-section">
|
||
<h4>Case Actions</h4>
|
||
${cases.length === 0 ? `
|
||
<div class="tscm-empty">No cases available. Create a case to attach notes.</div>
|
||
<button class="preset-btn" onclick="tscmCreateCase()" style="margin-top: 8px; font-size: 10px;">+ New Case</button>
|
||
` : `
|
||
<div class="tscm-case-note-form">
|
||
<label>Case</label>
|
||
<select id="tscmTriageCaseSelect">${caseOptions}</select>
|
||
<label>Note Type</label>
|
||
<select id="tscmTriageNoteType">
|
||
<option value="general" selected>General</option>
|
||
<option value="observation">Observation</option>
|
||
<option value="action">Action</option>
|
||
<option value="follow_up">Follow-up</option>
|
||
</select>
|
||
<label>Case Note</label>
|
||
<textarea id="tscmTriageNote" rows="5">${escapeHtml(noteTemplate)}</textarea>
|
||
<div class="tscm-case-note-actions">
|
||
<button class="preset-btn" onclick="tscmAddTriageNote()" style="font-size: 10px;">Add Note</button>
|
||
<button class="preset-btn" onclick="tscmOpenSelectedCase()" style="font-size: 10px;">Open Case</button>
|
||
${tscmLastSweepId ? `<button class="preset-btn" onclick="tscmPromptLinkSweep(${tscmLastSweepId})" style="font-size: 10px;" title="Link the current sweep to a case">Link to Case</button>` : ''}
|
||
</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Disclaimer:</strong> This triage sheet surfaces indicators only. It does NOT confirm surveillance activity.
|
||
</div>
|
||
`;
|
||
|
||
loadTscmTimeline(identifier, protocol, 'tscmTriageTimelineSection');
|
||
tscmLoadTriageProfile(identifier);
|
||
}
|
||
|
||
async function tscmLoadTriageProfile(identifier) {
|
||
const section = document.getElementById('tscmTriageProfileSection');
|
||
if (!section || !identifier) return;
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/findings/device/${encodeURIComponent(identifier)}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success' || !data.profile) {
|
||
section.innerHTML = '<div class="tscm-empty">No profile available.</div>';
|
||
return;
|
||
}
|
||
|
||
const profile = data.profile;
|
||
section.innerHTML = `
|
||
<table class="device-detail-table">
|
||
<tr><td>Identifier</td><td>${escapeHtml(profile.identifier || '')}</td></tr>
|
||
<tr><td>Name</td><td>${escapeHtml(profile.name || 'N/A')}</td></tr>
|
||
<tr><td>Manufacturer</td><td>${escapeHtml(profile.manufacturer || 'N/A')}</td></tr>
|
||
<tr><td>Device Type</td><td>${escapeHtml(profile.device_type || 'N/A')}</td></tr>
|
||
${(profile.tracker_name || profile.tracker_type) ? `<tr><td>Tracker</td><td>${escapeHtml(profile.tracker_name || profile.tracker_type)}</td></tr>` : ''}
|
||
${profile.tracker_confidence ? `<tr><td>Tracker Confidence</td><td>${escapeHtml(profile.tracker_confidence)}</td></tr>` : ''}
|
||
<tr><td>First Seen</td><td>${profile.first_seen ? new Date(profile.first_seen).toLocaleString() : 'N/A'}</td></tr>
|
||
<tr><td>Last Seen</td><td>${profile.last_seen ? new Date(profile.last_seen).toLocaleString() : 'N/A'}</td></tr>
|
||
<tr><td>Detections</td><td>${profile.detection_count || 0}</td></tr>
|
||
<tr><td>Risk Level</td><td>${escapeHtml(profile.risk_level || 'informational')}</td></tr>
|
||
<tr><td>Score</td><td>${profile.total_score || 0}</td></tr>
|
||
<tr><td>Confidence</td><td>${profile.confidence !== undefined ? Math.round(profile.confidence * 100) + '%' : 'N/A'}</td></tr>
|
||
${profile.known_device ? `<tr><td>Known Device</td><td>${escapeHtml(profile.known_device_name || 'Yes')}</td></tr>` : ''}
|
||
</table>
|
||
${profile.indicators && profile.indicators.length > 0 ? `
|
||
<div style="margin-top: 12px;">
|
||
<h4>Profile Indicators</h4>
|
||
<div class="indicator-list">
|
||
${profile.indicators.map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.description || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load triage profile:', e);
|
||
section.innerHTML = '<div class="tscm-empty">Failed to load profile.</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmSubmitCaseNote(caseId, content, noteType) {
|
||
if (!caseId) return false;
|
||
if (!content) {
|
||
alert('Note content is required.');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/cases/${caseId}/notes`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
content: content,
|
||
note_type: noteType || 'general'
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
return true;
|
||
}
|
||
alert(data.message || 'Failed to add note');
|
||
return false;
|
||
} catch (e) {
|
||
console.error('Failed to add case note:', e);
|
||
alert('Failed to add note');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function tscmAddTriageNote() {
|
||
const caseSelect = document.getElementById('tscmTriageCaseSelect');
|
||
const noteInput = document.getElementById('tscmTriageNote');
|
||
const typeSelect = document.getElementById('tscmTriageNoteType');
|
||
|
||
if (!caseSelect || !noteInput || !typeSelect) return;
|
||
const caseId = caseSelect.value;
|
||
const content = noteInput.value.trim();
|
||
const noteType = typeSelect.value;
|
||
|
||
if (!caseId) {
|
||
alert('Select a case to attach this note.');
|
||
return;
|
||
}
|
||
|
||
const ok = await tscmSubmitCaseNote(caseId, content, noteType);
|
||
if (ok) {
|
||
noteInput.value = '';
|
||
alert('Note added to case.');
|
||
}
|
||
}
|
||
|
||
function tscmOpenSelectedCase() {
|
||
const caseSelect = document.getElementById('tscmTriageCaseSelect');
|
||
if (!caseSelect || !caseSelect.value) {
|
||
alert('Select a case to open.');
|
||
return;
|
||
}
|
||
tscmViewCase(caseSelect.value);
|
||
}
|
||
|
||
function formatTscmTimestamp(ts) {
|
||
if (!ts) return 'N/A';
|
||
try {
|
||
return new Date(ts).toLocaleString();
|
||
} catch (e) {
|
||
return ts;
|
||
}
|
||
}
|
||
|
||
async function loadTscmTimeline(identifier, protocol, targetId = 'tscmTimelineSection') {
|
||
const section = document.getElementById(targetId);
|
||
const normalizedIdentifier = tscmNormalizeIdentifier(identifier, protocol);
|
||
if (!section || !normalizedIdentifier) return;
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/device/${encodeURIComponent(normalizedIdentifier)}/timeline?protocol=${encodeURIComponent(protocol)}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success' || !data.timeline) {
|
||
section.innerHTML = `
|
||
<h4>Timeline</h4>
|
||
<div class="tscm-empty">No timeline data available.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const timeline = data.timeline;
|
||
const metrics = timeline.metrics || {};
|
||
const signal = timeline.signal || {};
|
||
const movement = timeline.movement || {};
|
||
const meeting = timeline.meeting_correlation || {};
|
||
const observations = timeline.observations || [];
|
||
const recent = observations.slice(-5);
|
||
|
||
const metricsRows = [];
|
||
if (metrics.first_seen) metricsRows.push(`<tr><td>First Seen</td><td>${formatTscmTimestamp(metrics.first_seen)}</td></tr>`);
|
||
if (metrics.last_seen) metricsRows.push(`<tr><td>Last Seen</td><td>${formatTscmTimestamp(metrics.last_seen)}</td></tr>`);
|
||
if (metrics.total_observations !== undefined) metricsRows.push(`<tr><td>Observations</td><td>${metrics.total_observations}</td></tr>`);
|
||
if (metrics.presence_ratio !== undefined) metricsRows.push(`<tr><td>Presence Ratio</td><td>${Math.round(metrics.presence_ratio * 100)}%</td></tr>`);
|
||
|
||
const signalRows = [];
|
||
if (signal.rssi_min !== undefined && signal.rssi_min !== null) signalRows.push(`<tr><td>RSSI Min</td><td>${signal.rssi_min} dBm</td></tr>`);
|
||
if (signal.rssi_max !== undefined && signal.rssi_max !== null) signalRows.push(`<tr><td>RSSI Max</td><td>${signal.rssi_max} dBm</td></tr>`);
|
||
if (signal.rssi_mean !== undefined && signal.rssi_mean !== null) signalRows.push(`<tr><td>RSSI Mean</td><td>${signal.rssi_mean} dBm</td></tr>`);
|
||
if (signal.stability !== undefined && signal.stability !== null) signalRows.push(`<tr><td>Stability</td><td>${Math.round(signal.stability * 100)}%</td></tr>`);
|
||
|
||
const movementRows = [];
|
||
if (movement.pattern) movementRows.push(`<tr><td>Movement</td><td>${escapeHtml(movement.pattern)}</td></tr>`);
|
||
if (movement.appears_stationary !== undefined) {
|
||
movementRows.push(`<tr><td>Stationary</td><td>${movement.appears_stationary ? 'Yes' : 'No'}</td></tr>`);
|
||
}
|
||
if (meeting.correlated !== undefined) {
|
||
movementRows.push(`<tr><td>Meeting Correlated</td><td>${meeting.correlated ? 'Yes' : 'No'}</td></tr>`);
|
||
}
|
||
|
||
let observationsHtml = '';
|
||
if (recent.length > 0) {
|
||
observationsHtml = `
|
||
<div style="margin-top: 12px;">
|
||
<h4>Recent Observations</h4>
|
||
<table class="device-detail-table">
|
||
${recent.map(o => `
|
||
<tr>
|
||
<td>${formatTscmTimestamp(o.timestamp)}</td>
|
||
<td>${o.rssi !== null && o.rssi !== undefined ? `${o.rssi} dBm` : '--'}</td>
|
||
<td>${o.channel || o.frequency || '--'}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const noMetrics = metricsRows.length === 0 && signalRows.length === 0 && movementRows.length === 0;
|
||
|
||
section.innerHTML = `
|
||
<h4>Timeline</h4>
|
||
${noMetrics ? '<div class="tscm-empty">No timeline metrics available yet.</div>' : `
|
||
<table class="device-detail-table">
|
||
${metricsRows.join('')}
|
||
${signalRows.join('')}
|
||
${movementRows.join('')}
|
||
</table>
|
||
`}
|
||
${observationsHtml}
|
||
`;
|
||
} catch (e) {
|
||
section.innerHTML = `
|
||
<h4>Timeline</h4>
|
||
<div class="tscm-empty">Failed to load timeline data.</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async function loadDeviceTimelines() {
|
||
const container = document.getElementById('tscmDeviceTimelinesList');
|
||
if (!container) return;
|
||
container.innerHTML = '<div class="tscm-empty">Loading timelines...</div>';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/timelines');
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success' || !data.timelines || data.timelines.length === 0) {
|
||
container.innerHTML = '<div class="tscm-empty">No device timelines available</div>';
|
||
return;
|
||
}
|
||
|
||
const timelines = data.timelines;
|
||
let html = '';
|
||
timelines.forEach(tl => {
|
||
const identifier = tl.identifier || 'Unknown';
|
||
const protocol = tl.protocol || 'unknown';
|
||
const presencePct = tl.presence_ratio !== undefined ? Math.round(tl.presence_ratio * 100) : 0;
|
||
const pattern = tl.movement_pattern || 'UNKNOWN';
|
||
const patternColors = { 'STATIONARY': '#00e676', 'MOBILE': '#ff3366', 'INTERMITTENT': '#ff9800' };
|
||
const pColor = patternColors[pattern] || '#9e9e9e';
|
||
|
||
// Create a compact swim-lane row
|
||
html += `
|
||
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; font-size: 10px;"
|
||
onclick="tscmShowInvestigateById('${escapeHtml(identifier)}', '${escapeHtml(protocol)}')">
|
||
<span style="width: 12px; text-transform: uppercase; color: var(--text-muted); font-size: 8px;">${protocol.charAt(0).toUpperCase()}</span>
|
||
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); font-family: var(--font-mono);">${escapeHtml(identifier)}</span>
|
||
<div style="width: 100px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden;">
|
||
<div style="width: ${presencePct}%; height: 100%; background: var(--accent-cyan); border-radius: 3px;"></div>
|
||
</div>
|
||
<span style="width: 35px; text-align: right; color: var(--accent-cyan);">${presencePct}%</span>
|
||
<span style="padding: 1px 6px; background: ${pColor}22; color: ${pColor}; border-radius: 3px; font-size: 8px; font-weight: bold;">${pattern}</span>
|
||
</div>
|
||
`;
|
||
});
|
||
container.innerHTML = html;
|
||
} catch (e) {
|
||
console.error('Failed to load device timelines:', e);
|
||
container.innerHTML = '<div class="tscm-empty">Failed to load timelines</div>';
|
||
}
|
||
}
|
||
|
||
let deviceTimelineChartInstance = null;
|
||
|
||
async function fetchDeviceTimelineChart(identifier, protocol) {
|
||
try {
|
||
const response = await fetch(`/tscm/device/${encodeURIComponent(identifier)}/timeline?protocol=${encodeURIComponent(protocol)}&since_hours=24`);
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success' || !data.timeline) return;
|
||
|
||
const timeline = data.timeline;
|
||
const observations = timeline.observations || [];
|
||
const metrics = timeline.metrics || {};
|
||
const signal = timeline.signal || {};
|
||
const movement = timeline.movement || {};
|
||
|
||
// Render Chart.js RSSI timeline
|
||
const canvas = document.getElementById('deviceTimelineChart');
|
||
if (canvas && typeof Chart !== 'undefined' && observations.length > 0) {
|
||
if (deviceTimelineChartInstance) {
|
||
deviceTimelineChartInstance.destroy();
|
||
}
|
||
|
||
const chartData = observations.map(o => ({
|
||
x: new Date(o.timestamp),
|
||
y: o.rssi !== null && o.rssi !== undefined ? o.rssi : null,
|
||
})).filter(d => d.y !== null);
|
||
|
||
const pointColors = chartData.map(d => d.y !== null ? 'rgba(0, 230, 118, 0.8)' : 'rgba(158, 158, 158, 0.5)');
|
||
|
||
deviceTimelineChartInstance = new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
datasets: [{
|
||
label: 'RSSI (dBm)',
|
||
data: chartData,
|
||
borderColor: 'rgba(0, 212, 255, 0.8)',
|
||
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
||
fill: true,
|
||
pointBackgroundColor: pointColors,
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
tension: 0.3,
|
||
borderWidth: 1.5,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'time',
|
||
time: { unit: 'hour', displayFormats: { hour: 'ha', minute: 'h:mm a' } },
|
||
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
|
||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||
},
|
||
y: {
|
||
title: { display: true, text: 'dBm', color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
|
||
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
|
||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Render metrics badges
|
||
renderTimelineMetrics(metrics, signal, movement);
|
||
} catch (e) {
|
||
console.error('Failed to load device timeline chart:', e);
|
||
}
|
||
}
|
||
|
||
function renderTimelineMetrics(metrics, signal, movement) {
|
||
const container = document.getElementById('deviceTimelineMetrics');
|
||
if (!container) return;
|
||
|
||
const badges = [];
|
||
if (metrics.total_observations !== undefined) {
|
||
badges.push(`<span style="padding: 4px 8px; background: rgba(0,212,255,0.15); color: var(--accent-cyan); border-radius: 4px; font-size: 10px;">${metrics.total_observations} observations</span>`);
|
||
}
|
||
if (metrics.presence_ratio !== undefined) {
|
||
const pct = Math.round(metrics.presence_ratio * 100);
|
||
badges.push(`<span style="padding: 4px 8px; background: rgba(0,230,118,0.15); color: #00e676; border-radius: 4px; font-size: 10px;">${pct}% presence</span>`);
|
||
}
|
||
if (signal.rssi_min !== undefined && signal.rssi_max !== undefined && signal.rssi_min !== null) {
|
||
badges.push(`<span style="padding: 4px 8px; background: rgba(255,152,0,0.15); color: #ff9800; border-radius: 4px; font-size: 10px;">${signal.rssi_min} to ${signal.rssi_max} dBm</span>`);
|
||
}
|
||
if (signal.stability !== undefined && signal.stability !== null) {
|
||
badges.push(`<span style="padding: 4px 8px; background: rgba(156,39,176,0.15); color: #ce93d8; border-radius: 4px; font-size: 10px;">${Math.round(signal.stability * 100)}% stability</span>`);
|
||
}
|
||
if (movement.pattern) {
|
||
const patternColors = { 'STATIONARY': '#00e676', 'MOBILE': '#ff3366', 'INTERMITTENT': '#ff9800' };
|
||
const color = patternColors[movement.pattern] || '#9e9e9e';
|
||
badges.push(`<span style="padding: 4px 8px; background: ${color}22; color: ${color}; border-radius: 4px; font-size: 10px; font-weight: bold;">${movement.pattern}</span>`);
|
||
}
|
||
container.innerHTML = badges.join('');
|
||
}
|
||
|
||
async function loadTscmAdvancedAnalysis(device, protocol) {
|
||
if (protocol === 'wifi') {
|
||
const section = document.getElementById('tscmWifiAdvancedSection');
|
||
if (!section) return;
|
||
|
||
if (device && device.is_client) {
|
||
section.innerHTML = `
|
||
<h4>WiFi Advanced Indicators</h4>
|
||
<div class="tscm-empty">Client devices do not have AP indicators.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
bssid: device.bssid,
|
||
ssid: device.ssid || device.essid,
|
||
channel: device.channel,
|
||
encryption: device.security,
|
||
power: device.signal,
|
||
signal: device.signal,
|
||
};
|
||
|
||
const response = await fetch('/tscm/wifi/analyze-network', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success') {
|
||
section.innerHTML = `
|
||
<h4>WiFi Advanced Indicators</h4>
|
||
<div class="tscm-empty">No advanced indicators available.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const indicators = data.indicators || [];
|
||
if (indicators.length === 0) {
|
||
section.innerHTML = `
|
||
<h4>WiFi Advanced Indicators</h4>
|
||
<div class="tscm-empty">No advanced indicators detected.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
section.innerHTML = `
|
||
<h4>WiFi Advanced Indicators</h4>
|
||
<div class="indicator-list">
|
||
${indicators.map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${escapeHtml((i.type || 'indicator') + (i.severity ? ` • ${i.severity}` : ''))}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.description || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
section.innerHTML = `
|
||
<h4>WiFi Advanced Indicators</h4>
|
||
<div class="tscm-empty">Failed to analyze network.</div>
|
||
`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (protocol === 'bluetooth') {
|
||
const section = document.getElementById('tscmBleExplainSection');
|
||
if (!section) return;
|
||
const mac = device.mac || device.address;
|
||
if (!mac) {
|
||
section.innerHTML = `
|
||
<h4>Bluetooth Risk Explanation</h4>
|
||
<div class="tscm-empty">No identifier available for explanation.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/bluetooth/${encodeURIComponent(mac)}/explain`);
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success' || !data.explanation) {
|
||
section.innerHTML = `
|
||
<h4>Bluetooth Risk Explanation</h4>
|
||
<div class="tscm-empty">No explanation available.</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const exp = data.explanation;
|
||
const risk = exp.risk || {};
|
||
let proximity = exp.proximity || {};
|
||
let proximityNote = proximity.explanation || '';
|
||
let proximityDistance = proximity.estimated_distance || '';
|
||
let proximityRssi = null;
|
||
let proximityDisclaimer = '';
|
||
const tracker = exp.tracker || {};
|
||
const meeting = exp.meeting_correlation || {};
|
||
const action = exp.recommended_action || {};
|
||
const indicators = exp.indicators || [];
|
||
|
||
try {
|
||
const proxResponse = await fetch(`/tscm/bluetooth/${encodeURIComponent(mac)}/proximity`);
|
||
const proxData = await proxResponse.json();
|
||
if (proxData.status === 'success' && proxData.proximity) {
|
||
proximity = proxData.proximity;
|
||
proximityNote = proxData.proximity.explanation || proximityNote;
|
||
proximityDistance = proxData.proximity.estimated_distance || proximityDistance;
|
||
proximityRssi = proxData.proximity.rssi_used;
|
||
proximityDisclaimer = proxData.disclaimer || '';
|
||
}
|
||
} catch (e) {
|
||
console.warn('BLE proximity lookup failed:', e);
|
||
}
|
||
|
||
section.innerHTML = `
|
||
<h4>Bluetooth Risk Explanation</h4>
|
||
<table class="device-detail-table">
|
||
<tr><td>Risk Level</td><td>${escapeHtml(risk.level || 'unknown').toUpperCase()} (${risk.score || 0})</td></tr>
|
||
<tr><td>Risk Rationale</td><td>${escapeHtml(risk.explanation || 'N/A')}</td></tr>
|
||
<tr><td>Proximity</td><td>${escapeHtml(proximity.estimate || 'unknown')} ${proximityDistance ? `(${escapeHtml(proximityDistance)})` : ''}${proximityRssi !== null ? ` — RSSI ${proximityRssi} dBm` : ''}</td></tr>
|
||
<tr><td>Proximity Note</td><td>${escapeHtml(proximityNote || 'N/A')}</td></tr>
|
||
<tr><td>Tracker</td><td>${tracker.is_tracker ? `Yes (${escapeHtml(tracker.type || 'unknown')})` : 'No'}</td></tr>
|
||
<tr><td>Meeting Correlated</td><td>${meeting.correlated ? 'Yes' : 'No'}</td></tr>
|
||
<tr><td>Recommended Action</td><td>${escapeHtml(action.action || 'monitor')} — ${escapeHtml(action.rationale || '')}</td></tr>
|
||
</table>
|
||
${indicators.length > 0 ? `
|
||
<div style="margin-top: 12px;">
|
||
<h4>Indicators</h4>
|
||
<div class="indicator-list">
|
||
${indicators.map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.description || i.explanation || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${proximityDisclaimer ? `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Note:</strong> ${escapeHtml(proximityDisclaimer)}
|
||
</div>
|
||
` : ''}
|
||
${exp.disclaimer ? `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Note:</strong> ${escapeHtml(exp.disclaimer)}
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
} catch (e) {
|
||
section.innerHTML = `
|
||
<h4>Bluetooth Risk Explanation</h4>
|
||
<div class="tscm-empty">Failed to load explanation.</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeTscmDeviceModal() {
|
||
document.getElementById('tscmDeviceModal').style.display = 'none';
|
||
if (tscmCaseLinkContext) tscmCaseLinkContext = null;
|
||
}
|
||
|
||
function listenToRfSignal(frequency, modulation) {
|
||
// Close the modal
|
||
closeTscmDeviceModal();
|
||
|
||
// Switch to spectrum waterfall mode
|
||
switchMode('waterfall');
|
||
|
||
// Wait a moment for the mode to switch, then tune to the frequency
|
||
setTimeout(() => {
|
||
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||
Waterfall.quickTune(frequency, modulation);
|
||
} else {
|
||
// Fallback: update Waterfall center control directly
|
||
const freqInput = document.getElementById('wfCenterFreq');
|
||
if (freqInput) {
|
||
freqInput.value = frequency.toFixed(4);
|
||
}
|
||
alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`);
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
async function showDevicesByCategory(category) {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
let devices = [];
|
||
let title = '';
|
||
let titleClass = '';
|
||
|
||
if (category === 'correlations') {
|
||
// Show correlations
|
||
title = 'Cross-Protocol Correlations';
|
||
titleClass = 'classification-yellow';
|
||
|
||
if (tscmCorrelations.length === 0) {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="text-align: center; color: var(--text-muted);">No correlations detected yet.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title} (${tscmCorrelations.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
${tscmCorrelations.map(c => `
|
||
<div class="correlation-detail-item">
|
||
<strong>${escapeHtml(c.description || 'Cross-protocol match')}</strong>
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
|
||
Protocols: ${(c.protocols || []).join(', ')}<br>
|
||
Devices: ${(c.devices || []).join(', ')}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
modal.style.display = 'flex';
|
||
return;
|
||
}
|
||
|
||
if (category === 'identity') {
|
||
title = 'Identity Clusters (MAC-Randomization Resistant)';
|
||
titleClass = 'classification-cyan';
|
||
|
||
if (tscmIdentityClusters.length === 0) {
|
||
await tscmRefreshIdentityClusters();
|
||
}
|
||
|
||
if (tscmIdentityClusters.length === 0) {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="text-align: center; color: var(--text-muted);">No identity clusters detected yet.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title} (${tscmIdentityClusters.length})</h3>
|
||
<button class="preset-btn" onclick="tscmRefreshIdentityClusters().then(() => showDevicesByCategory('identity'))" style="font-size: 10px; padding: 6px 8px;">
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
${tscmIdentityClusters.map(c => `
|
||
<div class="correlation-item">
|
||
<strong>${escapeHtml(c.best_name || c.manufacturer_name || c.cluster_id)}</strong>
|
||
<div class="correlation-devices">
|
||
Risk: ${escapeHtml(c.risk_level || 'informational')} (${c.risk_score || 0}) |
|
||
MACs: ${(c.linked_macs || []).length} |
|
||
Observations: ${c.total_observations || 0} |
|
||
Confidence: ${c.confidence !== undefined ? (c.confidence * 100).toFixed(0) + '%' : 'n/a'}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Note:</strong> Identity clustering is probabilistic. It links observations by passive fingerprints and timing patterns.
|
||
</div>
|
||
`;
|
||
}
|
||
modal.style.display = 'flex';
|
||
return;
|
||
}
|
||
|
||
// Filter devices by classification
|
||
const filteredForCategory = getFilteredDevices({ ignoreRisk: true });
|
||
const allDevices = [
|
||
...filteredForCategory.wifi.map(d => ({ ...d, protocol: 'wifi', id: d.bssid })),
|
||
...filteredForCategory.bt.map(d => ({ ...d, protocol: 'bluetooth', id: d.mac })),
|
||
...filteredForCategory.rf.map(d => ({ ...d, protocol: 'rf', id: d.frequency }))
|
||
];
|
||
|
||
if (category === 'high_interest') {
|
||
devices = allDevices.filter(d => d.classification === 'high_interest');
|
||
title = 'High Interest Devices';
|
||
titleClass = 'classification-red';
|
||
} else if (category === 'review') {
|
||
devices = allDevices.filter(d => d.classification === 'review');
|
||
title = 'Devices Needing Review';
|
||
titleClass = 'classification-yellow';
|
||
} else if (category === 'informational') {
|
||
devices = allDevices.filter(d => d.classification === 'informational');
|
||
title = 'Informational Devices';
|
||
titleClass = 'classification-green';
|
||
}
|
||
|
||
// Sort by score descending
|
||
devices.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
|
||
if (devices.length === 0) {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="text-align: center; color: var(--text-muted);">No devices in this category.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header ${titleClass}">
|
||
<h3>${title} (${devices.length})</h3>
|
||
</div>
|
||
<div class="category-device-list">
|
||
${devices.map(d => `
|
||
<div class="category-device-item" onclick="event.stopPropagation(); showDeviceDetails('${d.id}', '${d.protocol}')">
|
||
<div class="category-device-header">
|
||
<span class="category-device-name">
|
||
${getClassificationIcon(d.classification)}
|
||
${escapeHtml(d.name || d.ssid || d.mac || d.bssid || (d.frequency ? d.frequency.toFixed(3) + ' MHz' : 'Unknown'))}
|
||
</span>
|
||
<span class="category-device-score">${d.score || 0}</span>
|
||
</div>
|
||
<div class="category-device-meta">
|
||
<span class="protocol-badge">${d.protocol}</span>
|
||
${d.indicators ? d.indicators.slice(0, 2).map(i => `<span class="indicator-mini">${i.type}</span>`).join('') : ''}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function showBaselineComparison() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
if (!tscmBaselineComparison) {
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-orange">
|
||
<h3>Baseline Comparison</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="text-align: center; color: var(--text-muted);">No baseline comparison data available.</p>
|
||
</div>
|
||
`;
|
||
modal.style.display = 'flex';
|
||
return;
|
||
}
|
||
|
||
const comparison = tscmBaselineComparison;
|
||
const baselineName = comparison.baseline_name || 'Baseline';
|
||
|
||
const formatItem = (item, protocol) => {
|
||
if (protocol === 'wifi') {
|
||
const name = item.essid || item.ssid || 'Hidden SSID';
|
||
const id = item.bssid || item.mac || '';
|
||
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
||
}
|
||
if (protocol === 'wifi_clients') {
|
||
const name = item.vendor || 'WiFi Client';
|
||
const id = item.mac || item.address || '';
|
||
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
||
}
|
||
if (protocol === 'bluetooth') {
|
||
const name = item.name || 'Unknown';
|
||
const id = item.mac || item.address || '';
|
||
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
||
}
|
||
if (protocol === 'rf') {
|
||
const freq = item.frequency ? `${item.frequency} MHz` : 'Unknown Frequency';
|
||
const band = item.band || '';
|
||
return `${escapeHtml(freq)} ${band ? `<span class="device-detail-id">${escapeHtml(band)}</span>` : ''}`;
|
||
}
|
||
return escapeHtml(item.name || item.identifier || 'Unknown');
|
||
};
|
||
|
||
const renderList = (items, protocol, limit = 10) => {
|
||
if (!items || items.length === 0) {
|
||
return '<div class="tscm-empty">None</div>';
|
||
}
|
||
const listItems = items.slice(0, limit).map(i => `<li>${formatItem(i, protocol)}</li>`).join('');
|
||
const more = items.length > limit
|
||
? `<div class="tscm-more-hint">+${items.length - limit} more</div>`
|
||
: '';
|
||
return `<ul class="device-reasons-list">${listItems}</ul>${more}`;
|
||
};
|
||
|
||
const sections = [
|
||
{ key: 'wifi', label: 'WiFi' },
|
||
{ key: 'wifi_clients', label: 'WiFi Clients' },
|
||
{ key: 'bluetooth', label: 'Bluetooth' },
|
||
{ key: 'rf', label: 'RF' },
|
||
];
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-orange">
|
||
<h3>Baseline Comparison — ${escapeHtml(baselineName)}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">
|
||
New: ${comparison.total_new || 0} | Missing: ${comparison.total_missing || 0}
|
||
</div>
|
||
${sections.map(section => {
|
||
const data = comparison[section.key] || {};
|
||
return `
|
||
<div style="margin-bottom: 16px;">
|
||
<h4>${section.label}</h4>
|
||
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 6px;">
|
||
New: ${data.new_count || 0} | Missing: ${data.missing_count || 0}
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||
<div>
|
||
<strong style="font-size: 10px; color: var(--text-secondary);">New</strong>
|
||
${renderList(data.new || [], section.key)}
|
||
</div>
|
||
<div>
|
||
<strong style="font-size: 10px; color: var(--text-secondary);">Missing</strong>
|
||
${renderList(data.missing || [], section.key)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Note:</strong> Baseline comparisons indicate environmental changes, not confirmed threats. Validate before action.
|
||
</div>
|
||
`;
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function updateTscmDisplays() {
|
||
const filtered = getFilteredDevices();
|
||
const filtersActive = tscmFilters.protocol !== 'all' || tscmFilters.risk !== 'all' ||
|
||
tscmFilters.status !== 'all' || tscmFilters.known !== 'all';
|
||
// Update WiFi list
|
||
const wifiList = document.getElementById('tscmWifiList');
|
||
if (filtered.wifi.length === 0) {
|
||
wifiList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No WiFi networks match filters' : 'No WiFi networks detected'}</div>`;
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...filtered.wifi].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
wifiList.innerHTML = sorted.map(d => `
|
||
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.bssid}', 'wifi')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
|
||
${escapeHtml(d.ssid || d.bssid || 'Hidden')}
|
||
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
||
</div>
|
||
${getScoreBadge(d.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${d.bssid}</span>
|
||
<span>${d.signal || '--'} dBm</span>
|
||
<span>${escapeHtml(d.vendor || 'Unknown')} • ${escapeHtml(d.security || 'Open')}</span>
|
||
</div>
|
||
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
||
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmWifiCount').textContent = filtered.wifi.length;
|
||
|
||
// Update WiFi clients list
|
||
const wifiClientList = document.getElementById('tscmWifiClientList');
|
||
if (filtered.wifi_clients.length === 0) {
|
||
wifiClientList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No WiFi clients match filters' : 'No WiFi clients detected'}</div>`;
|
||
} else {
|
||
const sortedClients = [...filtered.wifi_clients].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
wifiClientList.innerHTML = sortedClients.map(c => `
|
||
<div class="tscm-device-item ${getClassificationClass(c.classification)}" onclick="showDeviceDetails('${c.mac}', 'wifi')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(c.classification)}</span>
|
||
${escapeHtml(c.vendor || 'WiFi Client')}
|
||
<span class="client-badge" title="WiFi client">CLIENT</span>
|
||
${c.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
||
</div>
|
||
${getScoreBadge(c.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${c.mac}</span>
|
||
<span>${c.rssi || '--'} dBm</span>
|
||
<span>${c.associated_bssid ? `Assoc: ${c.associated_bssid}` : `Probes: ${c.probe_count || 0}`}</span>
|
||
</div>
|
||
${c.indicators && c.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(c.indicators)}</div>` : ''}
|
||
${c.recommended_action && c.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${c.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmWifiClientCount').textContent = filtered.wifi_clients.length;
|
||
|
||
// Update BT list
|
||
const btList = document.getElementById('tscmBtList');
|
||
if (filtered.bt.length === 0) {
|
||
btList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No Bluetooth devices match filters' : 'No Bluetooth devices detected'}</div>`;
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...filtered.bt].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
btList.innerHTML = sorted.map(d => `
|
||
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.mac}', 'bluetooth')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
|
||
${escapeHtml(d.name || 'Unknown')}
|
||
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">AUDIO</span>' : ''}
|
||
${formatTrackerBadge(d)}
|
||
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
||
</div>
|
||
${getScoreBadge(d.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${d.mac}</span>
|
||
<span>${d.rssi || '--'} dBm</span>
|
||
<span>${escapeHtml([d.device_type, d.manufacturer].filter(Boolean).join(' • ') || 'Unknown')}</span>
|
||
</div>
|
||
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
||
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmBtCount').textContent = filtered.bt.length;
|
||
|
||
// Update RF list
|
||
const rfList = document.getElementById('tscmRfList');
|
||
if (filtered.rf.length === 0) {
|
||
if (tscmRfStatusMessage) {
|
||
rfList.innerHTML = `<div class="tscm-status-message">${escapeHtml(tscmRfStatusMessage)}</div>`;
|
||
} else {
|
||
rfList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No RF signals match filters' : 'No RF signals detected'}</div>`;
|
||
}
|
||
} else {
|
||
// Sort by score (highest first)
|
||
const sorted = [...filtered.rf].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
rfList.innerHTML = sorted.map(s => `
|
||
<div class="tscm-device-item ${getClassificationClass(s.classification)}" onclick="showDeviceDetails('${s.frequency}', 'rf')">
|
||
<div class="tscm-device-header">
|
||
<div class="tscm-device-name">
|
||
<span class="classification-indicator">${getClassificationIcon(s.classification)}</span>
|
||
${s.frequency.toFixed(3)} MHz
|
||
${s.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
||
</div>
|
||
${getScoreBadge(s.score)}
|
||
</div>
|
||
<div class="tscm-device-meta">
|
||
<span>${s.band}</span>
|
||
<span>${s.power.toFixed(1)} dBm</span>
|
||
<span>+${(s.signal_strength || 0).toFixed(1)} dB above noise</span>
|
||
</div>
|
||
${s.indicators && s.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(s.indicators)}</div>` : ''}
|
||
${s.recommended_action && s.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${s.recommended_action}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
document.getElementById('tscmRfCount').textContent = filtered.rf.length;
|
||
|
||
// Update threats list
|
||
const threatList = document.getElementById('tscmThreatList');
|
||
let threatItems = tscmThreats;
|
||
if (tscmFilters.protocol !== 'all') {
|
||
threatItems = threatItems.filter(t => t.source === tscmFilters.protocol);
|
||
}
|
||
if (threatItems.length === 0) {
|
||
threatList.innerHTML = '<div class="tscm-empty"><div class="tscm-empty-primary">Monitoring active — nothing flagged</div><div class="tscm-empty-secondary">Signals are being analyzed against baseline thresholds. This does not rule out passive or dormant devices.</div></div>';
|
||
} else {
|
||
threatList.innerHTML = '<div class="tscm-threat-list">' + threatItems.map(t => `
|
||
<div class="tscm-threat-item ${t.severity}" onclick="showDeviceDetails('${escapeHtml(t.identifier)}', '${escapeHtml(t.source)}')" style="cursor: pointer;">
|
||
<div class="tscm-threat-header">
|
||
<span class="tscm-threat-type">${escapeHtml(t.threat_type || 'Unknown')}</span>
|
||
<span class="tscm-threat-severity">${t.severity}</span>
|
||
${t.threat_id ? `
|
||
<button class="tscm-case-link-btn" onclick="event.stopPropagation(); tscmPromptLinkThreat(${t.threat_id})">
|
||
Link
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
<div class="tscm-threat-details">
|
||
<strong>${escapeHtml(t.name || t.identifier)}</strong><br>
|
||
Source: ${t.source} | Signal: ${t.signal_strength || '--'} dBm
|
||
</div>
|
||
</div>
|
||
`).join('') + '</div>';
|
||
}
|
||
}
|
||
|
||
function updateCorrelationsDisplay() {
|
||
const container = document.getElementById('tscmCorrelationsContainer');
|
||
if (!container) return;
|
||
|
||
const hasCorrelations = tscmCorrelations.length > 0;
|
||
const hasIdentity = tscmIdentityClusters.length > 0;
|
||
|
||
if (!hasCorrelations && !hasIdentity) {
|
||
container.innerHTML = '';
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
container.style.display = 'block';
|
||
const sections = [];
|
||
|
||
if (hasCorrelations) {
|
||
sections.push(`
|
||
<div class="tscm-correlations">
|
||
<h4>Cross-Protocol Correlations (${tscmCorrelations.length})</h4>
|
||
${tscmCorrelations.map(c => `
|
||
<div class="correlation-item">
|
||
<strong>${escapeHtml(c.description)}</strong>
|
||
<div class="correlation-devices">
|
||
Devices: ${c.devices.join(', ')} | Protocols: ${c.protocols.join(', ')}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
if (hasIdentity) {
|
||
const sortedClusters = [...tscmIdentityClusters]
|
||
.sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0));
|
||
const topClusters = sortedClusters.slice(0, 10);
|
||
const summaryText = tscmIdentitySummary
|
||
? `High: ${tscmIdentitySummary.high || 0} | Medium: ${tscmIdentitySummary.medium || 0} | Total: ${tscmIdentitySummary.total || tscmIdentityClusters.length}`
|
||
: `Total: ${tscmIdentityClusters.length}`;
|
||
|
||
sections.push(`
|
||
<div class="tscm-correlations">
|
||
<h4>Identity Clusters (MAC-Randomization Resistant) — ${summaryText}</h4>
|
||
${topClusters.map(c => `
|
||
<div class="correlation-item">
|
||
<strong>${escapeHtml(c.best_name || c.manufacturer_name || c.cluster_id)}</strong>
|
||
<div class="correlation-devices">
|
||
Risk: ${escapeHtml(c.risk_level || 'informational')} (${c.risk_score || 0}) |
|
||
MACs: ${(c.linked_macs || []).length} |
|
||
Observations: ${c.total_observations || 0} |
|
||
Confidence: ${c.confidence !== undefined ? (c.confidence * 100).toFixed(0) + '%' : 'n/a'}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
${tscmIdentityClusters.length > topClusters.length ? `
|
||
<div class="tscm-more-hint">Showing top ${topClusters.length} clusters. Use the Identity Clusters card for full list.</div>
|
||
` : ''}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
container.innerHTML = sections.join('');
|
||
}
|
||
|
||
function completeTscmSweep(data) {
|
||
isTscmRunning = false;
|
||
if (tscmEventSource) {
|
||
tscmEventSource.close();
|
||
tscmEventSource = null;
|
||
}
|
||
|
||
document.getElementById('startTscmBtn').style.display = 'block';
|
||
document.getElementById('stopTscmBtn').style.display = 'none';
|
||
document.getElementById('tscmProgress').style.display = 'none';
|
||
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
|
||
document.getElementById('tscmProgressPercent').textContent = '100%';
|
||
document.getElementById('tscmProgressBar').style.width = '100%';
|
||
|
||
// Final update of counts
|
||
updateTscmThreatCounts();
|
||
|
||
// Display sweep summary with correlation results
|
||
const summaryContainer = document.getElementById('tscmSweepSummary');
|
||
if (summaryContainer && data) {
|
||
if (data.sweep_id) {
|
||
tscmLastSweepId = data.sweep_id;
|
||
}
|
||
const highInterest = data.high_interest_devices || 0;
|
||
const needsReview = data.needs_review_devices || 0;
|
||
const correlations = data.correlations_found || 0;
|
||
const identityClusters = data.identity_clusters || (tscmIdentitySummary ? tscmIdentitySummary.total : 0);
|
||
const baselineNew = data.baseline_new_devices || 0;
|
||
const baselineMissing = data.baseline_missing_devices || 0;
|
||
const wifiCount = data.wifi_count ?? tscmWifiDevices.length;
|
||
const wifiClientCount = data.wifi_client_count ?? tscmWifiClients.length;
|
||
const btCount = data.bt_count ?? tscmBtDevices.length;
|
||
const rfCount = data.rf_count ?? tscmRfSignals.length;
|
||
|
||
let assessment = 'BASELINE ENVIRONMENT';
|
||
let assessmentClass = 'informational';
|
||
if (highInterest > 0 || correlations > 0) {
|
||
assessment = 'ELEVATED CONCERN';
|
||
assessmentClass = 'high-interest';
|
||
} else if (needsReview > 3) {
|
||
assessment = 'MODERATE CONCERN';
|
||
assessmentClass = 'needs-review';
|
||
} else if (needsReview > 0) {
|
||
assessment = 'LOW CONCERN';
|
||
assessmentClass = 'needs-review';
|
||
}
|
||
|
||
summaryContainer.innerHTML = `
|
||
<div class="tscm-summary-box">
|
||
<div class="summary-stat high-interest">
|
||
<div class="count">${highInterest}</div>
|
||
<div class="label">High Interest</div>
|
||
</div>
|
||
<div class="summary-stat needs-review">
|
||
<div class="count">${needsReview}</div>
|
||
<div class="label">Needs Review</div>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<div class="count">${correlations}</div>
|
||
<div class="label">Correlations</div>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<div class="count">${identityClusters}</div>
|
||
<div class="label">Identity Clusters</div>
|
||
</div>
|
||
${(baselineNew || baselineMissing) ? `
|
||
<div class="summary-stat">
|
||
<div class="count">+${baselineNew} / -${baselineMissing}</div>
|
||
<div class="label">Baseline Delta</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="tscm-summary-meta" style="margin-top: 8px; font-size: 10px; color: var(--text-muted);">
|
||
Devices: ${wifiCount} WiFi AP • ${wifiClientCount} WiFi Clients • ${btCount} BT • ${rfCount} RF
|
||
</div>
|
||
<div class="tscm-assessment ${assessmentClass}">
|
||
<strong>Assessment:</strong> ${assessment}
|
||
</div>
|
||
${(baselineNew || baselineMissing) && tscmBaselineComparison ? `
|
||
<div style="margin-top: 8px;">
|
||
<button class="preset-btn" onclick="showBaselineComparison()" style="font-size: 10px;">
|
||
View Baseline Diff
|
||
</button>
|
||
</div>
|
||
` : ''}
|
||
${data.sweep_id ? `
|
||
<div style="margin-top: 8px;">
|
||
<button class="preset-btn" onclick="tscmPromptLinkSweep(${data.sweep_id})" style="font-size: 10px;">
|
||
Link Sweep to Case
|
||
</button>
|
||
</div>
|
||
` : ''}
|
||
<div class="tscm-disclaimer">
|
||
This screening identifies wireless/RF anomalies, NOT confirmed surveillance devices.
|
||
Findings require professional verification.
|
||
</div>
|
||
`;
|
||
summaryContainer.style.display = 'block';
|
||
}
|
||
|
||
// Update correlations display
|
||
updateCorrelationsDisplay();
|
||
}
|
||
|
||
async function tscmRecordBaseline() {
|
||
const name = document.getElementById('tscmBaselineName').value ||
|
||
`Baseline ${new Date().toLocaleString()}`;
|
||
|
||
try {
|
||
const response = await fetch('/tscm/baseline/record', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
isRecordingBaseline = true;
|
||
document.getElementById('tscmRecordBaselineBtn').style.display = 'none';
|
||
document.getElementById('tscmStopBaselineBtn').style.display = 'block';
|
||
document.getElementById('tscmBaselineStatus').textContent = 'Recording baseline...';
|
||
document.getElementById('tscmBaselineStatus').style.color = '#ff9933';
|
||
} else {
|
||
alert(data.message || 'Failed to start baseline recording');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to start baseline:', e);
|
||
alert('Failed to start baseline recording');
|
||
}
|
||
}
|
||
|
||
async function tscmStopBaseline() {
|
||
try {
|
||
const response = await fetch('/tscm/baseline/stop', { method: 'POST' });
|
||
const data = await response.json();
|
||
|
||
isRecordingBaseline = false;
|
||
document.getElementById('tscmRecordBaselineBtn').style.display = 'block';
|
||
document.getElementById('tscmStopBaselineBtn').style.display = 'none';
|
||
|
||
if (data.status === 'success') {
|
||
document.getElementById('tscmBaselineStatus').textContent =
|
||
`Baseline saved: ${data.wifi_count} WiFi, ${data.wifi_client_count || 0} Clients, ${data.bt_count} BT, ${data.rf_count} RF`;
|
||
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
|
||
loadTscmBaselines();
|
||
} else {
|
||
document.getElementById('tscmBaselineStatus').textContent = data.message || 'Recording stopped';
|
||
document.getElementById('tscmBaselineStatus').style.color = 'var(--text-muted)';
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to stop baseline:', e);
|
||
document.getElementById('tscmBaselineStatus').textContent = 'Error stopping baseline';
|
||
}
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ========== TSCM Advanced Features ==========
|
||
|
||
// Meeting Window Management
|
||
let tscmActiveMeetingId = null;
|
||
let tscmMeetingStartTime = null;
|
||
|
||
async function tscmStartMeeting() {
|
||
const meetingName = document.getElementById('tscmMeetingName').value ||
|
||
`Meeting ${new Date().toLocaleString()}`;
|
||
|
||
try {
|
||
const response = await fetch('/tscm/meeting/start-tracked', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: meetingName })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmActiveMeetingId = data.meeting_id;
|
||
tscmMeetingStartTime = new Date();
|
||
tscmLastMeetingId = null;
|
||
|
||
// Update UI
|
||
document.getElementById('tscmStartMeetingBtn').style.display = 'none';
|
||
document.getElementById('tscmEndMeetingBtn').style.display = 'block';
|
||
document.getElementById('tscmMeetingStatus').innerHTML =
|
||
`<span style="color: #ff9933;">Meeting active: ${escapeHtml(meetingName)}</span>`;
|
||
const summaryBtn = document.getElementById('tscmMeetingSummaryBtn');
|
||
if (summaryBtn) summaryBtn.style.display = 'block';
|
||
|
||
// Show meeting banner
|
||
const banner = document.getElementById('tscmMeetingBanner');
|
||
if (banner) {
|
||
banner.style.display = 'flex';
|
||
const nameSpan = document.getElementById('tscmMeetingBannerName');
|
||
if (nameSpan) nameSpan.textContent = meetingName;
|
||
const timeSpan = document.getElementById('tscmMeetingBannerTime');
|
||
if (timeSpan) timeSpan.textContent = `Started ${new Date().toLocaleTimeString()}`;
|
||
}
|
||
} else {
|
||
alert(data.message || 'Failed to start meeting window');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to start meeting:', e);
|
||
alert('Failed to start meeting window');
|
||
}
|
||
}
|
||
|
||
async function tscmEndMeeting() {
|
||
if (!tscmActiveMeetingId) return;
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/meeting/${tscmActiveMeetingId}/end`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
// Update UI
|
||
document.getElementById('tscmStartMeetingBtn').style.display = 'block';
|
||
document.getElementById('tscmEndMeetingBtn').style.display = 'none';
|
||
|
||
// Hide meeting banner
|
||
const banner = document.getElementById('tscmMeetingBanner');
|
||
if (banner) banner.style.display = 'none';
|
||
|
||
if (data.status === 'success') {
|
||
const duration = tscmMeetingStartTime ?
|
||
Math.round((new Date() - tscmMeetingStartTime) / 60000) : 0;
|
||
document.getElementById('tscmMeetingStatus').innerHTML =
|
||
`<span style="color: #00ff88;">Meeting ended (${duration} min) - ${data.devices_flagged || 0} devices flagged</span>`;
|
||
|
||
// Show export section if devices were flagged
|
||
if (data.devices_flagged > 0) {
|
||
document.getElementById('tscmExportSection').style.display = 'block';
|
||
}
|
||
} else {
|
||
document.getElementById('tscmMeetingStatus').textContent = 'Meeting ended';
|
||
}
|
||
|
||
tscmLastMeetingId = tscmActiveMeetingId;
|
||
tscmActiveMeetingId = null;
|
||
tscmMeetingStartTime = null;
|
||
|
||
const summaryBtn = document.getElementById('tscmMeetingSummaryBtn');
|
||
if (summaryBtn) summaryBtn.style.display = tscmLastMeetingId ? 'block' : 'none';
|
||
} catch (e) {
|
||
console.error('Failed to end meeting:', e);
|
||
}
|
||
}
|
||
|
||
async function tscmShowMeetingSummary(meetingId) {
|
||
let id = meetingId || tscmActiveMeetingId || tscmLastMeetingId;
|
||
|
||
if (!id) {
|
||
try {
|
||
const activeRes = await fetch('/tscm/meeting/active');
|
||
const activeData = await activeRes.json();
|
||
if (activeData && activeData.meeting) {
|
||
id = activeData.meeting.id;
|
||
tscmActiveMeetingId = id;
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to fetch active meeting:', e);
|
||
}
|
||
}
|
||
|
||
if (!id) {
|
||
alert('No meeting window available for summary.');
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading meeting summary...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/meeting/${id}/summary`);
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success' || !data.summary) {
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load meeting summary.</div>';
|
||
return;
|
||
}
|
||
|
||
const summary = data.summary;
|
||
const metrics = summary.summary || {};
|
||
const firstSeen = summary.devices_first_seen || [];
|
||
const behavior = summary.devices_behavior_change || [];
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>${escapeHtml(summary.name || 'Meeting Summary')}</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<table class="device-detail-table">
|
||
<tr><td>Start</td><td>${summary.start_time ? new Date(summary.start_time).toLocaleString() : 'N/A'}</td></tr>
|
||
<tr><td>End</td><td>${summary.end_time ? new Date(summary.end_time).toLocaleString() : 'In progress'}</td></tr>
|
||
<tr><td>Duration</td><td>${summary.duration_minutes ? `${summary.duration_minutes} min` : 'N/A'}</td></tr>
|
||
<tr><td>Total Active Devices</td><td>${metrics.total_devices_active || 0}</td></tr>
|
||
<tr><td>New During Meeting</td><td>${metrics.new_devices || 0}</td></tr>
|
||
<tr><td>Behavior Changes</td><td>${metrics.behavior_changes || 0}</td></tr>
|
||
<tr><td>High Interest</td><td>${metrics.high_interest || 0}</td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Devices First Seen During Meeting (${firstSeen.length})</h4>
|
||
${firstSeen.length === 0
|
||
? '<div class="tscm-empty">No devices first seen during meeting.</div>'
|
||
: `<div class="tscm-summary-list">
|
||
${firstSeen.map(d => `
|
||
<div class="tscm-summary-item">
|
||
<strong>${escapeHtml(d.name || d.identifier)}</strong>
|
||
<div class="tscm-summary-meta">
|
||
${escapeHtml(d.protocol || 'unknown')} • ${escapeHtml(d.description || '')}
|
||
</div>
|
||
${d.risk_modifier ? `<div class="tscm-summary-risk">${escapeHtml(d.risk_modifier)}</div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Behavior Changes (${behavior.length})</h4>
|
||
${behavior.length === 0
|
||
? '<div class="tscm-empty">No behavior changes detected.</div>'
|
||
: `<div class="tscm-summary-list">
|
||
${behavior.map(d => `
|
||
<div class="tscm-summary-item">
|
||
<strong>${escapeHtml(d.name || d.identifier)}</strong>
|
||
<div class="tscm-summary-meta">
|
||
${escapeHtml(d.protocol || 'unknown')} • ${escapeHtml(d.description || '')}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
${summary.disclaimer ? `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Note:</strong> ${escapeHtml(summary.disclaimer)}
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load meeting summary:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load meeting summary.</div>';
|
||
}
|
||
}
|
||
|
||
// Capabilities Display
|
||
async function tscmShowCapabilities() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading capabilities...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/capabilities');
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const caps = data.capabilities;
|
||
|
||
// Determine availability from nested structure
|
||
const wifiAvailable = caps.wifi && caps.wifi.mode !== 'unavailable';
|
||
const btAvailable = caps.bluetooth && caps.bluetooth.mode !== 'unavailable';
|
||
const rfAvailable = caps.rf && caps.rf.available;
|
||
|
||
// Build can/cannot detect lists based on capabilities
|
||
const canDetect = [];
|
||
const cannotDetect = [];
|
||
|
||
if (wifiAvailable) {
|
||
canDetect.push('WiFi access points and networks');
|
||
canDetect.push('Hidden SSIDs (presence only)');
|
||
if (caps.wifi.monitor_capable) {
|
||
canDetect.push('WiFi client devices (probe requests)');
|
||
canDetect.push('Deauthentication attacks');
|
||
}
|
||
} else {
|
||
cannotDetect.push('WiFi networks - no adapter available');
|
||
}
|
||
|
||
if (btAvailable) {
|
||
canDetect.push('Bluetooth Classic devices');
|
||
canDetect.push('BLE beacons and trackers');
|
||
canDetect.push('Audio-capable Bluetooth devices');
|
||
} else {
|
||
cannotDetect.push('Bluetooth devices - no adapter available');
|
||
}
|
||
|
||
if (rfAvailable) {
|
||
const minFreq = caps.rf.frequency_range_mhz?.min || 0;
|
||
const maxFreq = caps.rf.frequency_range_mhz?.max || 0;
|
||
canDetect.push(`RF signals (${minFreq}-${maxFreq} MHz)`);
|
||
canDetect.push('Unknown transmitters in frequency range');
|
||
} else {
|
||
cannotDetect.push('RF signals - no SDR device available');
|
||
}
|
||
|
||
// Always cannot detect
|
||
cannotDetect.push('Wired surveillance devices');
|
||
cannotDetect.push('Passive listening devices (no transmitter)');
|
||
cannotDetect.push('Devices that are powered off');
|
||
cannotDetect.push('Burst/store-and-forward transmitters (when idle)');
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>Sweep Capabilities</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>System Information</h4>
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">
|
||
OS: ${escapeHtml(caps.system?.os || 'Unknown')} ${escapeHtml(caps.system?.os_version || '')} |
|
||
Root: ${caps.system?.is_root ? 'Yes' : 'No'}
|
||
</div>
|
||
<h4>Available Detection Methods</h4>
|
||
<div class="capabilities-grid">
|
||
<div class="cap-detail-item ${wifiAvailable ? 'available' : 'unavailable'}">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||
<span class="cap-name">WiFi Scanning</span>
|
||
<span class="cap-status">${wifiAvailable ? caps.wifi.mode : 'Not Available'}</span>
|
||
${caps.wifi?.interface ? `<span class="cap-detail">${escapeHtml(caps.wifi.interface)}</span>` : ''}
|
||
</div>
|
||
<div class="cap-detail-item ${btAvailable ? 'available' : 'unavailable'}">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||
<span class="cap-name">Bluetooth Scanning</span>
|
||
<span class="cap-status">${btAvailable ? caps.bluetooth.mode : 'Not Available'}</span>
|
||
${caps.bluetooth?.adapter ? `<span class="cap-detail">${escapeHtml(caps.bluetooth.adapter)}</span>` : ''}
|
||
</div>
|
||
<div class="cap-detail-item ${rfAvailable ? 'available' : 'unavailable'}">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>
|
||
<span class="cap-name">RF/SDR Scanning</span>
|
||
<span class="cap-status">${rfAvailable ? 'Available' : 'Not Available'}</span>
|
||
${caps.rf?.device_type ? `<span class="cap-detail">${escapeHtml(caps.rf.device_type)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>What This Sweep CAN Detect</h4>
|
||
<ul class="cap-can-list">
|
||
${canDetect.map(item => `<li>✅ ${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>What This Sweep CANNOT Detect</h4>
|
||
<ul class="cap-cannot-list">
|
||
${cannotDetect.map(item => `<li>❌ ${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
${caps.all_limitations && caps.all_limitations.length > 0 ? `
|
||
<div class="device-detail-section">
|
||
<h4>Current Limitations</h4>
|
||
<ul class="cap-cannot-list">
|
||
${caps.all_limitations.map(item => `<li>⚠️ ${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Important:</strong> ${escapeHtml(caps.disclaimer || 'This tool detects wireless RF emissions only. Professional TSCM requires physical inspection, NLJD, thermal imaging, and spectrum analysis equipment.')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
content.innerHTML = `<div style="padding: 20px; color: #ff6666;">Failed to load capabilities: ${data.message || 'Unknown error'}</div>`;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load capabilities:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load capabilities</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmShowWifiIndicators() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading WiFi indicators...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/wifi/advanced-indicators');
|
||
const data = await response.json();
|
||
|
||
if (data.status !== 'success') {
|
||
content.innerHTML = `<div style="padding: 20px; color: #ff6666;">Failed to load indicators: ${escapeHtml(data.message || 'Unknown error')}</div>`;
|
||
return;
|
||
}
|
||
|
||
const indicators = data.indicators || [];
|
||
const unavailable = data.unavailable_features || [];
|
||
const disclaimer = data.disclaimer || 'Indicators are heuristic signals, not confirmations.';
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-yellow">
|
||
<h3>WiFi Advanced Indicators (${indicators.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
${indicators.length === 0 ? `
|
||
<div class="tscm-empty">No advanced indicators detected.</div>
|
||
` : `
|
||
<div class="indicator-list">
|
||
${indicators.map(i => `
|
||
<div class="indicator-item">
|
||
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}${i.severity ? ` • ${escapeHtml(i.severity)}` : ''}</span>
|
||
<span class="indicator-desc">${escapeHtml(i.description || '')}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`}
|
||
</div>
|
||
${unavailable.length > 0 ? `
|
||
<div class="device-detail-section">
|
||
<h4>Unavailable Features</h4>
|
||
<ul class="device-reasons-list">
|
||
${unavailable.map(u => `<li>${escapeHtml(u)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Note:</strong> ${escapeHtml(disclaimer)}
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load WiFi indicators:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load WiFi indicators</div>';
|
||
}
|
||
}
|
||
|
||
// Known Devices Management
|
||
async function tscmShowKnownDevices() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading known devices...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/known-devices');
|
||
const data = await response.json();
|
||
|
||
const devices = data.devices || [];
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-green">
|
||
<h3>✅ Known/Approved Devices (${devices.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<div style="margin-bottom: 12px;">
|
||
<button class="preset-btn" onclick="tscmAddKnownDevice()" style="font-size: 11px;">
|
||
+ Add Device
|
||
</button>
|
||
</div>
|
||
${devices.length === 0 ?
|
||
'<p style="color: var(--text-muted);">No known devices registered. Devices you mark as "known" will be excluded from threat scoring.</p>' :
|
||
`<div class="known-devices-list">
|
||
${devices.map(d => `
|
||
<div class="known-device-item">
|
||
<div class="known-device-info">
|
||
<strong>${escapeHtml(d.name || d.identifier)}</strong>
|
||
<span class="known-device-id">${escapeHtml(d.identifier)}</span>
|
||
<span class="known-device-type">${d.device_type}</span>
|
||
</div>
|
||
<div class="known-device-actions">
|
||
<button class="preset-btn" onclick="tscmRemoveKnownDevice('${encodeURIComponent(d.identifier)}')" style="font-size: 10px; background: #ff4444;">
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load known devices:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load known devices</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmAddKnownDevice() {
|
||
const identifier = prompt('Enter device identifier (MAC address, BSSID, or frequency):');
|
||
if (!identifier) return;
|
||
|
||
const name = prompt('Enter friendly name for this device:');
|
||
const protocol = prompt('Enter protocol type (wifi/bluetooth/rf):') || 'wifi';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/known-devices', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
identifier: identifier,
|
||
protocol: protocol,
|
||
name: name || identifier
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowKnownDevices(); // Refresh list
|
||
} else {
|
||
alert(data.message || 'Failed to add device');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to add known device:', e);
|
||
alert('Failed to add device');
|
||
}
|
||
}
|
||
|
||
async function tscmRemoveKnownDevice(identifier) {
|
||
if (!confirm('Remove this device from known devices list?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/known-devices/${identifier}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowKnownDevices(); // Refresh list
|
||
} else {
|
||
alert(data.message || 'Failed to remove device');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to remove known device:', e);
|
||
}
|
||
}
|
||
|
||
async function tscmAddToKnownDevices(identifier, name, protocol) {
|
||
// Ask for optional custom name
|
||
const customName = prompt(`Add "${name}" to known devices.\n\nEnter a friendly name (or leave blank to use default):`, name);
|
||
if (customName === null) return; // User cancelled
|
||
|
||
try {
|
||
const response = await fetch('/tscm/known-devices', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
identifier: identifier,
|
||
protocol: protocol,
|
||
name: customName || name
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
// Show success message
|
||
alert(`"${customName || name}" added to known devices.\n\nThis device will be excluded from threat scoring in future sweeps.`);
|
||
// Close the device modal
|
||
closeTscmDeviceModal();
|
||
} else {
|
||
alert(data.message || 'Failed to add device');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to add to known devices:', e);
|
||
alert('Failed to add device to known list');
|
||
}
|
||
}
|
||
|
||
// Case Linking Helpers
|
||
function tscmPromptLinkSweep(sweepId) {
|
||
if (!sweepId) return;
|
||
tscmCaseLinkContext = { type: 'sweep', id: sweepId };
|
||
tscmShowCases();
|
||
}
|
||
|
||
function tscmPromptLinkThreat(threatId) {
|
||
if (!threatId) return;
|
||
tscmCaseLinkContext = { type: 'threat', id: threatId };
|
||
tscmShowCases();
|
||
}
|
||
|
||
function tscmCancelCaseLink() {
|
||
tscmCaseLinkContext = null;
|
||
tscmShowCases();
|
||
}
|
||
|
||
async function tscmLinkCase(caseId) {
|
||
if (!tscmCaseLinkContext || !caseId) return;
|
||
const ctx = tscmCaseLinkContext;
|
||
const endpoint = ctx.type === 'sweep'
|
||
? `/tscm/cases/${caseId}/sweeps/${ctx.id}`
|
||
: `/tscm/cases/${caseId}/threats/${ctx.id}`;
|
||
|
||
try {
|
||
const response = await fetch(endpoint, { method: 'POST' });
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmCaseLinkContext = null;
|
||
tscmViewCase(caseId);
|
||
} else {
|
||
alert(data.message || 'Failed to link case');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to link case:', e);
|
||
alert('Failed to link case');
|
||
}
|
||
}
|
||
|
||
// Cases Management
|
||
async function tscmShowCases() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading cases...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/cases');
|
||
const data = await response.json();
|
||
|
||
const cases = data.cases || [];
|
||
const linkBanner = tscmCaseLinkContext ? `
|
||
<div class="tscm-case-link-banner">
|
||
<span>Linking ${tscmCaseLinkContext.type} ${tscmCaseLinkContext.id}. Select a case.</span>
|
||
<button class="preset-btn" onclick="tscmCancelCaseLink()" style="font-size: 10px; padding: 4px 6px;">Cancel</button>
|
||
</div>
|
||
` : '';
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>TSCM Cases (${cases.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
${linkBanner}
|
||
<div style="margin-bottom: 12px;">
|
||
<button class="preset-btn" onclick="tscmCreateCase()" style="font-size: 11px;">
|
||
+ New Case
|
||
</button>
|
||
</div>
|
||
${cases.length === 0 ?
|
||
'<p style="color: var(--text-muted);">No cases created. Cases help you organize sweeps and findings for specific locations or clients.</p>' :
|
||
`<div class="cases-list">
|
||
${cases.map(c => `
|
||
<div class="case-item" onclick="tscmViewCase(${c.id})">
|
||
<div class="case-header">
|
||
<strong>${escapeHtml(c.name)}</strong>
|
||
<span class="case-status ${c.status}">${c.status}</span>
|
||
</div>
|
||
<div class="case-meta">
|
||
${c.client_name ? `Client: ${escapeHtml(c.client_name)} | ` : ''}
|
||
${c.location ? `Location: ${escapeHtml(c.location)} | ` : ''}
|
||
Sweeps: ${c.sweep_count || 0} | Threats: ${c.threat_count || 0}
|
||
</div>
|
||
<div class="case-date">
|
||
Created: ${new Date(c.created_at).toLocaleDateString()}
|
||
</div>
|
||
${tscmCaseLinkContext ? `
|
||
<div class="case-actions">
|
||
<button class="preset-btn" onclick="event.stopPropagation(); tscmLinkCase(${c.id})" style="font-size: 10px; padding: 4px 6px;">
|
||
Link
|
||
</button>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load cases:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load cases</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmCreateCase() {
|
||
const name = prompt('Enter case name:');
|
||
if (!name) return;
|
||
|
||
const clientName = prompt('Enter client name (optional):');
|
||
const location = prompt('Enter location (optional):');
|
||
|
||
try {
|
||
const response = await fetch('/tscm/cases', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name,
|
||
client_name: clientName || null,
|
||
location: location || null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowCases(); // Refresh list
|
||
} else {
|
||
alert(data.message || 'Failed to create case');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to create case:', e);
|
||
alert('Failed to create case');
|
||
}
|
||
}
|
||
|
||
async function tscmViewCase(caseId) {
|
||
try {
|
||
const response = await fetch(`/tscm/cases/${caseId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const c = data.case;
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>${escapeHtml(c.name)}</h3>
|
||
<span class="case-status ${c.status}">${c.status}</span>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Case Details</h4>
|
||
<table class="device-detail-table">
|
||
<tr><td>Client</td><td>${escapeHtml(c.client_name || 'N/A')}</td></tr>
|
||
<tr><td>Location</td><td>${escapeHtml(c.location || 'N/A')}</td></tr>
|
||
<tr><td>Created</td><td>${new Date(c.created_at).toLocaleString()}</td></tr>
|
||
<tr><td>Status</td><td>${c.status}</td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Linked Sweeps (${(c.sweeps || []).length})</h4>
|
||
${(c.sweeps || []).length === 0 ?
|
||
'<p style="color: var(--text-muted);">No sweeps linked to this case yet.</p>' :
|
||
`<ul>${(c.sweeps || []).map(s => `<li>Sweep ${s.id} - ${new Date(s.timestamp).toLocaleString()}</li>`).join('')}</ul>`
|
||
}
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Flagged Threats (${(c.threats || []).length})</h4>
|
||
${(c.threats || []).length === 0 ?
|
||
'<p style="color: var(--text-muted);">No threats flagged in this case.</p>' :
|
||
`<ul>${(c.threats || []).map(t => `<li>${escapeHtml(t.identifier)} - ${t.threat_type}</li>`).join('')}</ul>`
|
||
}
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Case Notes (${(c.case_notes || []).length})</h4>
|
||
${(c.case_notes || []).length === 0
|
||
? '<div class="tscm-empty">No notes added yet.</div>'
|
||
: `<div class="tscm-case-notes">
|
||
${(c.case_notes || []).map(n => `
|
||
<div class="tscm-case-note">
|
||
<div class="tscm-case-note-meta">
|
||
<span class="tscm-case-note-type">${escapeHtml(n.note_type || 'general')}</span>
|
||
<span>${n.created_at ? new Date(n.created_at).toLocaleString() : ''}</span>
|
||
</div>
|
||
<div class="tscm-case-note-content">${escapeHtml(n.content || '')}</div>
|
||
${n.created_by ? `<div class="tscm-case-note-author">By ${escapeHtml(n.created_by)}</div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
}
|
||
<div class="tscm-case-note-form">
|
||
<label>Note Type</label>
|
||
<select id="tscmCaseNoteType">
|
||
<option value="general" selected>General</option>
|
||
<option value="observation">Observation</option>
|
||
<option value="action">Action</option>
|
||
<option value="follow_up">Follow-up</option>
|
||
</select>
|
||
<label>Add Note</label>
|
||
<textarea id="tscmCaseNoteInput" rows="4" placeholder="Add a note to this case..."></textarea>
|
||
<button class="preset-btn" onclick="tscmAddCaseNote(${c.id})" style="margin-top: 6px; font-size: 10px;">Add Note</button>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 16px;">
|
||
<button class="preset-btn" onclick="tscmShowCases()">← Back to Cases</button>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to view case:', e);
|
||
}
|
||
}
|
||
|
||
async function tscmAddCaseNote(caseId) {
|
||
const noteInput = document.getElementById('tscmCaseNoteInput');
|
||
const typeSelect = document.getElementById('tscmCaseNoteType');
|
||
if (!noteInput || !typeSelect) return;
|
||
|
||
const content = noteInput.value.trim();
|
||
const noteType = typeSelect.value;
|
||
|
||
const ok = await tscmSubmitCaseNote(caseId, content, noteType);
|
||
if (ok) {
|
||
tscmViewCase(caseId);
|
||
}
|
||
}
|
||
|
||
// Schedules Management
|
||
async function tscmShowSchedules() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading schedules...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const [scheduleRes, baselineRes] = await Promise.all([
|
||
fetch('/tscm/schedules'),
|
||
fetch('/tscm/baselines')
|
||
]);
|
||
|
||
const scheduleData = await scheduleRes.json();
|
||
const baselineData = await baselineRes.json();
|
||
|
||
const schedules = scheduleData.schedules || [];
|
||
const baselines = baselineData.baselines || [];
|
||
const baselineMap = {};
|
||
baselines.forEach(b => { baselineMap[String(b.id)] = b.name; });
|
||
|
||
const baselineOptions = [
|
||
'<option value="">No Baseline</option>',
|
||
...baselines.map(b => `<option value="${b.id}">${escapeHtml(b.name)}</option>`)
|
||
].join('');
|
||
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-cyan">
|
||
<h3>TSCM Schedules (${schedules.length})</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<div class="tscm-schedule-form">
|
||
<div class="form-group">
|
||
<label>Name</label>
|
||
<input type="text" id="tscmScheduleName" placeholder="Daily sweep">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Sweep Type</label>
|
||
<select id="tscmScheduleSweepType">
|
||
<option value="quick">Quick Scan (2 min)</option>
|
||
<option value="standard" selected>Standard (5 min)</option>
|
||
<option value="full">Full Sweep (15 min)</option>
|
||
<option value="wireless_cameras">Wireless Cameras</option>
|
||
<option value="body_worn">Body-Worn Devices</option>
|
||
<option value="gps_trackers">GPS Trackers</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Baseline</label>
|
||
<select id="tscmScheduleBaseline">
|
||
${baselineOptions}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Cadence</label>
|
||
<select id="tscmScheduleCadence" onchange="tscmScheduleCadenceChanged()">
|
||
<option value="daily" selected>Daily</option>
|
||
<option value="weekly">Weekly</option>
|
||
<option value="hourly">Every N Hours</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="tscmScheduleTimeRow">
|
||
<label>Time</label>
|
||
<input type="time" id="tscmScheduleTime" value="09:00">
|
||
</div>
|
||
<div class="form-group" id="tscmScheduleDayRow" style="display: none;">
|
||
<label>Day of Week</label>
|
||
<select id="tscmScheduleDay">
|
||
<option value="0">Sunday</option>
|
||
<option value="1">Monday</option>
|
||
<option value="2">Tuesday</option>
|
||
<option value="3">Wednesday</option>
|
||
<option value="4">Thursday</option>
|
||
<option value="5">Friday</option>
|
||
<option value="6">Saturday</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="tscmScheduleIntervalRow" style="display: none;">
|
||
<label>Interval (hours)</label>
|
||
<input type="number" id="tscmScheduleInterval" min="1" max="24" value="6">
|
||
</div>
|
||
<button class="preset-btn" onclick="tscmCreateScheduleFromForm()" style="margin-top: 6px; font-size: 11px;">
|
||
Create Schedule
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<h4>Existing Schedules</h4>
|
||
${schedules.length === 0
|
||
? '<div class="tscm-empty">No schedules created.</div>'
|
||
: `<div class="tscm-schedule-list">
|
||
${schedules.map(s => {
|
||
const isEnabled = !!s.enabled;
|
||
const baselineName = baselineMap[String(s.baseline_id)] || 'None';
|
||
const nextRun = s.next_run ? new Date(s.next_run).toLocaleString() : 'Not scheduled';
|
||
const lastRun = s.last_run ? new Date(s.last_run).toLocaleString() : 'Never';
|
||
return `
|
||
<div class="tscm-schedule-item ${isEnabled ? 'enabled' : 'disabled'}">
|
||
<div class="tscm-schedule-header">
|
||
<strong>${escapeHtml(s.name)}</strong>
|
||
<span class="tscm-schedule-status">${isEnabled ? 'Enabled' : 'Disabled'}</span>
|
||
</div>
|
||
<div class="tscm-schedule-meta">
|
||
Sweep: ${escapeHtml(s.sweep_type || 'standard')} | Baseline: ${escapeHtml(baselineName)}
|
||
</div>
|
||
<div class="tscm-schedule-meta">
|
||
Cron: ${escapeHtml(s.cron_expression || '')}
|
||
</div>
|
||
<div class="tscm-schedule-meta">
|
||
Next: ${nextRun} | Last: ${lastRun}
|
||
</div>
|
||
<div class="tscm-schedule-actions">
|
||
<button class="preset-btn" onclick="tscmRunScheduleNow(${s.id})" style="font-size: 10px; padding: 4px 6px;">
|
||
Run Now
|
||
</button>
|
||
<button class="preset-btn" onclick="tscmToggleSchedule(${s.id}, ${isEnabled ? 'false' : 'true'})" style="font-size: 10px; padding: 4px 6px;">
|
||
${isEnabled ? 'Disable' : 'Enable'}
|
||
</button>
|
||
<button class="preset-btn" onclick="tscmDeleteSchedule(${s.id})" style="font-size: 10px; padding: 4px 6px;">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>`
|
||
}
|
||
</div>
|
||
`;
|
||
tscmScheduleCadenceChanged();
|
||
} catch (e) {
|
||
console.error('Failed to load schedules:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load schedules</div>';
|
||
}
|
||
}
|
||
|
||
function tscmScheduleCadenceChanged() {
|
||
const cadence = document.getElementById('tscmScheduleCadence')?.value || 'daily';
|
||
const timeRow = document.getElementById('tscmScheduleTimeRow');
|
||
const dayRow = document.getElementById('tscmScheduleDayRow');
|
||
const intervalRow = document.getElementById('tscmScheduleIntervalRow');
|
||
|
||
if (timeRow) timeRow.style.display = cadence === 'hourly' ? 'none' : 'block';
|
||
if (dayRow) dayRow.style.display = cadence === 'weekly' ? 'block' : 'none';
|
||
if (intervalRow) intervalRow.style.display = cadence === 'hourly' ? 'block' : 'none';
|
||
}
|
||
|
||
async function tscmCreateScheduleFromForm() {
|
||
const name = document.getElementById('tscmScheduleName')?.value.trim();
|
||
if (!name) {
|
||
alert('Schedule name required');
|
||
return;
|
||
}
|
||
const sweepType = document.getElementById('tscmScheduleSweepType')?.value || 'standard';
|
||
const baselineId = document.getElementById('tscmScheduleBaseline')?.value || null;
|
||
const cadence = document.getElementById('tscmScheduleCadence')?.value || 'daily';
|
||
|
||
let cronExpression = '';
|
||
if (cadence === 'hourly') {
|
||
const interval = parseInt(document.getElementById('tscmScheduleInterval')?.value || '6', 10);
|
||
if (isNaN(interval) || interval < 1 || interval > 24) {
|
||
alert('Interval must be between 1 and 24 hours');
|
||
return;
|
||
}
|
||
cronExpression = `0 */${interval} * * *`;
|
||
} else {
|
||
const timeValue = document.getElementById('tscmScheduleTime')?.value || '09:00';
|
||
const [hourStr, minStr] = timeValue.split(':');
|
||
const hour = parseInt(hourStr, 10);
|
||
const minute = parseInt(minStr, 10);
|
||
if (isNaN(hour) || isNaN(minute)) {
|
||
alert('Invalid time');
|
||
return;
|
||
}
|
||
if (cadence === 'weekly') {
|
||
const day = document.getElementById('tscmScheduleDay')?.value || '0';
|
||
cronExpression = `${minute} ${hour} * * ${day}`;
|
||
} else {
|
||
cronExpression = `${minute} ${hour} * * *`;
|
||
}
|
||
}
|
||
|
||
const zoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
||
|
||
try {
|
||
const response = await fetch('/tscm/schedules', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name,
|
||
sweep_type: sweepType,
|
||
baseline_id: baselineId ? Number(baselineId) : null,
|
||
cron_expression: cronExpression,
|
||
zone_name: zoneName
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowSchedules();
|
||
} else {
|
||
alert(data.message || 'Failed to create schedule');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to create schedule:', e);
|
||
alert('Failed to create schedule');
|
||
}
|
||
}
|
||
|
||
async function tscmToggleSchedule(scheduleId, enabled) {
|
||
try {
|
||
const response = await fetch(`/tscm/schedules/${scheduleId}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled })
|
||
});
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowSchedules();
|
||
} else {
|
||
alert(data.message || 'Failed to update schedule');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to update schedule:', e);
|
||
alert('Failed to update schedule');
|
||
}
|
||
}
|
||
|
||
async function tscmRunScheduleNow(scheduleId) {
|
||
try {
|
||
const response = await fetch(`/tscm/schedules/${scheduleId}/run`, { method: 'POST' });
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
alert('Scheduled sweep started');
|
||
tscmShowSchedules();
|
||
} else {
|
||
alert(data.message || 'Failed to run schedule');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to run schedule:', e);
|
||
alert('Failed to run schedule');
|
||
}
|
||
}
|
||
|
||
async function tscmDeleteSchedule(scheduleId) {
|
||
if (!confirm('Delete this schedule?')) return;
|
||
try {
|
||
const response = await fetch(`/tscm/schedules/${scheduleId}`, { method: 'DELETE' });
|
||
const data = await response.json();
|
||
if (data.status === 'success') {
|
||
tscmShowSchedules();
|
||
} else {
|
||
alert(data.message || 'Failed to delete schedule');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to delete schedule:', e);
|
||
alert('Failed to delete schedule');
|
||
}
|
||
}
|
||
|
||
// Playbooks Display
|
||
async function tscmShowPlaybooks() {
|
||
const modal = document.getElementById('tscmDeviceModal');
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
|
||
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading playbooks...</div>';
|
||
modal.style.display = 'flex';
|
||
|
||
try {
|
||
const response = await fetch('/tscm/playbooks');
|
||
const data = await response.json();
|
||
|
||
const playbooks = data.playbooks || [];
|
||
content.innerHTML = `
|
||
<div class="device-detail-header classification-orange">
|
||
<h3>Operator Playbooks</h3>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="color: var(--text-muted); margin-bottom: 16px;">
|
||
Playbooks provide step-by-step guidance for investigating specific types of findings.
|
||
</p>
|
||
<div class="playbooks-list">
|
||
${playbooks.map(p => `
|
||
<div class="playbook-item" onclick="tscmViewPlaybook('${p.id}')">
|
||
<div class="playbook-header">
|
||
<strong>${escapeHtml(p.name || p.title)}</strong>
|
||
<span class="playbook-category">${escapeHtml(p.risk_level || p.category || 'General')}</span>
|
||
</div>
|
||
<div class="playbook-desc">
|
||
${escapeHtml(p.description || 'No description')}
|
||
</div>
|
||
<div class="playbook-meta">
|
||
${p.steps?.length || 0} steps
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
console.error('Failed to load playbooks:', e);
|
||
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load playbooks</div>';
|
||
}
|
||
}
|
||
|
||
async function tscmViewPlaybook(playbookId) {
|
||
try {
|
||
const response = await fetch(`/tscm/playbooks/${playbookId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const p = data.playbook;
|
||
const content = document.getElementById('tscmDeviceModalContent');
|
||
content.innerHTML = renderPlaybook(p);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to view playbook:', e);
|
||
}
|
||
}
|
||
|
||
function renderPlaybook(p) {
|
||
const riskColors = { 'critical': '#ff3366', 'high': '#ff6633', 'medium': '#ff9800', 'low': '#4caf50' };
|
||
const riskColor = riskColors[(p.risk_level || '').toLowerCase()] || '#ff9800';
|
||
return `
|
||
<div class="device-detail-header" style="border-left: 4px solid ${riskColor};">
|
||
<h3>${escapeHtml(p.title || p.name || 'Playbook')}</h3>
|
||
<span style="font-size: 10px; background: ${riskColor}; color: #000; padding: 2px 8px; border-radius: 3px; font-weight: bold; text-transform: uppercase;">${escapeHtml(p.risk_level || 'MEDIUM')}</span>
|
||
</div>
|
||
<div class="device-detail-section">
|
||
<p style="color: var(--text-muted);">${escapeHtml(p.description || '')}</p>
|
||
</div>
|
||
${p.when_to_escalate ? `
|
||
<div style="margin: 12px 0; padding: 10px 14px; background: rgba(255,51,102,0.1); border: 1px solid rgba(255,51,102,0.4); border-radius: 6px;">
|
||
<strong style="color: #ff3366; font-size: 11px; text-transform: uppercase;">Escalation Trigger</strong>
|
||
<p style="margin: 4px 0 0; font-size: 12px; color: #ff6666;">${escapeHtml(p.when_to_escalate)}</p>
|
||
</div>
|
||
` : ''}
|
||
<div class="device-detail-section">
|
||
<h4 style="margin-bottom: 10px;">Investigation Steps</h4>
|
||
<div class="playbook-checklist">
|
||
${(p.steps || []).map((step, i) => {
|
||
const stepNum = step.step_number || step.step || (i + 1);
|
||
return `
|
||
<div class="playbook-check-step" id="pbStep${i}" style="display: flex; gap: 10px; padding: 10px; margin-bottom: 8px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; transition: border-color 0.3s;" onclick="togglePlaybookStep(${i})">
|
||
<div style="flex-shrink: 0; display: flex; align-items: flex-start; padding-top: 2px;">
|
||
<input type="checkbox" id="pbCheck${i}" style="width: 16px; height: 16px; accent-color: #00e676; cursor: pointer;" onclick="event.stopPropagation(); togglePlaybookStep(${i})">
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
|
||
<span style="font-size: 10px; color: var(--accent-cyan); font-weight: bold;">STEP ${stepNum}</span>
|
||
<strong style="font-size: 12px;">${escapeHtml(step.action || step.title || '')}</strong>
|
||
</div>
|
||
<p style="font-size: 11px; color: var(--text-muted); margin: 0;">${escapeHtml(step.details || step.description || '')}</p>
|
||
${step.safety_note ? `<div style="margin-top: 6px; padding: 6px 8px; background: rgba(255,152,0,0.1); border-left: 3px solid #ff9800; border-radius: 3px; font-size: 10px; color: #ffb74d;"><strong>Safety:</strong> ${escapeHtml(step.safety_note)}</div>` : ''}
|
||
${step.evidence_needed && step.evidence_needed.length > 0 ? `<div style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"><strong>Evidence needed:</strong> ${step.evidence_needed.map(e => escapeHtml(e)).join(', ')}</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
${p.documentation_required && p.documentation_required.length > 0 ? `
|
||
<div class="device-detail-section">
|
||
<h4>Documentation Required</h4>
|
||
<ul style="list-style: none; padding: 0;">
|
||
${p.documentation_required.map(d => `<li style="padding: 4px 0; font-size: 11px; color: var(--text-secondary);">☐ ${escapeHtml(d)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
${p.disclaimer ? `
|
||
<div class="device-detail-disclaimer">
|
||
<strong>Disclaimer:</strong> ${escapeHtml(p.disclaimer)}
|
||
</div>
|
||
` : ''}
|
||
<div style="margin-top: 16px;">
|
||
<button class="preset-btn" onclick="tscmShowPlaybooks()">← Back to Playbooks</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function togglePlaybookStep(index) {
|
||
const checkbox = document.getElementById('pbCheck' + index);
|
||
const stepEl = document.getElementById('pbStep' + index);
|
||
if (!checkbox || !stepEl) return;
|
||
// Toggle if triggered from the row (not the checkbox itself)
|
||
if (document.activeElement !== checkbox) {
|
||
checkbox.checked = !checkbox.checked;
|
||
}
|
||
if (checkbox.checked) {
|
||
stepEl.style.borderColor = '#00e676';
|
||
stepEl.style.background = 'rgba(0, 230, 118, 0.05)';
|
||
} else {
|
||
stepEl.style.borderColor = 'var(--border-color)';
|
||
stepEl.style.background = 'rgba(0,0,0,0.2)';
|
||
}
|
||
}
|
||
|
||
async function fetchDevicePlaybook(identifier) {
|
||
try {
|
||
const response = await fetch(`/tscm/findings/${encodeURIComponent(identifier)}/playbook`);
|
||
const data = await response.json();
|
||
if (data.status === 'success' && data.playbook) {
|
||
return data.playbook;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch device playbook:', e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Report Downloads
|
||
async function tscmDownloadPdf() {
|
||
try {
|
||
const response = await fetch('/tscm/report/pdf');
|
||
if (response.ok) {
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `TSCM_Report_${new Date().toISOString().split('T')[0]}.pdf`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} else {
|
||
const data = await response.json();
|
||
alert(data.message || 'Failed to generate PDF');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to download PDF:', e);
|
||
alert('Failed to download PDF report');
|
||
}
|
||
}
|
||
|
||
async function tscmDownloadAnnex(format) {
|
||
try {
|
||
const response = await fetch(`/tscm/report/annex?format=${format}`);
|
||
if (response.ok) {
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `TSCM_Annex_${new Date().toISOString().split('T')[0]}.${format}`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} else {
|
||
const data = await response.json();
|
||
alert(data.message || 'Failed to generate annex');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to download annex:', e);
|
||
alert('Failed to download technical annex');
|
||
}
|
||
}
|
||
|
||
// Update capabilities bar on sweep start
|
||
async function updateTscmCapabilitiesBar(wifiInterface = '', btInterface = '') {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (wifiInterface) params.append('wifi_interface', wifiInterface);
|
||
if (btInterface) params.append('bt_adapter', btInterface);
|
||
const query = params.toString();
|
||
const response = await fetch(`/tscm/capabilities${query ? `?${query}` : ''}`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const caps = data.capabilities;
|
||
const bar = document.getElementById('tscmCapabilitiesBar');
|
||
|
||
if (bar) {
|
||
const wifiAvailable = caps.wifi && caps.wifi.mode && caps.wifi.mode !== 'unavailable';
|
||
const btAvailable = caps.bluetooth && caps.bluetooth.mode && caps.bluetooth.mode !== 'unavailable';
|
||
const rfAvailable = caps.rf && caps.rf.available;
|
||
const isRoot = caps.system && caps.system.is_root;
|
||
|
||
const normalizeMode = (mode) => mode ? mode.replace(/_/g, ' ').toUpperCase() : 'ON';
|
||
|
||
document.getElementById('capWifiStatus').textContent = wifiAvailable ? normalizeMode(caps.wifi.mode) : 'OFF';
|
||
document.getElementById('capWifi').classList.toggle('active', wifiAvailable);
|
||
|
||
document.getElementById('capBtStatus').textContent = btAvailable ? normalizeMode(caps.bluetooth.mode) : 'OFF';
|
||
document.getElementById('capBt').classList.toggle('active', btAvailable);
|
||
|
||
document.getElementById('capRfStatus').textContent = rfAvailable ? 'ON' : 'OFF';
|
||
document.getElementById('capRf').classList.toggle('active', rfAvailable);
|
||
|
||
document.getElementById('capRootStatus').textContent = isRoot ? 'ROOT' : 'USER';
|
||
document.getElementById('capRoot').classList.toggle('active', isRoot);
|
||
|
||
const limitationCount = (caps.all_limitations || []).length;
|
||
document.getElementById('capLimitationCount').textContent = limitationCount;
|
||
|
||
bar.style.display = 'flex';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to update capabilities bar:', e);
|
||
}
|
||
}
|
||
|
||
// Update baseline health indicator
|
||
async function updateTscmBaselineHealth(baselineId) {
|
||
if (!baselineId) {
|
||
const healthDiv = document.getElementById('tscmBaselineHealth');
|
||
if (healthDiv) healthDiv.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/tscm/baseline/${baselineId}/health`);
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'success') {
|
||
const healthDiv = document.getElementById('tscmBaselineHealth');
|
||
const badge = document.getElementById('baselineHealthBadge');
|
||
const nameEl = document.getElementById('baselineHealthName');
|
||
const ageEl = document.getElementById('baselineHealthAge');
|
||
|
||
if (healthDiv && badge) {
|
||
const health = data.health || {};
|
||
const status = (health.status || 'unknown').toLowerCase();
|
||
const displayStatus = status.replace(/_/g, ' ');
|
||
|
||
badge.textContent = displayStatus.toUpperCase();
|
||
badge.className = `health-badge health-${status}`;
|
||
if (health.reasons && health.reasons.length > 0) {
|
||
badge.title = health.reasons.join(' • ');
|
||
}
|
||
|
||
if (nameEl) {
|
||
const baselineSelect = document.getElementById('tscmBaselineSelect');
|
||
const selectedOption = baselineSelect ? baselineSelect.options[baselineSelect.selectedIndex] : null;
|
||
const selectedName = selectedOption ? selectedOption.textContent.replace(' (Active)', '') : 'Baseline';
|
||
nameEl.textContent = selectedName || 'Baseline';
|
||
}
|
||
if (ageEl) {
|
||
const ageHours = health.age_hours;
|
||
if (ageHours !== undefined && ageHours !== null) {
|
||
const ageLabel = ageHours >= 48
|
||
? `${(ageHours / 24).toFixed(1)}d`
|
||
: `${Math.round(ageHours)}h`;
|
||
ageEl.textContent = `• ${ageLabel} old`;
|
||
} else {
|
||
ageEl.textContent = '';
|
||
}
|
||
}
|
||
healthDiv.style.display = 'block';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to update baseline health:', e);
|
||
}
|
||
}
|
||
|
||
// Listen for baseline selection changes
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const baselineSelect = document.getElementById('tscmBaselineSelect');
|
||
if (baselineSelect) {
|
||
baselineSelect.addEventListener('change', function () {
|
||
updateTscmBaselineHealth(this.value);
|
||
});
|
||
}
|
||
|
||
const filterIds = ['tscmFilterProtocol', 'tscmFilterRisk', 'tscmFilterStatus', 'tscmFilterKnown'];
|
||
filterIds.forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.addEventListener('change', applyTscmFilters);
|
||
}
|
||
});
|
||
applyTscmFilters();
|
||
});
|
||
</script>
|
||
|
||
{% include 'partials/help-modal.html' %}
|
||
|
||
|
||
<!-- Satellite Add Modal -->
|
||
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
|
||
<div class="help-content" style="max-width: 600px;">
|
||
<button class="help-close" onclick="closeSatModal()">×</button>
|
||
<h2>Add Satellites</h2>
|
||
|
||
<!-- Tabs -->
|
||
<div
|
||
style="display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;">
|
||
<button class="sat-modal-tab preset-btn active" onclick="switchSatModalTab('tle')"
|
||
style="flex: 1;">Manual TLE</button>
|
||
<button class="sat-modal-tab preset-btn" onclick="switchSatModalTab('celestrak')"
|
||
style="flex: 1;">Celestrak</button>
|
||
</div>
|
||
|
||
<!-- TLE Section -->
|
||
<div id="tleSection" class="sat-modal-section active">
|
||
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
|
||
Paste TLE (Two-Line Element) data. Format: Name on first line, followed by TLE lines 1 and 2.
|
||
</p>
|
||
<textarea id="tleInput" placeholder="ISS (ZARYA)
|
||
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||
2 25544 51.6400 208.9163 0006703 296.5855 63.4606 15.49995465478450"
|
||
style="width: 100%; height: 150px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; font-family: var(--font-mono); font-size: 11px; resize: vertical;"></textarea>
|
||
<button class="preset-btn" onclick="addFromTLE()" style="margin-top: 10px; width: 100%;">Add
|
||
Satellite</button>
|
||
</div>
|
||
|
||
<!-- Celestrak Section -->
|
||
<div id="celestrakSection" class="sat-modal-section">
|
||
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
|
||
Fetch satellite TLE data from CelesTrak by category.
|
||
</p>
|
||
<div id="celestrakStatus" style="margin-bottom: 10px; font-size: 11px; min-height: 20px;"></div>
|
||
|
||
<div
|
||
style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;">
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('starlink')">Starlink</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('oneweb')">OneWeb</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('iridium-NEXT')">Iridium NEXT</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('visual')">Bright/Visual</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('geo')">Geostationary</button>
|
||
<button class="preset-btn" onclick="fetchCelestrakCategory('resource')">Earth Resources</button>
|
||
</div>
|
||
|
||
<p style="color: var(--text-muted); font-size: 10px; margin-top: 10px;">
|
||
Data from <a href="https://celestrak.org" target="_blank"
|
||
style="color: var(--accent-cyan);">celestrak.org</a>.
|
||
Note: Some categories (Starlink, OneWeb) contain many satellites.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Check dependencies on page load
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
// Check if user dismissed the startup check
|
||
const dismissed = localStorage.getItem('depsCheckDismissed');
|
||
|
||
// Quick check for missing dependencies
|
||
fetch('/dependencies')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
let missingModes = 0;
|
||
let missingTools = [];
|
||
|
||
for (const [modeKey, mode] of Object.entries(data.modes)) {
|
||
if (!mode.ready) {
|
||
missingModes++;
|
||
mode.missing_required.forEach(tool => {
|
||
if (!missingTools.includes(tool)) {
|
||
missingTools.push(tool);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Show startup prompt if tools are missing and not dismissed
|
||
// Only show if disclaimer has been accepted
|
||
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
|
||
showStartupDepsPrompt(missingModes, missingTools.length);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
function showStartupDepsPrompt(modeCount, toolCount) {
|
||
const notice = document.createElement('div');
|
||
notice.id = 'startupDepsModal';
|
||
notice.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
left: 20px;
|
||
z-index: 10000;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--accent-orange);
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 165, 0, 0.2);
|
||
max-width: 380px;
|
||
animation: slideIn 0.3s ease-out;
|
||
`;
|
||
notice.innerHTML = `
|
||
<style>
|
||
@keyframes slideIn {
|
||
from { transform: translateX(-100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
</style>
|
||
<div style="padding: 15px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||
<h3 style="margin: 0; color: var(--accent-orange); font-size: 14px; display: flex; align-items: center; gap: 8px;">
|
||
Missing Dependencies
|
||
</h3>
|
||
<button onclick="closeStartupDeps()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 18px; padding: 0; line-height: 1;">×</button>
|
||
</div>
|
||
<p style="color: var(--text-secondary); margin: 0 0 15px 0; font-size: 13px; line-height: 1.4;">
|
||
<strong style="color: var(--accent-orange);">${modeCount} mode(s)</strong> require tools that aren't installed.
|
||
</p>
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
<button class="action-btn" onclick="closeStartupDeps(); showSettings(); switchSettingsTab('tools');" style="padding: 10px 16px; font-size: 12px;">
|
||
View Details & Install
|
||
</button>
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim); cursor: pointer;">
|
||
<input type="checkbox" id="dontShowAgain" style="cursor: pointer;">
|
||
Don't show again
|
||
</label>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(notice);
|
||
}
|
||
|
||
function closeStartupDeps() {
|
||
const modal = document.getElementById('startupDepsModal');
|
||
if (modal) {
|
||
if (document.getElementById('dontShowAgain')?.checked) {
|
||
localStorage.setItem('depsCheckDismissed', 'true');
|
||
}
|
||
modal.remove();
|
||
}
|
||
}
|
||
|
||
function logout(event) {
|
||
// We use event.currentTarget to ensure we select the button even if the icon was clicked
|
||
const btn = event.currentTarget;
|
||
|
||
// 1. Visual Feedback: Change color to red and add a "glow"
|
||
btn.style.color = "#ff4d4d";
|
||
btn.style.borderColor = "#ff4d4d";
|
||
btn.style.textShadow = "0 0 10px #ff4d4d"; // Glow effect
|
||
btn.style.transform = "scale(0.95)"; // Slight press effect
|
||
|
||
// 2. Disable the button to prevent double clicks
|
||
btn.style.pointerEvents = "none";
|
||
|
||
// 3. Logic execution
|
||
setTimeout(() => {
|
||
localStorage.removeItem('user_session');
|
||
window.location.href = '/login';
|
||
}, 600); // 600ms is enough for the user to perceive the color change
|
||
}
|
||
</script>
|
||
|
||
<!-- Settings Modal -->
|
||
{% include 'partials/settings-modal.html' %}
|
||
|
||
<!-- Toast Container -->
|
||
<div id="toastContainer"></div>
|
||
|
||
<!-- Updater -->
|
||
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
||
<!-- Settings Manager -->
|
||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||
<!-- Alerts + Recording -->
|
||
<script src="{{ url_for('static', filename='js/core/alerts.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/recordings.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/ui-feedback.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/run-state.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/command-palette.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/first-run-setup.js') }}"></script>
|
||
|
||
<!-- Cheat Sheet Modal -->
|
||
<div id="cheatSheetModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)CheatSheets.hide()">
|
||
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:480px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
|
||
<button onclick="CheatSheets.hide()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;">✕</button>
|
||
<div id="cheatSheetContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Keyboard Shortcuts Modal -->
|
||
<div id="kbShortcutsModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)KeyboardShortcuts.hideHelp()">
|
||
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:520px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
|
||
<button onclick="KeyboardShortcuts.hideHelp()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;">✕</button>
|
||
<h2 style="margin:0 0 16px; font-size:16px; color:var(--accent-cyan, #4aa3ff); font-family:var(--font-mono);">Keyboard Shortcuts</h2>
|
||
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
|
||
<tbody>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+W</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Waterfall</td></tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+M</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle voice mute</td></tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+C</td><td style="padding:6px 8px; color:var(--text-secondary);">Show cheat sheet for current mode</td></tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+1..9</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Nth mode in current group</td></tr>
|
||
<tr><td style="padding:6px 8px; color:var(--accent-cyan);">Escape</td><td style="padding:6px 8px; color:var(--text-secondary);">Close modal / Return to welcome</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PWA Service Worker Registration -->
|
||
<script>
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
|
||
});
|
||
}
|
||
// Initialize global core modules after page load
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|