Compare commits

...

130 Commits

Author SHA1 Message Date
Smittix 24332a4e23 Release v2.13.1 - Help modal and navigation improvements
- Add help modal system with keyboard shortcuts reference
- Add Main Dashboard button in navigation bar
- Make settings modal accessible from all dashboards
- Dashboard CSS improvements and consistency fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:05:07 +00:00
Smittix ebc5754684 Update version in pyproject.toml to 2.13.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:48:09 +00:00
Smittix 340b300aa4 Release v2.13.0 - WiFi client display in AP detail drawer
Features:
- Display connected clients for access points in detail drawer
- Real-time client updates via SSE streaming
- Client cards show MAC, vendor, RSSI, probed SSIDs, and last seen
- Count badge in Connected Clients header

Other changes:
- Updated aircraft database
- CSS and template refinements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:44:23 +00:00
Smittix bf7026cc9f Merge branch 'codex/new-ui'
# Conflicts:
#	static/css/index.css
2026-02-04 15:11:24 +00:00
Smittix 1b04b52509 Sync scanner range from backend updates 2026-02-04 13:25:14 +00:00
Smittix fca334f472 Sync scanner range with backend config 2026-02-04 13:14:42 +00:00
Smittix d81d644319 Prefer progress data for scanner sweep 2026-02-04 13:11:02 +00:00
Smittix 400cf1114f Use frequency-based sweep display 2026-02-04 12:48:40 +00:00
Smittix fec38adc78 Stabilize scanner progress tracking 2026-02-04 12:42:30 +00:00
Smittix 993a7d2626 Stabilize sweep display and lower SNR default 2026-02-04 12:30:13 +00:00
Smittix dbe09411ac Stabilize sweep progress updates 2026-02-04 12:20:38 +00:00
Smittix 0afc47fcdd Ignore out-of-order scan updates 2026-02-04 12:17:36 +00:00
Smittix 4862b285a8 Order sweep updates to avoid progress jitter 2026-02-04 12:14:46 +00:00
Smittix 41dd1555d7 Emit sweep progress and clear scanner queue 2026-02-04 12:11:50 +00:00
Smittix 0cf3a25ac6 Ensure scanner releases SDR before listening 2026-02-04 12:07:30 +00:00
Smittix 3674b6e2d6 Stop rtl_power when starting listen 2026-02-04 12:04:50 +00:00
Smittix 4c9bcb00c3 Improve rtl_power line parsing 2026-02-04 12:03:01 +00:00
Smittix 2067d0bf84 Default squelch to zero and track SDR usage 2026-02-04 11:59:06 +00:00
Smittix c0fa59d10e Add SNR threshold control for power scan 2026-02-04 11:54:56 +00:00
Smittix 37add84d59 Switch scanner to rtl_power sweep 2026-02-04 11:52:39 +00:00
Smittix c23019b8c0 Advance scanner after dwell on signal 2026-02-04 11:44:19 +00:00
Smittix b4edd35f5f Tighten listening signal detection thresholds 2026-02-04 11:41:30 +00:00
Smittix 812f85b9a9 Log only interesting listening signals 2026-02-04 11:37:15 +00:00
Smittix 77888b7d88 Align scanner audio stream start 2026-02-04 11:27:10 +00:00
Smittix 4a38d7512d Align listening action button styles 2026-02-04 11:23:32 +00:00
Smittix 5d0df18dac Silence listen slow-start log 2026-02-04 11:19:44 +00:00
Smittix d18e38800e Retry listen playback without fallback 2026-02-04 11:12:46 +00:00
Smittix 76e595aaec Prompt user to enable audio playback 2026-02-04 11:10:34 +00:00
Smittix dfb9897fa1 Trigger user-initiated audio play on listen 2026-02-04 11:09:04 +00:00
Smittix 82ad784fcb Restart audio pipeline for fresh stream header 2026-02-04 11:04:43 +00:00
Smittix 4bd7077d64 Add listening audio probe diagnostics 2026-02-04 11:02:00 +00:00
Smittix 3f6b9cc5ef Force squelch open for listen audio 2026-02-04 11:00:20 +00:00
Smittix 0742647571 Stream listening audio as WAV 2026-02-04 10:56:57 +00:00
Smittix 33090419df Timeout audio stream if no first chunk 2026-02-04 10:53:03 +00:00
Smittix 4042d0e5f1 Allow listening audio endpoints without login 2026-02-04 10:46:49 +00:00
Smittix d3a0b41fba Flush ffmpeg audio stream packets 2026-02-04 10:06:45 +00:00
Smittix 2fefea5618 Add listening audio debug endpoint 2026-02-04 10:03:47 +00:00
Smittix d75f7c794f Retry listening audio stream fetch 2026-02-04 10:01:58 +00:00
Smittix 503b91ea87 Add fetch stream fallback for listening audio 2026-02-04 09:49:14 +00:00
Smittix 43db7c309d Add WebSocket audio fallback for listening 2026-02-04 09:46:34 +00:00
Smittix 6e57927409 Force audio stream load on listen 2026-02-04 09:39:47 +00:00
Smittix a404f5ded9 Send SDR settings for listening audio 2026-02-04 09:31:07 +00:00
Smittix f6a6aab623 Update URL on mode switch 2026-02-04 09:26:29 +00:00
Smittix 2cfbc0addc Apply JetBrains Mono tokens to standalone pages 2026-02-04 01:15:18 +00:00
Smittix 07d6ef984e Switch app font to JetBrains Mono 2026-02-04 01:10:42 +00:00
Smittix 50227ccae6 Use Terminus font across app 2026-02-04 00:56:22 +00:00
Smittix 8f3c636c61 Fix mode query routing from dashboard nav 2026-02-04 00:49:54 +00:00
Smittix 42761bbdbc Add global nav dropdown behavior 2026-02-04 00:47:05 +00:00
Smittix 0f2eba302c Add global nav styles 2026-02-04 00:45:00 +00:00
Smittix 83dd58721f Wire global navbar across pages 2026-02-04 00:37:41 +00:00
Smittix d658d0b81e Refine UI to clean professional style 2026-02-04 00:21:52 +00:00
Smittix e04113628a Fix dual scrollbar issue on main dashboard
Add overflow: hidden to html and body elements to prevent browser
window scrollbar while keeping internal content areas scrollable.

Fixes #119

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:10:47 +00:00
Smittix b1e92326b6 Fix multiple UI bugs and improve error handling
Issues fixed:
- #113: Display RTL-SDR serial numbers in device selector
- #112: Kill all processes now stops Bluetooth scans
- #111: BLE device list no longer overflows container bounds
- #109: WiFi scanner panels maintain minimum width (no more "imploding")
- #108: Radar device hover no longer causes violent shaking
- #106: "Use GPS" button now uses gpsd for USB GPS devices
- #105: Meter trend text no longer overlaps adjacent columns
- #104: dump1090 errors now provide specific troubleshooting guidance

Changes:
- app.py: Add Bluetooth cleanup to /killall endpoint
- routes/adsb.py: Parse dump1090 stderr for specific error messages
- templates/index.html: Show SDR serial numbers in device dropdown
- static/css/index.css: Fix WiFi/BT panel layouts with proper min-width
- static/css/components/signal-cards.css: Fix meter grid overflow
- static/css/components/proximity-viz.css: Fix radar hover transform
- static/css/settings.css: Add GPS detection spinner
- static/js/components/proximity-radar.js: Add invisible hit areas
- static/js/core/settings-manager.js: Use gpsd before browser geolocation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:45:40 +00:00
Smittix 9ac63bd75f Add application restart endpoint for post-update restarts
Adds POST /updater/restart endpoint that gracefully restarts the
application using os.execv. Cleans up all decoder processes and
global state before replacing the process with a fresh instance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:28:32 +00:00
Smittix f795180c7d Release v2.12.1
Bug fixes and improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:16:12 +00:00
Smittix d1f1ce1f4b Add SDR device status panel and ADS-B Bias-T toggle
- Add /devices/status endpoint showing which SDR is in use and by what mode
- Add real-time status panel on main dashboard with 5s auto-refresh
- Add Bias-T toggle to ADS-B dashboard with localStorage persistence
- Auto-detect correct dump1090 bias-t flag (--enable-biast vs unsupported)
- Standardize SDR device labels across all pages

Closes #102

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:36:27 +00:00
Smittix 334073089f Fix SDR device type not synced on page refresh
Initialize currentDeviceList from server-provided deviceList on page load
and auto-select the correct hardware type dropdown value. Previously the
device list was empty until "Refresh Devices" was clicked, causing the
hardware type dropdown to show incorrect values.

Fixes #99

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:26:23 +00:00
Smittix df634dc741 Fix Meshtastic connection type not restored on page refresh
Pass connection_type to updateConnectionUI() in checkStatus() so TCP
connections display correctly after browser refresh instead of defaulting
to Serial.

Fixes #98

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:23:56 +00:00
Smittix a76dfde02d Add SDR device registry to prevent decoder conflicts
Implements centralized tracking of SDR device allocation to prevent
multiple decoders from trying to use the same device simultaneously.

- Add sdr_device_registry with claim/release/status functions in app.py
- Update all SDR-based routes to claim devices on start and release on stop
- Return HTTP 409 with DEVICE_BUSY error when device is already in use
- Clear registry on /killall
- Skip device claims for remote connections (rtl_tcp, remote SBS)

Fixes #100
Fixes #101

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:05:21 +00:00
Smittix 36f8349bc7 Merge pull request #96 from alphafox02/fix-agent-bugs
Fix agent mode issues and WiFi deep scan polling
2026-02-01 12:57:02 +00:00
cemaxecuter 130a3a2d8e Don't stop agent scans when switching agents
When switching between agents in the UI, only stop the UI polling -
don't send a stop command to the agent. Agent scans should continue
running independently. When switching back, checkScanStatus() will
detect the running scan and resume polling.
2026-01-31 08:59:30 -05:00
cemaxecuter bd6fa27970 Detect existing monitor mode when loading agent interfaces
When refreshing agent WiFi interfaces, check if any interface has
type='monitor' and automatically set the monitor status to Active.
Previously the UI only showed Active when monitor was explicitly
enabled via the button.
2026-01-31 08:55:05 -05:00
cemaxecuter 630bc2971a Fix WiFi deep scan polling on agent - normalize scan_type value
Agent returns scan_type 'deepscan' but UI expected 'deep', causing the
polling to immediately stop when checking scan status on agent switch.
Now normalizes 'deepscan' to 'deep' in checkScanStatus.
2026-01-31 08:51:17 -05:00
cemaxecuter 7182f7803a Auto-refresh agent capabilities after monitor mode toggle
When monitor mode is toggled on a remote agent, the controller now
automatically refreshes the agent's capabilities and updates the
database. This keeps the UI interface list in sync without requiring
a manual refresh.
2026-01-31 08:48:32 -05:00
cemaxecuter a64a7c414c Invalidate capabilities cache after monitor mode toggle
After enabling/disabling monitor mode, clear the cached capabilities
so the next refresh shows the updated interface list (e.g., wlo1mon
instead of wlo1).
2026-01-31 08:17:07 -05:00
cemaxecuter f0cc396a6b Fix agent mode issues and WiFi deep scan polling
Agent fixes:
- Fix Ctrl+C hang by running cleanup in background thread
- Add force-exit on double Ctrl+C
- Improve exception handling in output reader threads to prevent
  bad file descriptor errors on shutdown
- Reduce cleanup timeouts for faster shutdown

Controller/UI fixes:
- Add URL validation for agent registration (check port, protocol)
- Show helpful message when agent is unreachable during registration
- Clarify API key field label (reserved for future use)
- Add client-side URL validation with user-friendly error messages

WiFi agent mode fixes:
- Add polling fallback for deep scan when push mode is disabled
- Polls /controller/agents/{id}/wifi/data every 2 seconds
- Detect running scans when switching to an agent
- Fix scan_mode detection (agent uses params.scan_type)
2026-01-31 08:10:32 -05:00
Smittix 5f588a5513 fix: Auto-detect RTL-SDR drivers and blacklist instead of prompting
- Skip RTL-SDR Blog driver prompt if rtl_test already exists
- Skip DVB blacklist prompt if blacklist file already exists
- Only prompt user when configuration is actually needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:08:08 +00:00
Smittix 599df7734b fix: Use Makefile instead of CMake for slowrx build
slowrx uses a simple Makefile, not CMake. Remove unnecessary cmake
dependency and fix the build process.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:02:07 +00:00
Smittix 49fa02142d feat: Add TCP connection support for Meshtastic
Allow connecting to WiFi-enabled Meshtastic devices via TCP/IP in
addition to USB/Serial connections. This enables remote monitoring
of mesh nodes that have WiFi capability (T-Beam, Heltec WiFi LoRa, etc).

- Add connection_type parameter ('serial' or 'tcp') to /meshtastic/start
- Add hostname parameter for TCP connections
- Update UI with connection type dropdown and hostname input field
- Show connection type in status responses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:01:46 +00:00
Smittix 333dc00ee2 fix: Show build errors and add pkg-config for slowrx source builds
- Add pkg-config dependency for cmake to locate libraries
- Display cmake/make error output (last 20 lines) on failure
- Helps users troubleshoot slowrx build failures on Debian/Ubuntu/macOS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:55:28 +00:00
Smittix 2bc71e44ad Merge branch 'upstream-shared-observer-location' 2026-01-30 22:52:17 +00:00
Smittix 92265da5fb fix: Add slowrx source build fallback for Debian/Ubuntu
If slowrx is not available via apt, build from source with required
dependencies (libfftw3-dev, libsndfile1-dev, libgtk-3-dev, libasound2-dev,
libpulse-dev).

Matches the existing fallback pattern used for macOS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:43:39 +00:00
Smittix 9c1516c086 feat: Add real-time Doppler tracking for ISS SSTV reception
- Add DopplerTracker class using skyfield for satellite tracking
- Calculate and apply Doppler shift correction (up to ±3.5 kHz at 145.800 MHz)
- Background thread monitors shift and retunes rtl_fm when >500 Hz drift
- New /sstv/doppler endpoint for real-time Doppler info
- Start endpoint accepts latitude/longitude for automatic tracking

Also:
- Add slowrx installation to setup.sh (source build for macOS, apt for Debian)
- Sync observer location to dashboard-specific localStorage keys

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:40:27 +00:00
Smittix cd7940bdc2 fix: Add TPMS pressure field mappings for 433MHz sensor display
The sensor field mapping only handled pressure_hPa (weather station
barometric pressure), causing TPMS tire pressure data to not display.

Added mappings for TPMS-specific rtl_433 field names:
- pressure_PSI (common in US TPMS sensors)
- pressure_kPa
- tire_pressure_kPa
- flags/state (tire state indicators)

Fixes #95

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:39:01 +00:00
James Ward 4a5f3e1802 docs: document shared location and auto-start env vars 2026-01-30 10:55:01 -08:00
James Ward 1b5bf4c061 fix: make ADS-B auto-start opt-in 2026-01-30 10:51:35 -08:00
James Ward 384d02649a feat: add shared observer location with opt-out 2026-01-30 10:49:53 -08:00
Smittix d51da40a67 Refactor settings modal HTML structure 2026-01-30 17:12:28 +00:00
Smittix 3a6bd3711e release: v2.12.0 - ISS SSTV decoder, update notifications, UI improvements
- Add ISS SSTV decoder mode with real-time tracking globe
- Add GitHub update notifications for new releases
- Enhance Meshtastic with QR codes and telemetry display
- Add new Space category for satellite modes
- Fix SoapySDR detection, dump1090 builds, and Flask compatibility
- Update version numbers and changelog

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:28 +00:00
Smittix d28d371caf feat: UI improvements and Space category
- Add new "Space" category with Satellite and ISS SSTV modes
- Rename "Scanner" to "Listening Post"
- SSTV now uses global SDR device selector
- Meshtastic map markers more visible (stronger glow, larger size)
- CSS layout fixes using flex instead of fixed heights

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:31 +00:00
Smittix 05d96b6077 fix: SoapySDR module detection on macOS with Homebrew
Set DYLD_LIBRARY_PATH and SOAPY_SDR_ROOT environment variables when
running SoapySDRUtil on macOS so Homebrew-installed modules (HackRF,
LimeSDR, etc.) are properly detected.

Fixes #77

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:25 +00:00
Smittix f6197592bb fix: Resolve dump1090 build failure in Docker
Remove -Werror flag and add explicit RTLSDR=yes to prevent build
failures on newer GCC versions in Docker builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:22:32 +00:00
Smittix aca7f56808 fix: Ensure Flask 3.0+ in setup script
System apt packages may install Flask 2.x which is incompatible with
Werkzeug 3.x. Add explicit upgrade after pip install to ensure Flask 3.0+.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:20:21 +00:00
Smittix 872cc806eb fix: Make psycopg2 optional for Flask/Werkzeug compatibility
- Bump Flask requirement to >=3.0.0 (required for Werkzeug 3.x)
- Make psycopg2 import conditional in routes/adsb.py and utils/adsb_history.py
- ADS-B history features gracefully disabled when PostgreSQL libs unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:14 +00:00
Smittix 7b847e0541 fix: Resolve dump1090 build failures on Kali/newer GCC
- Strip -Werror from FlightAware Makefile before building to prevent
  GCC warnings being treated as fatal errors (fixes spinner[4] issue)
- Replace abandoned antirez/dump1090 fallback with actively-maintained
  wiedehopf/readsb

Fixes #92

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:58:58 +00:00
Smittix 17b46a13c2 feat: Auto-update TLE data on app startup
- Add refresh_tle_data() function for reusable TLE updates
- Automatically fetch fresh TLE from CelesTrak when app starts
- Runs in background thread to avoid slowing down startup
- Includes NOAA-20 and NOAA-21 in name mappings
- Gracefully handles failures (uses cached data if offline)
- Existing /update-tle endpoint now uses shared function

This ensures satellite tracking data is always fresh, fixing
inaccurate positions caused by stale TLE data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:25:08 +00:00
Smittix ede3a5841b fix: Use real-time APIs for ISS position in satellite tracking
- Add _fetch_iss_realtime() helper function for real-time ISS position
- Satellite position endpoint now uses real-time API for ISS specifically
- Other satellites still use TLE-based calculations
- ISS orbit track still calculated from TLE (for future/past positions)
- Falls back between Open Notify and Where The ISS At APIs

This ensures the satellite dashboard shows accurate ISS position
while maintaining TLE-based tracking for other satellites.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:20:16 +00:00
Smittix 7270f827a9 fix: Use real-time APIs for ISS position instead of stale TLE
- Fetch live ISS position from Open Notify API (primary)
- Fallback to "Where The ISS At" API if primary fails
- Remove dependency on potentially outdated local TLE data
- Calculate observer elevation/azimuth using spherical geometry
- Both APIs are free and don't require authentication

This fixes the issue where the ISS position was incorrect due to
the local TLE data being almost a year out of date.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:18:20 +00:00
Smittix 468812bc09 feat: Replace SSTV map with Leaflet for accurate ISS tracking
- Use real Leaflet map with proper tile layers (same as satellite section)
- ISS marker with pulsing glow animation
- Ground track orbit line showing ISS path
- Map auto-pans to follow ISS position
- Simplified overlay showing position and next pass info
- Responsive layout that adapts to screen size
- Removed custom canvas rendering and continent data

The Leaflet map uses the same tile provider as other sections,
ensuring the ISS position is accurately displayed on a real map.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:15:32 +00:00
Smittix 7bef63aede feat: Replace 3D globe with accurate 2D world map
- Use simple equirectangular projection for guaranteed accuracy
- Direct linear mapping: lon to x, lat to y (no complex 3D math)
- Show ISS ground track orbit path
- Continent outlines rendered on flat map
- Canvas changed to 300x150 for proper 2:1 aspect ratio
- Updated CSS for rectangular map styling

The 2D map uses a straightforward coordinate transformation
that cannot produce incorrect positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:08:33 +00:00
Smittix 21dec0d53a fix: Correct globe projection orientation
- Fix x-axis mirroring for proper globe viewing orientation
- Adjust rotation formula to use lon - rotation instead of lon + rotation
- Globe now correctly shows landmasses relative to ISS position

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:53:31 +00:00
Smittix 52997b3c78 fix: Correct ISS position projection on globe
- Use actual ISS coordinates with globe rotation instead of fixed lon=0
- Fix orbit trail to use actual longitude offsets from ISS position
- Trail now properly follows behind ISS based on orbital path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:48:10 +00:00
Smittix 765e1384b5 fix: Replace simplified globe continents with accurate geography
Add geographically accurate continent outlines including:
- North America with proper coastline detail (Alaska, Florida, Gulf of Mexico)
- Greenland, Iceland, UK/Ireland as separate landmasses
- Central and South America with accurate shapes
- Europe with Scandinavia separated
- Africa with Madagascar
- Middle East/Arabian Peninsula
- Asia with India, Southeast Asia, Korea, Japan, Taiwan
- Philippines and Indonesia archipelago
- Australia and New Zealand
- Sri Lanka

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:44:22 +00:00
Smittix e18f85370f feat: Add world map with continents to ISS tracking globe
- Added simplified continent outlines (N/S America, Europe, Africa, Asia, Australia)
- Proper 3D orthographic projection with rotation
- Globe rotates to center on ISS position
- Green landmasses on blue ocean background
- ISS shown in yellow/orange with orbit trail
- Lat/lon grid lines properly projected on sphere

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:37:54 +00:00
Smittix a0604a43c0 fix: Globe now rotates to always show ISS position
- Globe view centers on ISS longitude so it's always visible
- Added console logging for debugging position updates
- Increased ISS marker size and glow for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:35:30 +00:00
Smittix 9cb44c6273 fix: Add direct ISS position endpoint for globe tracking
- Add /sstv/iss-position endpoint that calculates ISS position directly
- Update JS to use new endpoint instead of /satellite/position
- Returns lat, lon, altitude, and optionally elevation/azimuth from observer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:31:35 +00:00
Smittix eacf6d4970 fix: Direct ISS pass calculation instead of test_client
The test_client approach was failing silently. Now calculates ISS
passes directly using skyfield within the sstv route.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:29:15 +00:00
Smittix 07ae227cee feat: Add ISS tracking globe and location controls to SSTV mode
- Update TLE data with current orbital elements for accurate predictions
- Add location inputs (lat/lon) and GPS button to SSTV stats strip
- Add TLE update button to fetch latest orbital data from CelesTrak
- Add 3D globe visualization showing real-time ISS position
- Display ISS coordinates and altitude below globe
- Auto-refresh ISS position every 5 seconds
- Add NOAA-15, NOAA-18, NOAA-19 satellites to TLE data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:22:24 +00:00
Smittix 18ef6218d8 fix: SSTV location settings and panel sizing
- Fix GPS button not working (pass button element to handler)
- Hide output element in SSTV mode to allow panels to fill space
- Add explicit height rules for SSTV panels to expand vertically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:03:41 +00:00
Smittix 0c7ac816e9 feat: Add location settings for ISS pass predictions
- Add Location tab to settings modal with lat/lon inputs
- Add GPS detection button for auto-location
- Update SSTV to use saved location for ISS pass predictions
- Fix SSTV panels to use full screen width (remove max-width constraint)
- Improve ISS pass messages to guide users to location settings
- Add checked/last_check fields to update status response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:36:37 +00:00
Smittix 8e204725b2 feat: Add ISS SSTV decoder mode
Add slow-scan television decoder for receiving images from ISS.
Includes new Space dropdown in navigation grouping Satellite and SSTV modes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:06 +00:00
Smittix 40acca20b2 feat: Add GitHub update notifications
- Check for new releases from GitHub API with 6-hour cache
- Show toast notification when updates are available
- Add Updates tab in settings for manual checks and preferences
- Support git-based updates with stash handling for local changes
- Persist dismissed versions to avoid repeated notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:00 +00:00
Smittix ae804f92b2 feat: Enhance Meshtastic mode with QR code support
Add QR code generation for sharing Meshtastic channel configurations.
Add qrcode[pil] dependency for QR code generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:50:53 +00:00
Smittix 0a6effccae fix: Pass bias-T setting to ADS-B and AIS dashboards
The bias-T checkbox on the main dashboard was not being passed to the
ADS-B and AIS tracking start requests. Added getBiasTEnabled() helper
to each dashboard that reads from shared localStorage, and updated all
start request bodies to include bias_t parameter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:51:28 +00:00
Smittix 0cf73b1234 fix: Show disclaimer FIRST before welcome page
- Add inline script in <head> that checks localStorage before page renders
- If disclaimer not accepted, hide welcome page via injected CSS
- Show disclaimer modal on DOMContentLoaded
- After accepting, remove gate CSS and reveal welcome page
- User must accept disclaimer before they can access the application

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:40:15 +00:00
Smittix 8d354755f0 revert: Remove utility bar and fix disclaimer flash issue
Reverts the utility bar feature and disclaimer timing changes that
caused the disclaimer to flash on screen for users who had already
accepted it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:31:39 +00:00
Smittix 166f598386 feat: Fix disclaimer timing and add utility bar to dashboards
- Show disclaimer BEFORE welcome page on first visit (was showing after)
- Add shared utility-bar.html partial with theme, animations, settings, help
- Include utility bar on Aircraft, Satellite, and Vessels dashboards
- Support ?settings=open and ?help=open URL params from dashboards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:27:40 +00:00
Smittix 6e51739654 fix: Remove CSS filter that was inverting dark map tiles
The CSS filter (invert + hue-rotate) was previously used to make light
OSM tiles appear dark. Now that we use actual dark CARTO tiles, this
filter was inverting them back to light. Removed from all dashboards.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:14:07 +00:00
Smittix ec22823e59 feat: Centralize map tile management via Settings manager
- Add Settings.registerMap() to register maps for tile updates
- Add Settings.createTileLayer() to create tile layers from settings
- Update _updateMapTiles() to use registered maps
- Expose all maps to window object for settings manager access
- All dashboards now use Settings manager when available
- Tile provider changes in settings now apply immediately to all maps
- Use Fastly CDN for CARTO tiles (more reliable)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:00:25 +00:00
Smittix 87cd10194f fix: Add cache-busting parameter to tile URLs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:49:47 +00:00
Smittix 933575b480 fix: Remove {r} from CARTO tile URLs for proper dark mode
The {r} retina parameter was causing CARTO to return light/gray tiles
instead of dark tiles. Removed {r} from all tile layer URLs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:44:51 +00:00
Smittix a4218c0c33 fix: Meshtastic traceroute button and dark mode maps
- Fix traceroute button in Meshtastic popups using event delegation
  instead of inline onclick handlers (more reliable with Leaflet)
- Update all maps to use dark CARTO tiles for consistency:
  - ADS-B dashboard radar map
  - AIS dashboard vessel map
  - Satellite dashboard ground map
  - APRS map
  - Satellite ground track map in main UI
- Change settings manager default tile provider to cartodb_dark

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:33:48 +00:00
Smittix c67fa39e30 feat: Add pulsating ring effect for tracked aircraft/vessels
Makes it much clearer which vehicle is being tracked on the map by adding
two animated concentric rings that pulse outward from the selected marker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:07:47 +00:00
Smittix 9f7dc8f995 fix: Adjust ADS-B dashboard height to prevent bottom controls cutoff
Increased viewport height offset from 95px to 115px to account for the
actual combined height of header and stats strip elements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:03:04 +00:00
Smittix d1dd1ad4da fix: Make audio visualizer work without spectrum canvas
The audio visualizer was returning early if audioSpectrumCanvas didn't
exist, preventing the signal level from being fed to the synthesizer.
Now it continues to update currentSignalLevel even without the canvas.

Also added detailed logging to diagnose audio context issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:05:08 +00:00
Smittix c7fdea856d debug: Add signal level logging to synthesizer
Adds console logging and on-canvas display of signal level values to
help diagnose why synthesizer isn't responding to signals.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:01:33 +00:00
Smittix a7307dbf3a fix: Initialize audio visualizer when listening starts
The audio visualizer (Web Audio API analyzer) was not being initialized
when direct listening or scanner signal detection started, so the
synthesizer never received audio level data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:00:50 +00:00
Smittix 55ff644a8a fix: Connect synthesizer visualization to actual signal levels
The synthesizer was showing a decorative animation unrelated to actual
signals. Now it responds to real RMS levels from scanner SSE events and
Web Audio API data during direct listening.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:57:24 +00:00
Smittix 3d90e03ca9 feat: Add Meshtastic telemetry display and traceroute visualization
Add full telemetry display in node popups including device metrics
(voltage, channel utilization, air TX) and environment sensors
(temperature, humidity, barometric pressure).

Add traceroute functionality with interactive visualization showing
hop paths and SNR values. Includes API endpoints for sending traceroutes
and retrieving results, plus a modal UI for displaying route information.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:52:19 +00:00
Smittix 069e87f9ba feat: Add GPS auto-connect for AIS dashboard via gpsd
Automatically connects to gpsd on page load if available. Updates
observer location in real-time with GPS indicator in top bar.
Includes auto-reconnect on visibility change.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:35:49 +00:00
Smittix f3c5d124b5 fix: Sync Meshtastic node count between map and top bar
The map was showing correct node count from API while the top bar
showed 0 because uniqueNodes Set was only populated from messages.
Now loadNodes() adds nodes to uniqueNodes and updates stats.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:35:44 +00:00
Smittix d821e19334 fix: Add serial port discovery for Meshtastic multi-port systems
When multiple serial ports are detected (e.g., /dev/ttyACM0 and /dev/ttyUSB0),
the Meshtastic SDK's auto-detect fails. This adds a /meshtastic/ports endpoint
to list available ports and populates the device dropdown, auto-selecting the
first port when multiple exist.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:24:00 +00:00
Smittix d15b4efc97 feat: Add meter grouping by device ID with consumption trends
Transform flat scrolling meter list into grouped view showing one card
per unique meter with:
- Consumption history tracking and delta from previous reading
- Trend sparkline visualization (color-coded for normal/elevated/spike)
- Consumption rate calculation (units/hour over 30-min window)
- Cards update in place instead of creating duplicates
- Alert sound only plays for new meters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:56:43 +00:00
Smittix a3ad49a441 feat: Add device intelligence and manufacturer info for utility meters
- Add getMeterTypeInfo() with ERT endpoint type lookups for utility type
  (Electric/Gas/Water) and manufacturer (Itron, Landis+Gyr, Neptune, etc.)
- Hook addRtlamrReading into trackDevice() for Device Intelligence panel
- Add meter protocol handling to generateDeviceId()
- Display manufacturer and utility type on meter cards
- Show utility type as badge, manufacturer in meta row and details panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:26:18 +00:00
Smittix fb95e465a3 feat: Add logo link and fix welcome modal box heights
- Make logo clickable, opens GitHub Pages in new tab
- Match What's New box height to Select Mode box

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:45:17 +00:00
Smittix ab0a03b313 docs: Update main screenshot for v2.10.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:35:46 +00:00
Smittix f396ff7b66 feat: Add map marker highlighting for selected aircraft in ADSB
When clicking an aircraft in the sidebar, its map marker now shows
an enhanced white glow (10px) to distinguish it from other markers.
This matches the existing behavior in AIS mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:34:53 +00:00
Smittix 52cb47e5c9 refactor: Consolidate settings and dependencies into single modal
Merged the two gear icons in the header bar into one unified Settings modal.
Added a "Tools" tab to display dependency status, removing the separate
dependencies modal and button.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:30:08 +00:00
Smittix 003b44c62e docs: Update dashboard and main images for GitHub Pages
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:21:20 +00:00
Smittix 92caef5cb7 fix: Correct JetBrains status element ID in settings modal
The JavaScript checks for 'statusJetbrains' but the HTML had
'statusJetBrains' causing the status check to fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:16:15 +00:00
111 changed files with 41008 additions and 21119 deletions
+44
View File
@@ -2,6 +2,50 @@
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [2.12.0] - 2026-01-29
### Added
- **ISS SSTV Decoder Mode** - Receive Slow Scan Television transmissions from the ISS
- Real-time ISS tracking globe with accurate position via N2YO API
- Leaflet world map showing ISS ground track and current position
- Location settings for ISS pass predictions
- Integration with satellite tracking TLE data
- **GitHub Update Notifications** - Automatic new version alerts
- Checks for updates on app startup
- Unobtrusive notification when new releases are available
- Configurable check interval via settings
- **Meshtastic Enhancements**
- QR code support for easy device sharing
- Telemetry display with battery, voltage, and environmental data
- Traceroute visualization for mesh network topology
- Improved node synchronization between map and top bar
- **UI Improvements**
- New Space category for satellite and ISS-related modes
- Pulsating ring effect for tracked aircraft/vessels
- Map marker highlighting for selected aircraft in ADS-B
- Consolidated settings and dependencies into single modal
- **Auto-Update TLE Data** - Satellite tracking data updates automatically on app startup
- **GPS Auto-Connect** - AIS dashboard now connects to gpsd automatically
### Changed
- **Utility Meters** - Added device grouping by ID with consumption trends
- **Utility Meters** - Device intelligence and manufacturer information display
### Fixed
- **SoapySDR** - Module detection on macOS with Homebrew
- **dump1090** - Build failures in Docker containers
- **dump1090** - Build failures on Kali Linux and newer GCC versions
- **Flask** - Ensure Flask 3.0+ compatibility in setup script
- **psycopg2** - Now optional for Flask/Werkzeug compatibility
- **Bias-T** - Setting now properly passed to ADS-B and AIS dashboards
- **Dark Mode Maps** - Removed CSS filter that was inverting dark tiles
- **Map Tiles** - Fixed CARTO tile URLs and added cache-busting
- **Meshtastic** - Traceroute button and dark mode map fixes
- **ADS-B Dashboard** - Height adjustment to prevent bottom controls cutoff
- **Audio Visualizer** - Now works without spectrum canvas
---
## [2.11.0] - 2026-01-28 ## [2.11.0] - 2026-01-28
### Added ### Added
+2 -1
View File
@@ -67,7 +67,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& cd /tmp \ && cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \ && cd dump1090 \
&& make \ && sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /usr/bin/dump1090-fa \ && cp dump1090 /usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \ && ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
&& rm -rf /tmp/dump1090 \ && rm -rf /tmp/dump1090 \
+53 -12
View File
@@ -63,18 +63,59 @@ cd intercept
docker compose up -d docker compose up -d
``` ```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options. > **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### ADS-B History (Optional) ### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis. The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
```bash ```bash
# Start with ADS-B history and Postgres # Start with ADS-B history and Postgres
docker compose --profile history up -d docker compose --profile history up -d
``` ```
Then open **/adsb/history** for the reporting dashboard. Set the following environment variables (for example in a `.env` file):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
Then open **/adsb/history** for the reporting dashboard.
### Open the Interface ### Open the Interface
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"version": "2026-01-11_fae1348c", "version": "2026-02-01_ba81b697",
"downloaded": "2026-01-12T15:55:42.769654Z" "downloaded": "2026-02-04T17:06:54.806043Z"
} }
+133 -8
View File
@@ -27,7 +27,7 @@ from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory from utils.sdr import SDRFactory
@@ -38,6 +38,7 @@ from utils.constants import (
MAX_BT_DEVICE_AGE_SECONDS, MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS, MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS, MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_DEAUTH_ALERTS_AGE_SECONDS,
QUEUE_MAX_SIZE, QUEUE_MAX_SIZE,
) )
import logging import logging
@@ -175,6 +176,11 @@ dsc_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock() tscm_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
deauth_detector_lock = threading.Lock()
# ============================================ # ============================================
# GLOBAL STATE DICTIONARIES # GLOBAL STATE DICTIONARIES
# ============================================ # ============================================
@@ -204,6 +210,9 @@ ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessel
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup # DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages') dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
# Deauth alerts - using DataStore for automatic cleanup
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
# Satellite state # Satellite state
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated) satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
@@ -215,6 +224,53 @@ cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft) cleanup_manager.register(adsb_aircraft)
cleanup_manager.register(ais_vessels) cleanup_manager.register(ais_vessels)
cleanup_manager.register(dsc_messages) cleanup_manager.register(dsc_messages)
cleanup_manager.register(deauth_alerts)
# ============================================
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode.
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
Returns:
Error message if device is in use, None if successfully claimed
"""
with sdr_device_registry_lock:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
sdr_device_registry[device_index] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
"""
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
# ============================================ # ============================================
@@ -222,9 +278,13 @@ cleanup_manager.register(dsc_messages)
# ============================================ # ============================================
@app.before_request @app.before_request
def require_login(): def require_login():
# Routes that don't require login (to avoid infinite redirect loop) # Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
# Allow audio streaming endpoints without session auth
if request.path.startswith('/listening/audio/'):
return None
# Controller API endpoints use API key auth, not session auth # Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login # Allow agent push/pull endpoints without session login
@@ -279,7 +339,14 @@ def index() -> str:
'rtlamr': check_tool('rtlamr') 'rtlamr': check_tool('rtlamr')
} }
devices = [d.to_dict() for d in SDRFactory.detect_devices()] devices = [d.to_dict() for d in SDRFactory.detect_devices()]
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG) return render_template(
'index.html',
tools=tools,
devices=devices,
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@app.route('/favicon.svg') @app.route('/favicon.svg')
@@ -294,6 +361,22 @@ def get_devices() -> Response:
return jsonify([d.to_dict() for d in devices]) return jsonify([d.to_dict() for d in devices])
@app.route('/devices/status')
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
result.append(d)
return jsonify(result)
@app.route('/devices/debug') @app.route('/devices/debug')
def get_devices_debug() -> Response: def get_devices_debug() -> Response:
"""Get detailed SDR device detection diagnostics.""" """Get detailed SDR device detection diagnostics."""
@@ -566,19 +649,21 @@ def health_check() -> Response:
@app.route('/killall', methods=['POST']) @app.route('/killall', methods=['POST'])
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder and WiFi processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
# Import adsb and ais modules to reset their state # Import adsb and ais modules to reset their state
from routes import adsb as adsb_module from routes import adsb as adsb_module
from routes import ais as ais_module from routes import ais as ais_module
from utils.bluetooth import reset_bluetooth_scanner
killed = [] killed = []
processes_to_kill = [ processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433', 'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng', 'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher' 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl'
] ]
for proc in processes_to_kill: for proc in processes_to_kill:
@@ -622,6 +707,30 @@ def kill_all() -> Response:
dsc_process = None dsc_process = None
dsc_rtl_process = None dsc_rtl_process = None
# Reset Bluetooth state (legacy)
with bt_lock:
if bt_process:
try:
bt_process.terminate()
bt_process.wait(timeout=2)
except Exception:
try:
bt_process.kill()
except Exception:
pass
bt_process = None
# Reset Bluetooth v2 scanner
try:
reset_bluetooth_scanner()
killed.append('bluetooth_scanner')
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
return jsonify({'status': 'killed', 'processes': killed}) return jsonify({'status': 'killed', 'processes': killed})
@@ -714,6 +823,22 @@ def main() -> None:
from routes import register_blueprints from routes import register_blueprints
register_blueprints(app) register_blueprints(app)
# Update TLE data in background thread (non-blocking)
def update_tle_background():
try:
from routes.satellite import refresh_tle_data
print("Updating satellite TLE data from CelesTrak...")
updated = refresh_tle_data()
if updated:
print(f"TLE data updated for: {', '.join(updated)}")
else:
print("TLE update: No satellites updated (may be offline)")
except Exception as e:
print(f"TLE update failed (will use cached data): {e}")
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
tle_thread.start()
# Initialize WebSocket for audio streaming # Initialize WebSocket for audio streaming
try: try:
from routes.audio_websocket import init_audio_websocket from routes.audio_websocket import init_audio_websocket
+46 -11
View File
@@ -7,10 +7,46 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.11.0" VERSION = "2.13.1"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.13.1",
"date": "February 2026",
"highlights": [
"Help modal system with keyboard shortcuts reference",
"Main Dashboard button in navigation bar",
"Settings modal accessible from all dashboards",
"Dashboard CSS improvements and consistency fixes",
]
},
{
"version": "2.13.0",
"date": "February 2026",
"highlights": [
"WiFi client display in AP detail drawer",
"Real-time client updates via SSE streaming",
"Probed SSID badges for connected clients",
]
},
{
"version": "2.12.1",
"date": "February 2026",
"highlights": [
"Bug fixes and improvements",
]
},
{
"version": "2.12.0",
"date": "January 2026",
"highlights": [
"ISS SSTV decoder with real-time ISS tracking globe",
"GitHub update notifications for new releases",
"Meshtastic QR code support and telemetry display",
"New Space category with reorganized UI",
]
},
{ {
"version": "2.11.0", "version": "2.11.0",
"date": "January 2026", "date": "January 2026",
@@ -61,16 +97,6 @@ CHANGELOG = [
"Risk scoring and threat classification", "Risk scoring and threat classification",
] ]
}, },
{
"version": "2.7.0",
"date": "November 2025",
"highlights": [
"Multi-SDR hardware support via SoapySDR",
"LimeSDR, HackRF, Airspy, SDRplay support",
"Improved aircraft database with photo lookup",
"GPS auto-detection and integration",
]
},
] ]
@@ -139,6 +165,7 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
# ADS-B settings # ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003) ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0) ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False) ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost') ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432) ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
@@ -149,11 +176,19 @@ ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0) ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000) ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
# Satellite settings # Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30) SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30) SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45) SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Admin credentials # Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin') ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin') ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
+21 -10
View File
@@ -1,18 +1,29 @@
# TLE data for satellite tracking (updated periodically) # TLE data for satellite tracking (updated periodically)
# To update: click "Update TLE" in satellite dashboard or SSTV mode
# Data source: CelesTrak (celestrak.org)
TLE_SATELLITES = { TLE_SATELLITES = {
'ISS': ('ISS (ZARYA)', 'ISS': ('ISS (ZARYA)',
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000', '1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'), '2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
'NOAA-15': ('NOAA 15',
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
'NOAA-18': ('NOAA 18',
'1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996',
'2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'),
'NOAA-19': ('NOAA 19',
'1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998',
'2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'),
'NOAA-20': ('NOAA 20 (JPSS-1)', 'NOAA-20': ('NOAA 20 (JPSS-1)',
'1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', '1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'), '2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
'NOAA-21': ('NOAA 21 (JPSS-2)', 'NOAA-21': ('NOAA 21 (JPSS-2)',
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', '1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'), '2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
'METEOR-M2': ('METEOR-M 2', 'METEOR-M2': ('METEOR-M 2',
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', '1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'), '2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
'METEOR-M2-3': ('METEOR-M2 3', 'METEOR-M2-3': ('METEOR-M2 3',
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', '1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'), '2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
} }
+8
View File
@@ -36,6 +36,10 @@ services:
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb # - INTERCEPT_ADSB_DB_NAME=intercept_adsb
# - INTERCEPT_ADSB_DB_USER=intercept # - INTERCEPT_ADSB_DB_USER=intercept
# - INTERCEPT_ADSB_DB_PASSWORD=intercept # - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Network mode for WiFi scanning (requires host network) # Network mode for WiFi scanning (requires host network)
# network_mode: host # network_mode: host
restart: unless-stopped restart: unless-stopped
@@ -68,6 +72,10 @@ services:
- INTERCEPT_ADSB_DB_NAME=intercept_adsb - INTERCEPT_ADSB_DB_NAME=intercept_adsb
- INTERCEPT_ADSB_DB_USER=intercept - INTERCEPT_ADSB_DB_USER=intercept
- INTERCEPT_ADSB_DB_PASSWORD=intercept - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"] test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
+608
View File
@@ -0,0 +1,608 @@
# iNTERCEPT UI Guide
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
## Table of Contents
1. [Design Tokens](#design-tokens)
2. [Base Templates](#base-templates)
3. [Navigation](#navigation)
4. [Components](#components)
5. [Adding a New Module Page](#adding-a-new-module-page)
6. [Adding a New Dashboard](#adding-a-new-dashboard)
---
## Design Tokens
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
### Colors
```css
/* Backgrounds (layered depth) */
--bg-primary: #0a0c10; /* Darkest - page background */
--bg-secondary: #0f1218; /* Panels, sidebars */
--bg-tertiary: #151a23; /* Cards, elevated elements */
--bg-card: #121620; /* Card backgrounds */
--bg-elevated: #1a202c; /* Hover states, modals */
/* Accent Colors */
--accent-cyan: #4a9eff; /* Primary action color */
--accent-green: #22c55e; /* Success, online status */
--accent-red: #ef4444; /* Error, danger, stop */
--accent-orange: #f59e0b; /* Warning */
--accent-amber: #d4a853; /* Secondary highlight */
/* Text Hierarchy */
--text-primary: #e8eaed; /* Main content */
--text-secondary: #9ca3af; /* Secondary content */
--text-dim: #4b5563; /* Disabled, placeholder */
--text-muted: #374151; /* Barely visible */
/* Status Colors */
--status-online: #22c55e;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-offline: #6b7280;
```
### Spacing Scale
```css
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
```
### Typography
```css
/* Font Families */
--font-sans: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Font Sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
```
### Border Radius
```css
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
```
### Light Theme
The design system supports light/dark themes via `data-theme` attribute:
```html
<html data-theme="dark"> <!-- or "light" -->
```
Toggle with JavaScript:
```javascript
document.documentElement.setAttribute('data-theme', 'light');
```
---
## Base Templates
### `templates/layout/base.html`
The main base template for standard pages. Use for pages with sidebar + content layout.
```html
{% extends 'layout/base.html' %}
{% block title %}My Page Title{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
{% endblock %}
{% block navigation %}
{% set active_mode = 'mymode' %}
{% include 'partials/nav.html' %}
{% endblock %}
{% block sidebar %}
<div class="app-sidebar">
<!-- Sidebar content -->
</div>
{% endblock %}
{% block content %}
<div class="page-container">
<h1>Page Title</h1>
<!-- Page content -->
</div>
{% endblock %}
{% block scripts %}
<script>
// Page-specific JavaScript
</script>
{% endblock %}
```
### `templates/layout/base_dashboard.html`
Extended base for full-screen dashboards (maps, visualizations).
```html
{% extends 'layout/base_dashboard.html' %}
{% set active_mode = 'mydashboard' %}
{% block dashboard_title %}MY DASHBOARD{% endblock %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
{% endblock %}
{% block stats_strip %}
<div class="stats-strip">
<!-- Stats bar content -->
</div>
{% endblock %}
{% block dashboard_content %}
<div class="dashboard-map-container">
<!-- Main visualization -->
</div>
<div class="dashboard-sidebar">
<!-- Sidebar panels -->
</div>
{% endblock %}
```
---
## Navigation
### Including Navigation
```html
{% set active_mode = 'pager' %}
{% include 'partials/nav.html' %}
```
### Valid `active_mode` Values
| Mode | Description |
|------|-------------|
| `pager` | Pager decoding |
| `sensor` | 433MHz sensors |
| `rtlamr` | Utility meters |
| `adsb` | Aircraft tracking |
| `ais` | Vessel tracking |
| `aprs` | Amateur radio |
| `wifi` | WiFi scanning |
| `bluetooth` | Bluetooth scanning |
| `tscm` | Counter-surveillance |
| `satellite` | Satellite tracking |
| `sstv` | ISS SSTV |
| `listening` | Listening post |
| `spystations` | Spy stations |
| `meshtastic` | Mesh networking |
### Navigation Groups
The navigation is organized into groups:
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
- **Wireless**: WiFi, Bluetooth
- **Security**: TSCM
- **Space**: Satellite, ISS SSTV
---
## Components
### Card / Panel
```html
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
<p>Panel content here</p>
{% endcall %}
```
Or manually:
```html
<div class="panel">
<div class="panel-header">
<span>PANEL TITLE</span>
<div class="panel-indicator active"></div>
</div>
<div class="panel-content">
<p>Content here</p>
</div>
</div>
```
### Empty State
```html
{% include 'components/empty_state.html' with context %}
{# Or with variables: #}
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
{% include 'components/empty_state.html' %}
{% endwith %}
```
### Loading State
```html
{# Inline spinner #}
{% include 'components/loading.html' %}
{# With text #}
{% with text='Loading data...', size='lg' %}
{% include 'components/loading.html' %}
{% endwith %}
{# Full overlay #}
{% with overlay=true, text='Please wait...' %}
{% include 'components/loading.html' %}
{% endwith %}
```
### Status Badge
```html
{% with status='online', text='Connected', id='connectionStatus' %}
{% include 'components/status_badge.html' %}
{% endwith %}
```
Status values: `online`, `offline`, `warning`, `error`, `inactive`
### Buttons
```html
<!-- Primary action -->
<button class="btn btn-primary">Start Tracking</button>
<!-- Secondary action -->
<button class="btn btn-secondary">Cancel</button>
<!-- Danger action -->
<button class="btn btn-danger">Stop</button>
<!-- Ghost/subtle -->
<button class="btn btn-ghost">Settings</button>
<!-- Sizes -->
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary btn-lg">Large</button>
<!-- Icon button -->
<button class="btn btn-icon btn-secondary">
<span class="icon">...</span>
</button>
```
### Badges
```html
<span class="badge">Default</span>
<span class="badge badge-primary">Primary</span>
<span class="badge badge-success">Online</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-danger">Error</span>
```
### Form Groups
```html
<div class="form-group">
<label for="frequency">Frequency (MHz)</label>
<input type="text" id="frequency" value="153.350">
<span class="form-help">Enter frequency in MHz</span>
</div>
<div class="form-group">
<label for="gain">Gain</label>
<select id="gain">
<option value="auto">Auto</option>
<option value="30">30 dB</option>
</select>
</div>
<label class="form-check">
<input type="checkbox" id="alerts">
<span>Enable alerts</span>
</label>
```
### Stats Strip
Used in dashboards for horizontal statistics display:
```html
<div class="stats-strip">
<div class="stats-strip-inner">
<div class="strip-stat">
<span class="strip-value" id="count">0</span>
<span class="strip-label">COUNT</span>
</div>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot active" id="statusDot"></div>
<span id="statusText">TRACKING</span>
</div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
</div>
</div>
```
---
## Adding a New Module Page
### 1. Create the Route
In `routes/mymodule.py`:
```python
from flask import Blueprint, render_template
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
@mymodule_bp.route('/dashboard')
def dashboard():
return render_template('mymodule_dashboard.html',
offline_settings=get_offline_settings())
```
### 2. Register the Blueprint
In `routes/__init__.py`:
```python
from routes.mymodule import mymodule_bp
app.register_blueprint(mymodule_bp)
```
### 3. Create the Template
Option A: Simple page extending base.html
```html
{% extends 'layout/base.html' %}
{% set active_mode = 'mymodule' %}
{% block title %}My Module{% endblock %}
{% block navigation %}
{% include 'partials/nav.html' %}
{% endblock %}
{% block content %}
<!-- Your content -->
{% endblock %}
```
Option B: Full-screen dashboard
```html
{% extends 'layout/base_dashboard.html' %}
{% set active_mode = 'mymodule' %}
{% block dashboard_title %}MY MODULE{% endblock %}
{% block dashboard_content %}
<!-- Your dashboard content -->
{% endblock %}
```
### 4. Add to Navigation
In `templates/partials/nav.html`, add your module to the appropriate group:
```html
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
onclick="switchMode('mymodule')">
<span class="nav-icon icon"><!-- SVG icon --></span>
<span class="nav-label">My Module</span>
</button>
```
Or if it's a dashboard link:
```html
<a href="/mymodule/dashboard"
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
style="text-decoration: none;">
<span class="nav-icon icon"><!-- SVG icon --></span>
<span class="nav-label">My Module</span>
</a>
```
### 5. Create Stylesheet
In `static/css/mymodule.css`:
```css
/**
* My Module Styles
*/
@import url('./core/variables.css');
/* Your styles using design tokens */
.mymodule-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
```
---
## Adding a New Dashboard
For full-screen dashboards like ADSB, AIS, or Satellite:
### 1. Create the Template
```html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MY DASHBOARD // iNTERCEPT</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<!-- Design tokens (required) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<!-- Fonts -->
{% 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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% endif %}
<!-- External libraries if needed -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Dashboard styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
</head>
<body>
<!-- Background effects -->
<div class="radar-bg"></div>
<div class="scanline"></div>
<!-- Header -->
<header class="header">
<div class="logo">
<a href="/" style="color: inherit; text-decoration: none;">
MY DASHBOARD
<span>// iNTERCEPT</span>
</a>
</div>
<div class="status-bar">
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
<a href="/" class="back-link">Main Dashboard</a>
</div>
</header>
<!-- Unified Navigation -->
{% set active_mode = 'mydashboard' %}
{% include 'partials/nav.html' %}
<!-- Stats Strip -->
<div class="stats-strip">
<!-- Stats content -->
</div>
<!-- Main Dashboard Content -->
<main class="dashboard">
<!-- Your dashboard layout -->
</main>
<script>
// Dashboard JavaScript
</script>
</body>
</html>
```
### 2. Create the Stylesheet
```css
/**
* My Dashboard Styles
*/
@import url('./core/variables.css');
:root {
/* Dashboard-specific aliases */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--bg-card: var(--bg-tertiary);
--grid-line: rgba(74, 158, 255, 0.08);
}
/* Your dashboard styles */
```
---
## Best Practices
### DO
- Use design tokens for all colors, spacing, and typography
- Include the nav partial on all pages for consistent navigation
- Set `active_mode` before including the nav partial
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
- Support both light and dark themes
- Test on mobile viewports
### DON'T
- Hardcode color values - use CSS variables
- Create new color variations without adding to tokens
- Duplicate navigation markup - use the partial
- Skip the favicon and design tokens imports
- Use inline styles for layout (use utility classes)
---
## File Structure
```
templates/
├── layout/
│ ├── base.html # Standard page base
│ └── base_dashboard.html # Dashboard page base
├── partials/
│ ├── nav.html # Unified navigation
│ ├── page_header.html # Page title component
│ └── settings-modal.html # Settings modal
├── components/
│ ├── card.html # Panel/card component
│ ├── empty_state.html # Empty state placeholder
│ ├── loading.html # Loading spinner
│ ├── stats_strip.html # Stats bar component
│ └── status_badge.html # Status indicator
├── index.html # Main dashboard
├── adsb_dashboard.html # Aircraft tracking
├── ais_dashboard.html # Vessel tracking
└── satellite_dashboard.html # Satellite tracking
static/css/
├── core/
│ ├── variables.css # Design tokens
│ ├── base.css # Reset & typography
│ ├── components.css # Component styles
│ └── layout.css # Layout styles
├── index.css # Main dashboard styles
├── adsb_dashboard.css # Aircraft dashboard
├── ais_dashboard.css # Vessel dashboard
├── satellite_dashboard.css # Satellite dashboard
└── responsive.css # Responsive breakpoints
```
+76 -43
View File
@@ -61,55 +61,88 @@ INTERCEPT automatically detects known trackers:
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb) 1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed 2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source: 3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly - **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS) - **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates - **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception - **Shared Location** - By default, the observer location is shared across modules
5. **View Map** - Aircraft appear on the interactive Leaflet map (disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information 6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering 7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only 8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view 9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks ### Emergency Squawks
The system highlights aircraft transmitting emergency squawks: The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack - **7500** - Hijack
- **7600** - Radio failure - **7600** - Radio failure
- **7700** - General emergency - **7700** - General emergency
## ADS-B History (Optional) ## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting. The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History ### Enable History
Set the following environment variables (Docker recommended): Set the following environment variables (Docker recommended):
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting | | `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) | | `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port | | `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name | | `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user | | `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password | | `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Docker Setup ### Other ADS-B Settings
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage: | Variable | Default | Description |
|----------|---------|-------------|
```bash | `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
docker compose up -d | `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
```
**Local install example**
### Using the History Dashboard
```bash
1. Open **/adsb/history** INTERCEPT_ADSB_AUTO_START=true \
2. Use **Start Tracking** to run ADS-B in headless mode INTERCEPT_SHARED_OBSERVER_LOCATION=false \
3. View aircraft history and timelines python app.py
4. Stop tracking when desired (session history is recorded) ```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
## Satellite Mode ## Satellite Mode
Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 KiB

After

Width:  |  Height:  |  Size: 694 KiB

+7 -1
View File
@@ -35,7 +35,7 @@
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div class="stat"> <div class="stat">
<span class="stat-value">12+</span> <span class="stat-value">15+</span>
<span class="stat-label">Modes</span> <span class="stat-label">Modes</span>
</div> </div>
<div class="stat"> <div class="stat">
@@ -142,6 +142,12 @@
<h3>Meshtastic</h3> <h3>Meshtastic</h3>
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p> <p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div> </div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div>
</div> </div>
</div> </div>
</section> </section>
+279 -41
View File
@@ -872,6 +872,150 @@ class ModeManager:
return data return data
# =========================================================================
# WiFi Monitor Mode
# =========================================================================
def toggle_monitor_mode(self, params: dict) -> dict:
"""Enable or disable monitor mode on a WiFi interface."""
import re
action = params.get('action', 'start')
interface = params.get('interface', '')
kill_processes = params.get('kill_processes', False)
# Validate interface name (alphanumeric, underscore, dash only)
if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface):
return {'status': 'error', 'message': 'Invalid interface name'}
airmon_path = self._get_tool_path('airmon-ng')
iw_path = self._get_tool_path('iw')
if action == 'start':
if airmon_path:
try:
# Get interfaces before
def get_wireless_interfaces():
interfaces = set()
try:
for iface in os.listdir('/sys/class/net'):
if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface:
interfaces.add(iface)
except OSError:
pass
return interfaces
interfaces_before = get_wireless_interfaces()
# Kill interfering processes if requested
if kill_processes:
subprocess.run([airmon_path, 'check', 'kill'],
capture_output=True, timeout=10)
# Start monitor mode
result = subprocess.run([airmon_path, 'start', interface],
capture_output=True, text=True, timeout=15)
output = result.stdout + result.stderr
time.sleep(1)
interfaces_after = get_wireless_interfaces()
# Find the new monitor interface
new_interfaces = interfaces_after - interfaces_before
monitor_iface = None
if new_interfaces:
for iface in new_interfaces:
if 'mon' in iface:
monitor_iface = iface
break
if not monitor_iface:
monitor_iface = list(new_interfaces)[0]
# Try to parse from airmon-ng output
if not monitor_iface:
patterns = [
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
]
for pattern in patterns:
match = re.search(pattern, output, re.IGNORECASE)
if match:
candidate = match.group(1)
if candidate and not candidate[0].isdigit():
monitor_iface = candidate
break
# Fallback: check if original interface is in monitor mode
if not monitor_iface:
try:
result = subprocess.run(['iwconfig', interface],
capture_output=True, text=True, timeout=5)
if 'Mode:Monitor' in result.stdout:
monitor_iface = interface
except (subprocess.SubprocessError, OSError):
pass
# Last resort: try common naming
if not monitor_iface:
potential = interface + 'mon'
if os.path.exists(f'/sys/class/net/{potential}'):
monitor_iface = potential
if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'):
all_wireless = list(get_wireless_interfaces())
return {
'status': 'error',
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
}
self.wifi_monitor_interface = monitor_iface
self._capabilities = None # Invalidate cache so interfaces refresh
logger.info(f"Monitor mode enabled on {monitor_iface}")
return {'status': 'success', 'monitor_interface': monitor_iface}
except Exception as e:
logger.error(f"Error enabling monitor mode: {e}")
return {'status': 'error', 'message': str(e)}
elif iw_path:
try:
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
self.wifi_monitor_interface = interface
self._capabilities = None # Invalidate cache
return {'status': 'success', 'monitor_interface': interface}
except Exception as e:
return {'status': 'error', 'message': str(e)}
else:
return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'}
else: # stop
current_iface = getattr(self, 'wifi_monitor_interface', None) or interface
if airmon_path:
try:
subprocess.run([airmon_path, 'stop', current_iface],
capture_output=True, text=True, timeout=15)
self.wifi_monitor_interface = None
self._capabilities = None # Invalidate cache
return {'status': 'success', 'message': 'Monitor mode disabled'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
elif iw_path:
try:
subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True)
subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True)
subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True)
self.wifi_monitor_interface = None
self._capabilities = None # Invalidate cache
return {'status': 'success', 'message': 'Monitor mode disabled'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
return {'status': 'error', 'message': 'Unknown action'}
# ========================================================================= # =========================================================================
# Mode-specific implementations # Mode-specific implementations
# ========================================================================= # =========================================================================
@@ -914,26 +1058,34 @@ class ModeManager:
"""Internal mode stop - terminates processes and cleans up.""" """Internal mode stop - terminates processes and cleans up."""
logger.info(f"Stopping mode {mode}") logger.info(f"Stopping mode {mode}")
# Signal stop # Signal stop first - this unblocks any waiting threads
if mode in self.stop_events: if mode in self.stop_events:
self.stop_events[mode].set() self.stop_events[mode].set()
# Terminate process if running # Terminate process if running
if mode in self.processes: if mode in self.processes:
proc = self.processes[mode] proc = self.processes[mode]
if proc and proc.poll() is None: try:
proc.terminate() if proc and proc.poll() is None:
try: proc.terminate()
proc.wait(timeout=3) try:
except subprocess.TimeoutExpired: proc.wait(timeout=2)
proc.kill() except subprocess.TimeoutExpired:
proc.kill()
try:
proc.wait(timeout=1)
except Exception:
pass
except (OSError, ProcessLookupError) as e:
# Process already dead or inaccessible
logger.debug(f"Process cleanup for {mode}: {e}")
del self.processes[mode] del self.processes[mode]
# Wait for output thread # Wait for output thread (short timeout since stop event is set)
if mode in self.output_threads: if mode in self.output_threads:
thread = self.output_threads[mode] thread = self.output_threads[mode]
if thread and thread.is_alive(): if thread and thread.is_alive():
thread.join(timeout=2) thread.join(timeout=1)
del self.output_threads[mode] del self.output_threads[mode]
# Clean up # Clean up
@@ -1137,10 +1289,16 @@ class ModeManager:
except json.JSONDecodeError: except json.JSONDecodeError:
pass # Not JSON, ignore pass # Not JSON, ignore
except (OSError, ValueError) as e:
# Bad file descriptor or closed file - process was terminated
logger.debug(f"Sensor output reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"Sensor output reader error: {e}") logger.error(f"Sensor output reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("Sensor output reader stopped") logger.info("Sensor output reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2102,15 +2260,24 @@ class ModeManager:
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}") logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
except (OSError, ValueError) as e:
# Bad file descriptor or closed file - process was terminated
logger.debug(f"Pager reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"Pager reader error: {e}") logger.error(f"Pager reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
if 'pager_rtl' in self.processes: if 'pager_rtl' in self.processes:
rtl_proc = self.processes['pager_rtl'] try:
if rtl_proc.poll() is None: rtl_proc = self.processes['pager_rtl']
rtl_proc.terminate() if rtl_proc.poll() is None:
del self.processes['pager_rtl'] rtl_proc.terminate()
del self.processes['pager_rtl']
except Exception:
pass
logger.info("Pager reader stopped") logger.info("Pager reader stopped")
def _parse_pager_message(self, line: str) -> dict | None: def _parse_pager_message(self, line: str) -> dict | None:
@@ -2492,10 +2659,15 @@ class ModeManager:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
except (OSError, ValueError) as e:
logger.debug(f"ACARS reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"ACARS reader error: {e}") logger.error(f"ACARS reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("ACARS reader stopped") logger.info("ACARS reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2632,15 +2804,23 @@ class ModeManager:
logger.debug(f"APRS: {callsign}") logger.debug(f"APRS: {callsign}")
except (OSError, ValueError) as e:
logger.debug(f"APRS reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"APRS reader error: {e}") logger.error(f"APRS reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
if 'aprs_rtl' in self.processes: if 'aprs_rtl' in self.processes:
rtl_proc = self.processes['aprs_rtl'] try:
if rtl_proc.poll() is None: rtl_proc = self.processes['aprs_rtl']
rtl_proc.terminate() if rtl_proc.poll() is None:
del self.processes['aprs_rtl'] rtl_proc.terminate()
del self.processes['aprs_rtl']
except Exception:
pass
logger.info("APRS reader stopped") logger.info("APRS reader stopped")
def _parse_aprs_packet(self, line: str) -> dict | None: def _parse_aprs_packet(self, line: str) -> dict | None:
@@ -2788,15 +2968,23 @@ class ModeManager:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
except (OSError, ValueError) as e:
logger.debug(f"RTLAMR reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"RTLAMR reader error: {e}") logger.error(f"RTLAMR reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
if 'rtlamr_tcp' in self.processes: if 'rtlamr_tcp' in self.processes:
tcp_proc = self.processes['rtlamr_tcp'] try:
if tcp_proc.poll() is None: tcp_proc = self.processes['rtlamr_tcp']
tcp_proc.terminate() if tcp_proc.poll() is None:
del self.processes['rtlamr_tcp'] tcp_proc.terminate()
del self.processes['rtlamr_tcp']
except Exception:
pass
logger.info("RTLAMR reader stopped") logger.info("RTLAMR reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2901,10 +3089,15 @@ class ModeManager:
except ImportError: except ImportError:
logger.warning("DSCDecoder not available (missing scipy/numpy)") logger.warning("DSCDecoder not available (missing scipy/numpy)")
except (OSError, ValueError) as e:
logger.debug(f"DSC reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"DSC reader error: {e}") logger.error(f"DSC reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("DSC reader stopped") logger.info("DSC reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -3629,6 +3822,12 @@ class InterceptAgentHandler(BaseHTTPRequestHandler):
config.push_interval = int(body['push_interval']) config.push_interval = int(body['push_interval'])
self._send_json({'status': 'updated', 'config': config.to_dict()}) self._send_json({'status': 'updated', 'config': config.to_dict()})
elif path == '/wifi/monitor':
# Enable/disable monitor mode on WiFi interface
result = mode_manager.toggle_monitor_mode(body)
status = 200 if result.get('status') == 'success' else 400
self._send_json(result, status)
elif path.startswith('/') and path.count('/') == 2: elif path.startswith('/') and path.count('/') == 2:
# /{mode}/start or /{mode}/stop # /{mode}/start or /{mode}/stop
parts = path.split('/') parts = path.split('/')
@@ -3794,19 +3993,53 @@ def main():
print(" Press Ctrl+C to stop") print(" Press Ctrl+C to stop")
print() print()
# Handle shutdown # Shutdown flag
shutdown_requested = threading.Event()
# Handle shutdown - run cleanup in separate thread to avoid blocking
def signal_handler(sig, frame): def signal_handler(sig, frame):
if shutdown_requested.is_set():
# Already shutting down, force exit
print("\nForce exit...")
os._exit(1)
shutdown_requested.set()
print("\nShutting down...") print("\nShutting down...")
# Stop all running modes
for mode in list(mode_manager.running_modes.keys()): def cleanup():
mode_manager.stop_mode(mode) # Stop all running modes first (they have subprocesses)
if data_push_loop: for mode in list(mode_manager.running_modes.keys()):
data_push_loop.stop() try:
if push_client: mode_manager.stop_mode(mode)
push_client.stop() except Exception as e:
gps_manager.stop() logger.debug(f"Error stopping {mode}: {e}")
httpd.shutdown()
sys.exit(0) # Stop push services
if data_push_loop:
try:
data_push_loop.stop()
except Exception:
pass
if push_client:
try:
push_client.stop()
except Exception:
pass
# Stop GPS
try:
gps_manager.stop()
except Exception:
pass
# Shutdown HTTP server
try:
httpd.shutdown()
except Exception:
pass
# Run cleanup in background thread so signal handler returns quickly
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
cleanup_thread.start()
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
@@ -3815,9 +4048,14 @@ def main():
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: except Exception:
if push_client: pass
push_client.stop()
# Give cleanup thread time to finish
if shutdown_requested.is_set():
time.sleep(0.5)
print("Agent stopped.")
if __name__ == '__main__': if __name__ == '__main__':
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.10.0" version = "2.13.1"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -26,7 +26,7 @@ classifiers = [
"Topic :: System :: Networking :: Monitoring", "Topic :: System :: Networking :: Monitoring",
] ]
dependencies = [ dependencies = [
"flask>=2.0.0", "flask>=3.0.0",
"skyfield>=1.45", "skyfield>=1.45",
"pyserial>=3.5", "pyserial>=3.5",
"Werkzeug>=3.1.5", "Werkzeug>=3.1.5",
+7 -1
View File
@@ -1,5 +1,5 @@
# Core dependencies # Core dependencies
flask>=2.0.0 flask>=3.0.0
flask-limiter>=2.5.4 flask-limiter>=2.5.4
requests>=2.28.0 requests>=2.28.0
Werkzeug>=3.1.5 Werkzeug>=3.1.5
@@ -23,6 +23,12 @@ pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features) # Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0 meshtastic>=2.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
# Development dependencies (install with: pip install -r requirements-dev.txt) # Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0 # pytest>=7.0.0
# pytest-cov>=4.0.0 # pytest-cov>=4.0.0
+4
View File
@@ -24,6 +24,8 @@ def register_blueprints(app):
from .spy_stations import spy_stations_bp from .spy_stations import spy_stations_bp
from .controller import controller_bp from .controller import controller_bp
from .offline import offline_bp from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -47,6 +49,8 @@ def register_blueprints(app):
app.register_blueprint(spy_stations_bp) app.register_blueprint(spy_stations_bp)
app.register_blueprint(controller_bp) # Remote agent controller app.register_blueprint(controller_bp) # Remote agent controller
app.register_blueprint(offline_bp) # Offline mode settings app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+31 -2
View File
@@ -43,6 +43,9 @@ DEFAULT_ACARS_FREQUENCIES = [
acars_message_count = 0 acars_message_count = 0
acars_last_message_time = None acars_last_message_time = None
# Track which device is being used
acars_active_device: int | None = None
def find_acarsdec(): def find_acarsdec():
"""Find acarsdec binary.""" """Find acarsdec binary."""
@@ -175,7 +178,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST']) @acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response: def start_acars() -> Response:
"""Start ACARS decoder.""" """Start ACARS decoder."""
global acars_message_count, acars_last_message_time global acars_message_count, acars_last_message_time, acars_active_device
with app_module.acars_lock: with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None: if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -202,6 +205,18 @@ def start_acars() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
acars_active_device = device_int
# Get frequencies - use provided or defaults # Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
if isinstance(frequencies, str): if isinstance(frequencies, str):
@@ -282,7 +297,10 @@ def start_acars() -> Response:
time.sleep(PROCESS_START_WAIT) time.sleep(PROCESS_START_WAIT)
if process.poll() is not None: if process.poll() is not None:
# Process died # Process died - release device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -310,6 +328,10 @@ def start_acars() -> Response:
}) })
except Exception as e: except Exception as e:
# Release device on failure
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
logger.error(f"Failed to start ACARS decoder: {e}") logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -317,6 +339,8 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST']) @acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response: def stop_acars() -> Response:
"""Stop ACARS decoder.""" """Stop ACARS decoder."""
global acars_active_device
with app_module.acars_lock: with app_module.acars_lock:
if not app_module.acars_process: if not app_module.acars_process:
return jsonify({ return jsonify({
@@ -334,6 +358,11 @@ def stop_acars() -> Response:
app_module.acars_process = None app_module.acars_process = None
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+531 -471
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -15,6 +15,7 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template from flask import Blueprint, jsonify, request, Response, render_template
import app as app_module import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse from utils.sse import format_sse
@@ -369,6 +370,16 @@ def start_ais():
app_module.ais_process = None app_module.ais_process = None
logger.info("Killed existing AIS process") logger.info("Killed existing AIS process")
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Build command using SDR abstraction # Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
@@ -399,6 +410,8 @@ def start_ais():
time.sleep(2.0) time.sleep(2.0)
if app_module.ais_process.poll() is not None: if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
@@ -424,6 +437,8 @@ def start_ais():
'port': tcp_port 'port': tcp_port
}) })
except Exception as e: except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -447,6 +462,11 @@ def stop_ais():
pass pass
app_module.ais_process = None app_module.ais_process = None
logger.info("AIS process stopped") logger.info("AIS process stopped")
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
ais_running = False ais_running = False
ais_active_device = None ais_active_device = None
@@ -480,4 +500,7 @@ def stream_ais():
@ais_bp.route('/dashboard') @ais_bp.route('/dashboard')
def ais_dashboard(): def ais_dashboard():
"""Popout AIS dashboard.""" """Popout AIS dashboard."""
return render_template('ais_dashboard.html') return render_template(
'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
+7 -3
View File
@@ -228,9 +228,13 @@ def init_audio_websocket(app: Flask):
except TimeoutError: except TimeoutError:
pass pass
except Exception as e: except Exception as e:
if "timed out" not in str(e).lower(): msg = str(e).lower()
logger.error(f"WebSocket receive error: {e}") if "connection closed" in msg:
logger.info("WebSocket closed by client")
break
if "timed out" not in msg:
logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active # Stream audio data if active
if streaming and proc and proc.poll() is None: if streaming and proc and proc.poll() is None:
+66 -1
View File
@@ -91,6 +91,17 @@ def register_agent():
if not base_url: if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400 return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
# Validate URL format
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
# Check if agent already exists # Check if agent already exists
existing = get_agent_by_name(name) existing = get_agent_by_name(name)
if existing: if existing:
@@ -128,9 +139,12 @@ def register_agent():
update_agent(agent_id, update_last_seen=True) update_agent(agent_id, update_last_seen=True)
agent = get_agent(agent_id) agent = get_agent(agent_id)
message = 'Agent registered successfully'
if capabilities is None:
message += ' (could not connect - agent may be offline)'
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'message': 'Agent registered successfully', 'message': message,
'agent': agent 'agent': agent
}), 201 }), 201
@@ -466,6 +480,57 @@ def proxy_mode_data(agent_id: int, mode: str):
}), 502 }), 502
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {}
try:
client = create_client_from_agent(agent)
result = client.post('/wifi/monitor', data)
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
if result.get('status') == 'success':
try:
metadata = client.refresh_metadata()
if metadata.get('healthy'):
caps = metadata.get('capabilities') or {}
agent_interfaces = caps.get('interfaces', {})
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
except Exception:
pass # Non-fatal if refresh fails
return jsonify({
'status': result.get('status', 'error'),
'agent_id': agent_id,
'agent_name': agent['name'],
'monitor_interface': result.get('monitor_interface'),
'message': result.get('message')
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
# ============================================================================= # =============================================================================
# Push Data Ingestion # Push Data Ingestion
# ============================================================================= # =============================================================================
+29 -16
View File
@@ -47,6 +47,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state) # Module state (track if running independent of process state)
dsc_running = False dsc_running = False
# Track which device is being used
dsc_active_device: int | None = None
def _get_dsc_decoder_path() -> str | None: def _get_dsc_decoder_path() -> str | None:
"""Get path to DSC decoder.""" """Get path to DSC decoder."""
@@ -309,21 +312,18 @@ def start_decoding() -> Response:
'message': str(e) 'message': str(e)
}), 400 }), 400
# Check if device is in use by AIS # Check if device is available using centralized registry
try: global dsc_active_device
from routes import ais as ais_module device_int = int(device)
if hasattr(ais_module, 'ais_running') and ais_module.ais_running: error = app_module.claim_sdr_device(device_int, 'dsc')
# AIS is running - check if same device if error:
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device): return jsonify({
return jsonify({ 'status': 'error',
'status': 'error', 'error_type': 'DEVICE_BUSY',
'error_type': 'DEVICE_BUSY', 'message': error
'message': f'SDR device {device} is in use by AIS tracking', }), 409
'suggestion': 'Use a different SDR device or stop AIS tracking first',
'in_use_by': 'ais' dsc_active_device = device_int
}), 409
except ImportError:
pass
# Clear queue # Clear queue
while not app_module.dsc_queue.empty(): while not app_module.dsc_queue.empty():
@@ -408,11 +408,19 @@ def start_decoding() -> Response:
}) })
except FileNotFoundError as e: except FileNotFoundError as e:
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'Tool not found: {e.filename}' 'message': f'Tool not found: {e.filename}'
}), 400 }), 400
except Exception as e: except Exception as e:
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
logger.error(f"Failed to start DSC decoder: {e}") logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -423,7 +431,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST']) @dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
"""Stop DSC decoder.""" """Stop DSC decoder."""
global dsc_running global dsc_running, dsc_active_device
with app_module.dsc_lock: with app_module.dsc_lock:
if not app_module.dsc_process: if not app_module.dsc_process:
@@ -460,6 +468,11 @@ def stop_decoding() -> Response:
app_module.dsc_process = None app_module.dsc_process = None
app_module.dsc_rtl_process = None app_module.dsc_rtl_process = None
# Release device from registry
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+720 -257
View File
File diff suppressed because it is too large Load Diff
+571 -9
View File
@@ -3,8 +3,9 @@
Provides endpoints for connecting to Meshtastic devices, configuring Provides endpoints for connecting to Meshtastic devices, configuring
channels with encryption keys, and streaming received messages. channels with encryption keys, and streaming received messages.
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.) Supports multiple connection types:
connected via USB/Serial. - USB/Serial: Physical device connected via USB
- TCP: WiFi-enabled devices accessible via IP address
""" """
from __future__ import annotations from __future__ import annotations
@@ -57,13 +58,45 @@ def _message_callback(msg: MeshtasticMessage) -> None:
pass pass
@meshtastic_bp.route('/ports')
def list_ports():
"""
List available serial ports that may have Meshtastic devices.
Returns:
JSON with list of available serial ports.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'ports': [],
'message': 'Meshtastic SDK not installed'
})
try:
from meshtastic.util import findPorts
ports = findPorts()
return jsonify({
'status': 'ok',
'ports': ports,
'count': len(ports)
})
except Exception as e:
logger.error(f"Error listing ports: {e}")
return jsonify({
'status': 'error',
'ports': [],
'message': str(e)
})
@meshtastic_bp.route('/status') @meshtastic_bp.route('/status')
def get_status(): def get_status():
""" """
Get Meshtastic connection status. Get Meshtastic connection status.
Returns: Returns:
JSON with connection status, device info, and node information. JSON with connection status, device info, connection type, and node information.
""" """
if not is_meshtastic_available(): if not is_meshtastic_available():
return jsonify({ return jsonify({
@@ -79,6 +112,7 @@ def get_status():
'available': True, 'available': True,
'running': False, 'running': False,
'device': None, 'device': None,
'connection_type': None,
'node_info': None, 'node_info': None,
}) })
@@ -88,6 +122,7 @@ def get_status():
'available': True, 'available': True,
'running': client.is_running, 'running': client.is_running,
'device': client.device_path, 'device': client.device_path,
'connection_type': client.connection_type,
'error': client.error, 'error': client.error,
'node_info': node_info.to_dict() if node_info else None, 'node_info': node_info.to_dict() if node_info else None,
}) })
@@ -99,13 +134,20 @@ def start_mesh():
Start Meshtastic listener. Start Meshtastic listener.
Connects to a Meshtastic device and begins receiving messages. Connects to a Meshtastic device and begins receiving messages.
The device must be connected via USB/Serial. Supports both USB/Serial and TCP connections.
JSON body (optional): JSON body (optional):
{ {
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided. "connection_type": "serial", // 'serial' (default) or 'tcp'
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
} }
Examples:
Serial (auto-discover): {}
Serial (specific port): {"device": "/dev/ttyUSB0"}
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
Returns: Returns:
JSON with connection status. JSON with connection status.
""" """
@@ -119,7 +161,8 @@ def start_mesh():
if client and client.is_running: if client and client.is_running:
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_running',
'device': client.device_path 'device': client.device_path,
'connection_type': client.connection_type
}) })
# Clear queue and history # Clear queue and history
@@ -130,18 +173,46 @@ def start_mesh():
break break
_recent_messages.clear() _recent_messages.clear()
# Get optional device path # Parse connection parameters
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
connection_type = data.get('connection_type', 'serial').lower().strip()
device = data.get('device') device = data.get('device')
hostname = data.get('hostname')
# Validate device path if provided # Validate connection type
if connection_type not in ('serial', 'tcp'):
return jsonify({
'status': 'error',
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
}), 400
# Validate TCP parameters
if connection_type == 'tcp':
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname is required for TCP connections'
}), 400
hostname = str(hostname).strip()
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname cannot be empty'
}), 400
# Validate serial device path if provided
if device: if device:
device = str(device).strip() device = str(device).strip()
if not device: if not device:
device = None device = None
# Start client # Start client
success = start_meshtastic(device=device, callback=_message_callback) success = start_meshtastic(
device=device,
callback=_message_callback,
connection_type=connection_type,
hostname=hostname
)
if success: if success:
client = get_meshtastic_client() client = get_meshtastic_client()
@@ -149,6 +220,7 @@ def start_mesh():
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'device': client.device_path if client else None, 'device': client.device_path if client else None,
'connection_type': client.connection_type if client else None,
'node_info': node_info.to_dict() if node_info else None, 'node_info': node_info.to_dict() if node_info else None,
}) })
else: else:
@@ -489,3 +561,493 @@ def get_nodes():
'count': len(nodes_list), 'count': len(nodes_list),
'with_position_count': sum(1 for n in nodes_list if n.get('has_position')) 'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
}) })
@meshtastic_bp.route('/traceroute', methods=['POST'])
def send_traceroute():
"""
Send a traceroute request to a mesh node.
JSON body:
{
"destination": "!a1b2c3d4", // Required: target node ID
"hop_limit": 7 // Optional: max hops (1-7, default 7)
}
Returns:
JSON with traceroute request status.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'message': 'Meshtastic SDK not installed'
}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
data = request.get_json(silent=True) or {}
destination = data.get('destination')
if not destination:
return jsonify({
'status': 'error',
'message': 'Destination node ID is required'
}), 400
hop_limit = data.get('hop_limit', 7)
if not isinstance(hop_limit, int) or not 1 <= hop_limit <= 7:
hop_limit = 7
success, error = client.send_traceroute(destination, hop_limit=hop_limit)
if success:
return jsonify({
'status': 'sent',
'destination': destination,
'hop_limit': hop_limit
})
else:
return jsonify({
'status': 'error',
'message': error or 'Failed to send traceroute'
}), 500
@meshtastic_bp.route('/traceroute/results')
def get_traceroute_results():
"""
Get recent traceroute results.
Query parameters:
limit: Maximum number of results to return (default: 10)
Returns:
JSON with list of traceroute results.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'results': []
}), 400
limit = request.args.get('limit', 10, type=int)
results = client.get_traceroute_results(limit=limit)
return jsonify({
'status': 'ok',
'results': [r.to_dict() for r in results],
'count': len(results)
})
@meshtastic_bp.route('/position/request', methods=['POST'])
def request_position():
"""
Request position from a specific node.
JSON body:
{
"node_id": "!a1b2c3d4" // Required: target node ID
}
Returns:
JSON with request status.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'message': 'Meshtastic SDK not installed'
}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
data = request.get_json(silent=True) or {}
node_id = data.get('node_id')
if not node_id:
return jsonify({
'status': 'error',
'message': 'Node ID is required'
}), 400
success, error = client.request_position(node_id)
if success:
return jsonify({
'status': 'sent',
'node_id': node_id
})
else:
return jsonify({
'status': 'error',
'message': error or 'Failed to request position'
}), 500
@meshtastic_bp.route('/firmware/check')
def check_firmware():
"""
Check current firmware version and compare to latest release.
Returns:
JSON with current_version, latest_version, update_available, release_url.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
result = client.check_firmware()
result['status'] = 'ok'
return jsonify(result)
@meshtastic_bp.route('/channels/<int:index>/qr')
def get_channel_qr(index: int):
"""
Generate QR code for a channel configuration.
Args:
index: Channel index (0-7)
Returns:
PNG image of QR code.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
if not 0 <= index <= 7:
return jsonify({
'status': 'error',
'message': 'Channel index must be 0-7'
}), 400
png_data = client.generate_channel_qr(index)
if png_data:
return Response(png_data, mimetype='image/png')
else:
return jsonify({
'status': 'error',
'message': 'Failed to generate QR code. Make sure qrcode library is installed.'
}), 500
@meshtastic_bp.route('/telemetry/history')
def get_telemetry_history():
"""
Get telemetry history for a node.
Query parameters:
node_id: Node ID or number (required)
hours: Number of hours of history (default: 24)
Returns:
JSON with telemetry data points.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'data': []
}), 400
node_id = request.args.get('node_id')
hours = request.args.get('hours', 24, type=int)
if not node_id:
return jsonify({
'status': 'error',
'message': 'node_id is required',
'data': []
}), 400
# Parse node ID to number
try:
if node_id.startswith('!'):
node_num = int(node_id[1:], 16)
else:
node_num = int(node_id)
except ValueError:
return jsonify({
'status': 'error',
'message': f'Invalid node_id: {node_id}',
'data': []
}), 400
history = client.get_telemetry_history(node_num, hours=hours)
return jsonify({
'status': 'ok',
'node_id': node_id,
'hours': hours,
'data': [p.to_dict() for p in history],
'count': len(history)
})
@meshtastic_bp.route('/neighbors')
def get_neighbors():
"""
Get neighbor information for mesh topology visualization.
Query parameters:
node_id: Specific node ID (optional, returns all if not provided)
Returns:
JSON with neighbor relationships.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'neighbors': {}
}), 400
node_id = request.args.get('node_id')
node_num = None
if node_id:
try:
if node_id.startswith('!'):
node_num = int(node_id[1:], 16)
else:
node_num = int(node_id)
except ValueError:
return jsonify({
'status': 'error',
'message': f'Invalid node_id: {node_id}',
'neighbors': {}
}), 400
neighbors = client.get_neighbors(node_num)
# Convert to JSON-serializable format
result = {}
for num, neighbor_list in neighbors.items():
node_key = f"!{num:08x}"
result[node_key] = [n.to_dict() for n in neighbor_list]
return jsonify({
'status': 'ok',
'neighbors': result,
'node_count': len(result)
})
@meshtastic_bp.route('/pending')
def get_pending_messages():
"""
Get messages waiting for ACK.
Returns:
JSON with pending messages and their status.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'messages': []
}), 400
pending = client.get_pending_messages()
return jsonify({
'status': 'ok',
'messages': [m.to_dict() for m in pending.values()],
'count': len(pending)
})
@meshtastic_bp.route('/range-test/start', methods=['POST'])
def start_range_test():
"""
Start a range test.
JSON body:
{
"count": 10, // Number of packets to send (default 10)
"interval": 5 // Seconds between packets (default 5)
}
Returns:
JSON with start status.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'message': 'Meshtastic SDK not installed'
}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
data = request.get_json(silent=True) or {}
count = data.get('count', 10)
interval = data.get('interval', 5)
# Validate
if not isinstance(count, int) or count < 1 or count > 100:
count = 10
if not isinstance(interval, int) or interval < 1 or interval > 60:
interval = 5
success, error = client.start_range_test(count=count, interval=interval)
if success:
return jsonify({
'status': 'started',
'count': count,
'interval': interval
})
else:
return jsonify({
'status': 'error',
'message': error or 'Failed to start range test'
}), 500
@meshtastic_bp.route('/range-test/stop', methods=['POST'])
def stop_range_test():
"""
Stop an ongoing range test.
Returns:
JSON confirmation.
"""
client = get_meshtastic_client()
if client:
client.stop_range_test()
return jsonify({'status': 'stopped'})
@meshtastic_bp.route('/range-test/status')
def get_range_test_status():
"""
Get range test status and results.
Returns:
JSON with running status and results.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'running': False,
'results': []
}), 400
status = client.get_range_test_status()
return jsonify({
'status': 'ok',
**status
})
@meshtastic_bp.route('/store-forward/status')
def get_store_forward_status():
"""
Check if Store & Forward router is available.
Returns:
JSON with availability status and router info.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'available': False
}), 400
sf_status = client.check_store_forward_available()
return jsonify({
'status': 'ok',
**sf_status
})
@meshtastic_bp.route('/store-forward/request', methods=['POST'])
def request_store_forward():
"""
Request missed messages from Store & Forward router.
JSON body:
{
"window_minutes": 60 // Minutes of history to request (default 60)
}
Returns:
JSON with request status.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'message': 'Meshtastic SDK not installed'
}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
data = request.get_json(silent=True) or {}
window_minutes = data.get('window_minutes', 60)
if not isinstance(window_minutes, int) or window_minutes < 1 or window_minutes > 1440:
window_minutes = 60
success, error = client.request_store_forward(window_minutes=window_minutes)
if success:
return jsonify({
'status': 'sent',
'window_minutes': window_minutes
})
else:
return jsonify({
'status': 'error',
'message': error or 'Failed to request S&F history'
}), 500
+40 -4
View File
@@ -29,6 +29,9 @@ from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__) pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None: def parse_multimon_output(line: str) -> dict[str, str] | None:
"""Parse multimon-ng output line.""" """Parse multimon-ng output line."""
@@ -155,6 +158,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
@pager_bp.route('/start', methods=['POST']) @pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response: def start_decoding() -> Response:
global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409 return jsonify({'status': 'error', 'message': 'Already running'}), 409
@@ -178,10 +183,29 @@ def start_decoding() -> Response:
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
# Validate protocols # Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols) protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list): if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
@@ -213,10 +237,6 @@ def start_decoding() -> Response:
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host: if rtl_tcp_host:
# Validate and create network device # Validate and create network device
try: try:
@@ -302,13 +322,23 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e: except FileNotFoundError as e:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e: except Exception as e:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
# Kill rtl_fm process first # Kill rtl_fm process first
@@ -337,6 +367,12 @@ def stop_decoding() -> Response:
app_module.current_process.kill() app_module.current_process.kill()
app_module.current_process = None app_module.current_process = None
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'}) return jsonify({'status': 'not_running'})
+33 -7
View File
@@ -26,6 +26,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
rtl_tcp_process = None rtl_tcp_process = None
rtl_tcp_lock = threading.Lock() rtl_tcp_lock = threading.Lock()
# Track which device is being used
rtlamr_active_device: int | None = None
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtlamr JSON output to queue.""" """Stream rtlamr JSON output to queue."""
@@ -66,7 +69,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@rtlamr_bp.route('/start_rtlamr', methods=['POST']) @rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response: def start_rtlamr() -> Response:
global rtl_tcp_process global rtl_tcp_process, rtlamr_active_device
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
@@ -83,6 +86,18 @@ def start_rtlamr() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
rtlamr_active_device = device_int
# Clear queue # Clear queue
while not app_module.rtlamr_queue.empty(): while not app_module.rtlamr_queue.empty():
try: try:
@@ -182,27 +197,33 @@ def start_rtlamr() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError: except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp # If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock: with rtl_tcp_lock:
if rtl_tcp_process: if rtl_tcp_process:
rtl_tcp_process.terminate() rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}) return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e: except Exception as e:
# If rtlamr fails, clean up rtl_tcp # If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock: with rtl_tcp_lock:
if rtl_tcp_process: if rtl_tcp_process:
rtl_tcp_process.terminate() rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST']) @rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response: def stop_rtlamr() -> Response:
global rtl_tcp_process global rtl_tcp_process, rtlamr_active_device
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
app_module.rtlamr_process.terminate() app_module.rtlamr_process.terminate()
@@ -211,7 +232,7 @@ def stop_rtlamr() -> Response:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill() app_module.rtlamr_process.kill()
app_module.rtlamr_process = None app_module.rtlamr_process = None
# Also stop rtl_tcp # Also stop rtl_tcp
with rtl_tcp_lock: with rtl_tcp_lock:
if rtl_tcp_process: if rtl_tcp_process:
@@ -222,7 +243,12 @@ def stop_rtlamr() -> Response:
rtl_tcp_process.kill() rtl_tcp_process.kill()
rtl_tcp_process = None rtl_tcp_process = None
logger.info("rtl_tcp stopped") logger.info("rtl_tcp stopped")
# Release device from registry
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+178 -40
View File
@@ -3,12 +3,17 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import urllib.request import urllib.request
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import Blueprint, jsonify, request, render_template, Response import requests
from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
from utils.logging import satellite_logger as logger from utils.logging import satellite_logger as logger
@@ -26,10 +31,101 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES) _tle_cache = dict(TLE_SATELLITES)
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
"""
Fetch real-time ISS position from external APIs.
Returns position data dict or None if all APIs fail.
"""
iss_lat = None
iss_lon = None
iss_alt = 420 # Default altitude in km
source = None
# Try primary API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
source = 'open-notify'
except Exception as e:
logger.debug(f"Open Notify API failed: {e}")
# Try fallback API: Where The ISS At
if iss_lat is None:
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
iss_alt = float(data.get('altitude', 420))
source = 'wheretheiss'
except Exception as e:
logger.debug(f"Where The ISS At API failed: {e}")
if iss_lat is None:
return None
result = {
'satellite': 'ISS',
'lat': iss_lat,
'lon': iss_lon,
'altitude': iss_alt,
'source': source
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
# Earth radius in km
earth_radius = 6371
# Convert to radians
lat1 = math.radians(observer_lat)
lat2 = math.radians(iss_lat)
lon1 = math.radians(observer_lon)
lon2 = math.radians(iss_lon)
# Haversine for ground distance
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
ground_distance = earth_radius * c
# Calculate slant range
slant_range = math.sqrt(ground_distance**2 + iss_alt**2)
# Calculate elevation angle (simplified)
if ground_distance > 0:
elevation = math.degrees(math.atan2(iss_alt - (ground_distance**2 / (2 * earth_radius)), ground_distance))
else:
elevation = 90.0
# Calculate azimuth
y = math.sin(dlon) * math.cos(lat2)
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
azimuth = math.degrees(math.atan2(y, x))
azimuth = (azimuth + 360) % 360
result['elevation'] = round(elevation, 1)
result['azimuth'] = round(azimuth, 1)
result['distance'] = round(slant_range, 1)
result['visible'] = elevation > 0
return result
@satellite_bp.route('/dashboard') @satellite_bp.route('/dashboard')
def satellite_dashboard(): def satellite_dashboard():
"""Popout satellite tracking dashboard.""" """Popout satellite tracking dashboard."""
return render_template('satellite_dashboard.html') return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@satellite_bp.route('/predict', methods=['POST']) @satellite_bp.route('/predict', methods=['POST'])
@@ -239,6 +335,35 @@ def get_satellite_position():
positions = [] positions = []
for sat_name in satellites: for sat_name in satellites:
# Special handling for ISS - use real-time API for accurate position
if sat_name == 'ISS':
iss_data = _fetch_iss_realtime(lat, lon)
if iss_data:
# Add orbit track if requested (using TLE for track prediction)
if include_track and 'ISS' in _tle_cache:
try:
tle_data = _tle_cache['ISS']
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
orbit_track = []
for minutes_offset in range(-45, 46, 1):
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
try:
geo = satellite.at(t_point)
sp = wgs84.subpoint(geo)
orbit_track.append({
'lat': float(sp.latitude.degrees),
'lon': float(sp.longitude.degrees),
'past': minutes_offset < 0
})
except Exception:
continue
iss_data['track'] = orbit_track
except Exception:
pass
positions.append(iss_data)
continue
# Other satellites - use TLE data
if sat_name not in _tle_cache: if sat_name not in _tle_cache:
continue continue
@@ -292,56 +417,69 @@ def get_satellite_position():
}) })
@satellite_bp.route('/update-tle', methods=['POST']) def refresh_tle_data() -> list:
def update_tle(): """
"""Update TLE data from CelesTrak.""" Refresh TLE data from CelesTrak.
This can be called at startup or periodically to keep TLE data fresh.
Returns list of satellite names that were updated.
"""
global _tle_cache global _tle_cache
try: name_mappings = {
name_mappings = { 'ISS (ZARYA)': 'ISS',
'ISS (ZARYA)': 'ISS', 'NOAA 15': 'NOAA-15',
'NOAA 15': 'NOAA-15', 'NOAA 18': 'NOAA-18',
'NOAA 18': 'NOAA-18', 'NOAA 19': 'NOAA-19',
'NOAA 19': 'NOAA-19', 'NOAA 20 (JPSS-1)': 'NOAA-20',
'METEOR-M 2': 'METEOR-M2', 'NOAA 21 (JPSS-2)': 'NOAA-21',
'METEOR-M2 3': 'METEOR-M2-3' 'METEOR-M 2': 'METEOR-M2',
} 'METEOR-M2 3': 'METEOR-M2-3'
}
updated = [] updated = []
for group in ['stations', 'weather']: for group in ['stations', 'weather', 'noaa']:
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle' url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
try: try:
with urllib.request.urlopen(url, timeout=10) as response: with urllib.request.urlopen(url, timeout=15) as response:
content = response.read().decode('utf-8') content = response.read().decode('utf-8')
lines = content.strip().split('\n') lines = content.strip().split('\n')
i = 0 i = 0
while i + 2 < len(lines): while i + 2 < len(lines):
name = lines[i].strip() name = lines[i].strip()
line1 = lines[i + 1].strip() line1 = lines[i + 1].strip()
line2 = lines[i + 2].strip() line2 = lines[i + 2].strip()
if not (line1.startswith('1 ') and line2.startswith('2 ')): if not (line1.startswith('1 ') and line2.startswith('2 ')):
i += 1 i += 1
continue continue
internal_name = name_mappings.get(name, name) internal_name = name_mappings.get(name, name)
if internal_name in _tle_cache: if internal_name in _tle_cache:
_tle_cache[internal_name] = (name, line1, line2) _tle_cache[internal_name] = (name, line1, line2)
if internal_name not in updated:
updated.append(internal_name) updated.append(internal_name)
i += 3 i += 3
except Exception as e: except Exception as e:
logger.error(f"Error fetching {group}: {e}") logger.warning(f"Error fetching TLE group {group}: {e}")
continue continue
return updated
@satellite_bp.route('/update-tle', methods=['POST'])
def update_tle():
"""Update TLE data from CelesTrak (API endpoint)."""
try:
updated = refresh_tle_data()
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'updated': updated 'updated': updated
}) })
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
+37 -4
View File
@@ -24,6 +24,9 @@ from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__) sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue.""" """Stream rtl_433 JSON output to queue."""
@@ -64,6 +67,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
@sensor_bp.route('/start_sensor', methods=['POST']) @sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: def start_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
@@ -79,6 +84,22 @@ def start_sensor() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
# Clear queue # Clear queue
while not app_module.sensor_queue.empty(): while not app_module.sensor_queue.empty():
try: try:
@@ -93,10 +114,6 @@ def start_sensor() -> Response:
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host: if rtl_tcp_host:
# Validate and create network device # Validate and create network device
try: try:
@@ -155,13 +172,23 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError: except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e: except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response: def stop_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
app_module.sensor_process.terminate() app_module.sensor_process.terminate()
@@ -170,6 +197,12 @@ def stop_sensor() -> Response:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
app_module.sensor_process.kill() app_module.sensor_process.kill()
app_module.sensor_process = None app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'}) return jsonify({'status': 'not_running'})
+626
View File
@@ -0,0 +1,626 @@
"""ISS SSTV (Slow-Scan Television) decoder routes.
Provides endpoints for decoding SSTV images from the International Space Station.
ISS SSTV events occur during special commemorations and typically transmit on 145.800 MHz FM.
"""
from __future__ import annotations
import queue
import time
from pathlib import Path
from typing import Generator
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.sstv import (
get_sstv_decoder,
is_sstv_available,
ISS_SSTV_FREQ,
DecodeProgress,
DopplerInfo,
)
logger = get_logger('intercept.sstv')
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
# Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
def _progress_callback(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
try:
_sstv_queue.put_nowait(progress.to_dict())
except queue.Full:
try:
_sstv_queue.get_nowait()
_sstv_queue.put_nowait(progress.to_dict())
except queue.Empty:
pass
@sstv_bp.route('/status')
def get_status():
"""
Get SSTV decoder status.
Returns:
JSON with decoder availability and current status.
"""
available = is_sstv_available()
decoder = get_sstv_decoder()
result = {
'available': available,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ,
'image_count': len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled,
}
# Include Doppler info if available
doppler_info = decoder.last_doppler_info
if doppler_info:
result['doppler'] = doppler_info.to_dict()
return jsonify(result)
@sstv_bp.route('/start', methods=['POST'])
def start_decoder():
"""
Start SSTV decoder.
JSON body (optional):
{
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
"device": 0, // RTL-SDR device index
"latitude": 40.7128, // Observer latitude for Doppler correction
"longitude": -74.0060 // Observer longitude for Doppler correction
}
If latitude and longitude are provided, real-time Doppler shift compensation
will be enabled, which improves reception by tracking the ISS frequency shift
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
Returns:
JSON with start status.
"""
if not is_sstv_available():
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
}), 400
decoder = get_sstv_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'frequency': ISS_SSTV_FREQ,
'doppler_enabled': decoder.doppler_enabled
})
# Clear queue
while not _sstv_queue.empty():
try:
_sstv_queue.get_nowait()
except queue.Empty:
break
# Get parameters
data = request.get_json(silent=True) or {}
frequency = data.get('frequency', ISS_SSTV_FREQ)
device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
# Validate frequency
try:
frequency = float(frequency)
if not (100 <= frequency <= 500): # VHF range
return jsonify({
'status': 'error',
'message': 'Frequency must be between 100-500 MHz'
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency'
}), 400
# Validate location if provided
if latitude is not None and longitude is not None:
try:
latitude = float(latitude)
longitude = float(longitude)
if not (-90 <= latitude <= 90):
return jsonify({
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
if not (-180 <= longitude <= 180):
return jsonify({
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
else:
latitude = None
longitude = None
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
frequency=frequency,
device_index=device_index,
latitude=latitude,
longitude=longitude
)
if success:
result = {
'status': 'started',
'frequency': frequency,
'device': device_index,
'doppler_enabled': decoder.doppler_enabled
}
# Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info:
result['doppler'] = decoder.last_doppler_info.to_dict()
return jsonify(result)
else:
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
}), 500
@sstv_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""
Stop SSTV decoder.
Returns:
JSON confirmation.
"""
decoder = get_sstv_decoder()
decoder.stop()
return jsonify({'status': 'stopped'})
@sstv_bp.route('/doppler')
def get_doppler():
"""
Get current Doppler shift information.
Returns real-time Doppler shift data if tracking is enabled.
Returns:
JSON with Doppler shift information.
"""
decoder = get_sstv_decoder()
if not decoder.doppler_enabled:
return jsonify({
'status': 'disabled',
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
})
doppler_info = decoder.last_doppler_info
if not doppler_info:
return jsonify({
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({
'status': 'ok',
'doppler': doppler_info.to_dict(),
'nominal_frequency_mhz': ISS_SSTV_FREQ,
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
})
@sstv_bp.route('/images')
def list_images():
"""
Get list of decoded SSTV images.
Query parameters:
limit: Maximum number of images to return (default: all)
Returns:
JSON with list of decoded images.
"""
decoder = get_sstv_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
@sstv_bp.route('/images/<filename>')
def get_image(filename: str):
"""
Get a decoded SSTV image file.
Args:
filename: Image filename
Returns:
Image file or 404.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
# Find image in decoder's output directory
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png')
@sstv_bp.route('/stream')
def stream_progress():
"""
SSE stream of SSTV decode progress.
Provides real-time Server-Sent Events stream of decode progress.
Event format:
data: {"type": "sstv_progress", "status": "decoding", "mode": "PD120", ...}
Returns:
SSE stream (text/event-stream)
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
progress = _sstv_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@sstv_bp.route('/iss-schedule')
def iss_schedule():
"""
Get ISS pass schedule for SSTV reception.
Calculates ISS passes directly using skyfield.
Query parameters:
latitude: Observer latitude (required)
longitude: Observer longitude (required)
hours: Hours to look ahead (default: 48)
Returns:
JSON with ISS pass schedule.
"""
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int)
if lat is None or lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
try:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from datetime import timedelta
from data.satellites import TLE_SATELLITES
# Get ISS TLE
iss_tle = TLE_SATELLITES.get('ISS')
if not iss_tle:
return jsonify({
'status': 'error',
'message': 'ISS TLE data not available'
}), 500
ts = load.timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
def above_horizon(t):
diff = satellite - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1/720
times, events = find_discrete(t0, t1, above_horizon)
passes = []
i = 0
while i < len(times):
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]: # Setting
set_time = times[j]
i = j
break
else:
i += 1
continue
if set_time is None:
i += 1
continue
# Calculate max elevation
max_el = 0
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
duration_minutes = int(duration_seconds / 60)
for k in range(30):
frac = k / 29
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
diff = satellite - observer
topocentric = diff.at(t_point)
alt, _, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
if max_el >= 10: # Min elevation filter
passes.append({
'satellite': 'ISS',
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'duration': duration_minutes,
'color': '#00ffff'
})
i += 1
return jsonify({
'status': 'ok',
'passes': passes,
'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
})
except ImportError:
return jsonify({
'status': 'error',
'message': 'skyfield library not installed'
}), 503
except Exception as e:
logger.error(f"Error getting ISS schedule: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@sstv_bp.route('/iss-position')
def iss_position():
"""
Get current ISS position from real-time API.
Uses the Open Notify API for accurate real-time position,
with fallback to "Where The ISS At" API.
Query parameters:
latitude: Observer latitude (optional, for elevation calc)
longitude: Observer longitude (optional, for elevation calc)
Returns:
JSON with ISS current position.
"""
import requests
from datetime import datetime
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
# Try primary API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': 420, # Approximate ISS altitude in km
'timestamp': datetime.utcnow().isoformat(),
'source': 'open-notify'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
# Try fallback API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': float(data.get('altitude', 420)),
'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Both APIs failed
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
"""Calculate elevation, azimuth, and distance from observer to ISS."""
import math
# ISS altitude in km
iss_alt_km = 420
# Earth radius in km
earth_radius = 6371
# Convert to radians
lat1 = math.radians(obs_lat)
lat2 = math.radians(iss_lat)
lon1 = math.radians(obs_lon)
lon2 = math.radians(iss_lon)
# Haversine for ground distance
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
ground_distance = earth_radius * c
# Calculate elevation angle (simplified)
# Using spherical geometry approximation
iss_height = iss_alt_km
slant_range = math.sqrt(ground_distance**2 + iss_height**2)
if ground_distance > 0:
elevation = math.degrees(math.atan2(iss_height - (ground_distance**2 / (2 * earth_radius)), ground_distance))
else:
elevation = 90.0
# Calculate azimuth
y = math.sin(dlon) * math.cos(lat2)
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
azimuth = math.degrees(math.atan2(y, x))
azimuth = (azimuth + 360) % 360
return {
'elevation': round(elevation, 1),
'azimuth': round(azimuth, 1),
'distance': round(slant_range, 1)
}
@sstv_bp.route('/decode-file', methods=['POST'])
def decode_file():
"""
Decode SSTV from an uploaded audio file.
Expects multipart/form-data with 'audio' file field.
Returns:
JSON with decoded images.
"""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided'
}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected'
}), 400
# Save to temp file
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = tmp.name
try:
decoder = get_sstv_decoder()
images = decoder.decode_file(tmp_path)
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
finally:
# Clean up temp file
try:
Path(tmp_path).unlink()
except Exception:
pass
+179
View File
@@ -0,0 +1,179 @@
"""Updater routes - GitHub update checking and application updates."""
from __future__ import annotations
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.updater import (
check_for_updates,
dismiss_update,
get_update_status,
perform_update,
restart_application,
)
logger = get_logger('intercept.routes.updater')
updater_bp = Blueprint('updater', __name__, url_prefix='/updater')
@updater_bp.route('/check', methods=['GET'])
def check_updates() -> Response:
"""
Check for updates from GitHub.
Uses caching to avoid excessive API calls. Will only hit GitHub
if the cache is stale (default: 6 hours).
Query parameters:
force: Set to 'true' to bypass cache and check GitHub directly
Returns:
JSON with update status information
"""
force = request.args.get('force', '').lower() == 'true'
try:
result = check_for_updates(force=force)
return jsonify(result)
except Exception as e:
logger.error(f"Error checking for updates: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/status', methods=['GET'])
def update_status() -> Response:
"""
Get current update status from cache.
This endpoint does NOT trigger a GitHub check - it only returns
cached data. Use /check to trigger a fresh check.
Returns:
JSON with cached update status
"""
try:
result = get_update_status()
return jsonify(result)
except Exception as e:
logger.error(f"Error getting update status: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/update', methods=['POST'])
def do_update() -> Response:
"""
Perform a git pull to update the application.
Request body (JSON):
stash_changes: If true, stash local changes before pulling
Returns:
JSON with update result information
"""
data = request.json or {}
stash_changes = data.get('stash_changes', False)
try:
result = perform_update(stash_changes=stash_changes)
if result.get('success'):
return jsonify(result)
else:
# Return appropriate status code based on error type
error = result.get('error', '')
if error == 'local_changes':
return jsonify(result), 409 # Conflict
elif error == 'merge_conflict':
return jsonify(result), 409
elif result.get('manual_update'):
return jsonify(result), 400
else:
return jsonify(result), 500
except Exception as e:
logger.error(f"Error performing update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/dismiss', methods=['POST'])
def dismiss_notification() -> Response:
"""
Dismiss update notification for a specific version.
The notification will not be shown again until a newer version
is available.
Request body (JSON):
version: The version to dismiss notifications for
Returns:
JSON with success status
"""
data = request.json or {}
version = data.get('version')
if not version:
return jsonify({
'success': False,
'error': 'Version is required'
}), 400
try:
result = dismiss_update(version)
return jsonify(result)
except Exception as e:
logger.error(f"Error dismissing update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/restart', methods=['POST'])
def restart_app() -> Response:
"""
Restart the application.
This endpoint triggers a graceful restart of the application:
1. Stops all running decoder processes
2. Cleans up global state
3. Replaces the current process with a fresh instance
The response may not be received by the client since the process
is replaced immediately. Clients should poll /health until the
server responds again.
Returns:
JSON with restart status (may not be delivered)
"""
import threading
logger.info("Restart requested via API")
# Send response before restarting
# Use a short delay to allow the response to be sent
def delayed_restart():
import time
time.sleep(0.5) # Allow response to be sent
restart_application()
# Start restart in a background thread so we can return a response
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
restart_thread.start()
return jsonify({
'success': True,
'message': 'Application is restarting. Please wait...',
'action': 'restart'
})
+140
View File
@@ -1413,3 +1413,143 @@ def v2_clear_data():
except Exception as e: except Exception as e:
logger.exception("Error clearing data") logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# =============================================================================
# V2 Deauth Detection Endpoints
# =============================================================================
@wifi_bp.route('/v2/deauth/status')
def v2_deauth_status():
"""
Get deauth detection status and recent alerts.
Returns:
- is_running: Whether deauth detector is active
- interface: Monitor interface being used
- stats: Detection statistics
- recent_alerts: Recent deauth alerts
"""
try:
scanner = get_wifi_scanner()
detector = scanner.deauth_detector
if detector:
stats = detector.stats
alerts = detector.get_alerts(limit=50)
else:
stats = {
'is_running': False,
'interface': None,
'packets_captured': 0,
'alerts_generated': 0,
}
alerts = []
return jsonify({
'is_running': stats.get('is_running', False),
'interface': stats.get('interface'),
'started_at': stats.get('started_at'),
'stats': {
'packets_captured': stats.get('packets_captured', 0),
'alerts_generated': stats.get('alerts_generated', 0),
'active_trackers': stats.get('active_trackers', 0),
},
'recent_alerts': alerts,
})
except Exception as e:
logger.exception("Error getting deauth status")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/stream')
def v2_deauth_stream():
"""
SSE stream for real-time deauth alerts.
Events:
- deauth_alert: A deauth attack was detected
- deauth_detector_started: Detector started
- deauth_detector_stopped: Detector stopped
- deauth_error: An error occurred
- keepalive: Periodic keepalive
"""
def generate():
last_keepalive = time.time()
keepalive_interval = SSE_KEEPALIVE_INTERVAL
while True:
try:
# Try to get from the dedicated deauth queue
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@wifi_bp.route('/v2/deauth/alerts')
def v2_deauth_alerts():
"""
Get historical deauth alerts.
Query params:
- limit: Maximum number of alerts to return (default 100)
"""
try:
limit = request.args.get('limit', 100, type=int)
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
scanner = get_wifi_scanner()
alerts = scanner.get_deauth_alerts(limit=limit)
# Also include alerts from DataStore that might have been persisted
try:
stored_alerts = list(app_module.deauth_alerts.values())
# Merge and deduplicate by ID
alert_ids = {a.get('id') for a in alerts}
for alert in stored_alerts:
if alert.get('id') not in alert_ids:
alerts.append(alert)
# Sort by timestamp descending
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
alerts = alerts[:limit]
except Exception:
pass
return jsonify({
'alerts': alerts,
'count': len(alerts),
})
except Exception as e:
logger.exception("Error getting deauth alerts")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
def v2_deauth_clear():
"""Clear deauth alert history."""
try:
scanner = get_wifi_scanner()
scanner.clear_deauth_alerts()
# Clear the queue
while not app_module.deauth_detector_queue.empty():
try:
app_module.deauth_detector_queue.get_nowait()
except queue.Empty:
break
return jsonify({'status': 'cleared'})
except Exception as e:
logger.exception("Error clearing deauth alerts")
return jsonify({'error': str(e)}), 500
+109 -23
View File
@@ -204,6 +204,7 @@ check_tools() {
check_required "dump1090" "ADS-B decoder" dump1090 check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
echo echo
info "GPS:" info "GPS:"
@@ -303,6 +304,10 @@ install_python_deps() {
else else
ok "Python dependencies installed" ok "Python dependencies installed"
fi fi
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
# System apt packages may have older Flask 2.x which is incompatible
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
echo echo
} }
@@ -381,6 +386,49 @@ install_rtlamr_from_source() {
fi fi
} }
install_slowrx_from_source_macos() {
info "slowrx not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install cmake
brew_install fftw
brew_install libsndfile
brew_install gtk+3
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
mkdir -p build && cd build
local cmake_log make_log
cmake_log=$(cmake .. 2>&1) || {
warn "cmake failed for slowrx:"
echo "$cmake_log" | tail -20
exit 1
}
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
exit 1
}
# Install to /usr/local/bin
if [[ -w /usr/local/bin ]]; then
install -m 0755 slowrx /usr/local/bin/slowrx
else
sudo install -m 0755 slowrx /usr/local/bin/slowrx
fi
ok "slowrx installed successfully from source"
)
}
install_multimon_ng_from_source_macos() { install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..." info "multimon-ng not available via Homebrew. Building from source..."
@@ -413,7 +461,7 @@ install_multimon_ng_from_source_macos() {
} }
install_macos_packages() { install_macos_packages() {
TOTAL_STEPS=15 TOTAL_STEPS=16
CURRENT_STEP=0 CURRENT_STEP=0
progress "Checking Homebrew" progress "Checking Homebrew"
@@ -433,6 +481,13 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)" progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew" (brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Installing slowrx (SSTV decoder)"
if ! cmd_exists slowrx; then
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
else
ok "slowrx already installed"
fi
progress "Installing ffmpeg" progress "Installing ffmpeg"
brew_install ffmpeg brew_install ffmpeg
@@ -549,6 +604,8 @@ install_dump1090_from_source_debian() {
|| { fail "Failed to clone FlightAware dump1090"; exit 1; } || { fail "Failed to clone FlightAware dump1090"; exit 1; }
cd "$tmp_dir/dump1090" cd "$tmp_dir/dump1090"
# Remove -Werror to prevent build failures on newer GCC versions
sed -i 's/-Werror//g' Makefile 2>/dev/null || sed -i '' 's/-Werror//g' Makefile
info "Compiling FlightAware dump1090..." info "Compiling FlightAware dump1090..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090 $SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
@@ -556,17 +613,17 @@ install_dump1090_from_source_debian() {
exit 0 exit 0
fi fi
warn "FlightAware build failed. Falling back to antirez/dump1090..." warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
rm -rf "$tmp_dir/dump1090" rm -rf "$tmp_dir/dump1090"
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \ git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone antirez dump1090"; exit 1; } || { fail "Failed to clone wiedehopf/readsb"; exit 1; }
cd "$tmp_dir/dump1090" cd "$tmp_dir/dump1090"
info "Compiling antirez dump1090..." info "Compiling readsb..."
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; } make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090 $SUDO install -m 0755 readsb /usr/local/bin/dump1090
ok "dump1090 installed successfully (antirez)." ok "dump1090 installed successfully (via readsb)."
) )
} }
@@ -626,6 +683,37 @@ install_aiscatcher_from_source_debian() {
) )
} }
install_slowrx_from_source_debian() {
info "slowrx not available via APT. Building from source..."
# slowrx uses a simple Makefile, not CMake
apt_install build-essential git pkg-config \
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
local make_log
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
warn "ISS SSTV decoding will not be available."
exit 1
}
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
ok "slowrx installed successfully."
)
}
install_ubertooth_from_source_debian() { install_ubertooth_from_source_debian() {
info "Building Ubertooth from source..." info "Building Ubertooth from source..."
@@ -761,7 +849,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
TOTAL_STEPS=20 TOTAL_STEPS=21
CURRENT_STEP=0 CURRENT_STEP=0
progress "Updating APT package lists" progress "Updating APT package lists"
@@ -805,19 +893,9 @@ install_debian_packages() {
progress "RTL-SDR Blog drivers" progress "RTL-SDR Blog drivers"
if cmd_exists rtl_test; then if cmd_exists rtl_test; then
info "RTL-SDR tools already installed." ok "RTL-SDR drivers already installed"
if $IS_DRAGONOS; then
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
else
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
echo "Installing these will REPLACE your current RTL-SDR drivers."
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
install_rtlsdr_blog_drivers_debian
else
ok "Keeping existing RTL-SDR drivers."
fi
fi
else else
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
install_rtlsdr_blog_drivers_debian install_rtlsdr_blog_drivers_debian
fi fi
@@ -827,6 +905,9 @@ install_debian_packages() {
progress "Installing direwolf (APRS decoder)" progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true apt_install direwolf || true
progress "Installing slowrx (SSTV decoder)"
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
progress "Installing ffmpeg" progress "Installing ffmpeg"
apt_install ffmpeg apt_install ffmpeg
@@ -916,11 +997,12 @@ install_debian_packages() {
setup_udev_rules_debian setup_udev_rules_debian
progress "Kernel driver configuration" progress "Kernel driver configuration"
echo
if $IS_DRAGONOS; then if $IS_DRAGONOS; then
info "DragonOS already has RTL-SDR drivers configured correctly." info "DragonOS already has RTL-SDR drivers configured correctly."
info "Skipping kernel driver blacklist (not needed)." elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
ok "DVB kernel drivers already blacklisted"
else else
echo
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access." echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
echo "Blacklisting them allows rtl_sdr tools to access the device." echo "Blacklisting them allows rtl_sdr tools to access the device."
if ask_yes_no "Blacklist conflicting kernel drivers?"; then if ask_yes_no "Blacklist conflicting kernel drivers?"; then
@@ -1010,3 +1092,7 @@ main() {
} }
main "$@" main "$@"
# Clear traps before exiting to prevent spurious errors during cleanup
trap - ERR EXIT
exit 0
+134 -37
View File
@@ -5,6 +5,8 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10; --bg-dark: #0a0c10;
--bg-panel: #0f1218; --bg-panel: #0f1218;
--bg-card: #151a23; --bg-card: #151a23;
@@ -25,7 +27,7 @@
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: var(--font-sans);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -71,7 +73,7 @@ body {
} }
} }
/* Header - Mobile first */ /* Header */
.header { .header {
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -81,20 +83,19 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 12px;
min-height: 52px; min-height: 52px;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.header { .header {
padding: 12px 20px; padding: 12px 20px;
flex-wrap: nowrap;
} }
} }
.logo { .logo {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
letter-spacing: 2px; letter-spacing: 2px;
@@ -126,14 +127,52 @@ body {
letter-spacing: 2px; letter-spacing: 2px;
} }
} }
}
.status-bar { .status-bar {
display: flex; display: flex;
gap: 20px; gap: 12px;
align-items: center; align-items: center;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
flex-wrap: nowrap;
}
.agent-selector-compact {
display: flex;
align-items: center;
gap: 8px;
}
.agent-selector-compact .agent-select-sm {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.agent-selector-compact .agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-selector-compact .agent-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.agent-selector-compact .show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
} }
.status-dot { .status-dot {
@@ -172,15 +211,15 @@ body {
} }
/* Main dashboard grid - Mobile first */ /* Main dashboard grid - Mobile first */
/* Header ~55px + Stats strip ~36px = ~91px, using 95px for safety */ /* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard { .dashboard {
position: relative; position: relative;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
height: calc(100dvh - 95px); height: calc(100dvh - 160px);
height: calc(100vh - 95px); /* Fallback */ height: calc(100vh - 160px); /* Fallback */
min-height: 400px; min-height: 400px;
} }
@@ -216,7 +255,7 @@ body {
@media (min-width: 1024px) { @media (min-width: 1024px) {
.acars-sidebar { .acars-sidebar {
display: flex; display: flex;
max-height: calc(100dvh - 95px); max-height: calc(100dvh - 160px);
} }
} }
@@ -624,7 +663,7 @@ body {
} }
.telemetry-value { .telemetry-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: var(--accent-cyan); color: var(--accent-cyan);
} }
@@ -680,7 +719,7 @@ body {
} }
.aircraft-icao { .aircraft-icao {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
@@ -700,7 +739,7 @@ body {
} }
.aircraft-detail-value { .aircraft-detail-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
color: var(--accent-cyan); color: var(--accent-cyan);
font-size: 11px; font-size: 11px;
} }
@@ -790,7 +829,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
} }
@@ -801,7 +840,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
} }
@@ -814,6 +853,24 @@ body {
color: var(--accent-green); color: var(--accent-green);
} }
/* Bias-T toggle styling */
.bias-t-label {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
border: 1px solid var(--accent-orange, #ff6400);
border-radius: 4px;
color: var(--accent-orange, #ff6400);
font-weight: 500;
font-size: 10px;
}
.bias-t-label input[type="checkbox"] {
accent-color: var(--accent-orange, #ff6400);
}
.control-group.airband-group { .control-group.airband-group {
background: rgba(245, 158, 11, 0.05); background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.2);
@@ -861,7 +918,7 @@ body {
border: none; border: none;
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: #fff;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -893,7 +950,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
} }
@@ -903,10 +960,7 @@ body {
background: var(--bg-dark) !important; background: var(--bg-dark) !important;
} }
.leaflet-tile-pane, /* Using actual dark tiles now - no filter needed */
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-panel) !important; background: var(--bg-panel) !important;
@@ -1008,7 +1062,7 @@ body {
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
@@ -1042,7 +1096,7 @@ body {
} }
.airband-status { .airband-status {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
padding: 0 8px; padding: 0 8px;
color: var(--text-muted); color: var(--text-muted);
@@ -1146,6 +1200,55 @@ body {
50% { opacity: 0.5; transform: scale(0.8); } 50% { opacity: 0.5; transform: scale(0.8); }
} }
/* ============================================
TRACKED AIRCRAFT PULSATING RING
============================================ */
.aircraft-marker.selected {
position: relative;
}
.tracking-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border: 2px solid var(--accent-cyan);
border-radius: 50%;
animation: tracking-pulse 1.5s ease-out infinite;
pointer-events: none;
}
.tracking-ring-inner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 28px;
border: 1px solid var(--accent-cyan);
border-radius: 50%;
animation: tracking-pulse 1.5s ease-out infinite 0.3s;
pointer-events: none;
}
@keyframes tracking-pulse {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
border-color: rgba(74, 158, 255, 1);
}
50% {
opacity: 0.6;
}
100% {
transform: translate(-50%, -50%) scale(1.8);
opacity: 0;
border-color: rgba(74, 158, 255, 0);
}
}
/* ============== MOBILE/TABLET FIXES ============== */ /* ============== MOBILE/TABLET FIXES ============== */
@media (max-width: 1023px) { @media (max-width: 1023px) {
/* Dashboard - allow scrolling */ /* Dashboard - allow scrolling */
@@ -1153,7 +1256,7 @@ body {
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
height: auto !important; height: auto !important;
min-height: calc(100dvh - 95px); min-height: calc(100dvh - 160px);
overflow-y: auto !important; overflow-y: auto !important;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -1222,12 +1325,6 @@ body {
padding: 6px 8px; padding: 6px 8px;
} }
/* Status bar - compact on mobile */
.status-bar {
flex-wrap: wrap;
gap: 6px;
}
/* Strip time smaller on mobile */ /* Strip time smaller on mobile */
.strip-time { .strip-time {
font-size: 10px; font-size: 10px;
@@ -1343,7 +1440,7 @@ body {
} }
.strip-value { .strip-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -1481,7 +1578,7 @@ body {
.report-grid span:nth-child(even) { .report-grid span:nth-child(even) {
color: var(--text-primary); color: var(--text-primary);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.report-highlights { .report-highlights {
@@ -1720,7 +1817,7 @@ body {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
padding-left: 8px; padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2); border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap; white-space: nowrap;
@@ -1874,7 +1971,7 @@ body {
} }
.squawk-code { .squawk-code {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-weight: 700; font-weight: 700;
color: var(--accent-cyan); color: var(--accent-cyan);
font-size: 12px; font-size: 12px;
+8 -6
View File
@@ -5,6 +5,8 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10; --bg-dark: #0a0c10;
--bg-panel: #0f1218; --bg-panel: #0f1218;
--bg-card: #141a24; --bg-card: #141a24;
@@ -20,14 +22,14 @@
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: var(--font-sans);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
} }
.mono { .mono {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.radar-bg { .radar-bg {
@@ -91,7 +93,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
} }
@@ -268,7 +270,7 @@ body {
} }
.status-pill { .status-pill {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 999px; border-radius: 999px;
@@ -306,7 +308,7 @@ body {
} }
.panel-meta { .panel-meta {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--accent-cyan); color: var(--accent-cyan);
} }
@@ -347,7 +349,7 @@ body {
} }
.mono { .mono {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.empty-row td, .empty-row td,
+331 -343
View File
@@ -1,343 +1,331 @@
/* /*
* Agents Management CSS * Agents Management CSS
* Styles for the remote agent management interface * Styles for the remote agent management interface
*/ * Inherits CSS variables from core/variables.css
*/
/* CSS Variables (inherited from main theme) */
:root { /* Agent indicator in navigation */
--bg-primary: #0a0a0f; .agent-indicator {
--bg-secondary: #12121a; display: flex;
--text-primary: #e0e0e0; align-items: center;
--text-secondary: #888; gap: 8px;
--border-color: #1a1a2e; padding: 6px 12px;
--accent-cyan: #00d4ff; background: rgba(0, 212, 255, 0.1);
--accent-green: #00ff88; border: 1px solid rgba(0, 212, 255, 0.3);
--accent-red: #ff3366; border-radius: 20px;
--accent-orange: #ff9f1c; cursor: pointer;
} transition: all 0.2s;
}
/* Agent indicator in navigation */
.agent-indicator { .agent-indicator:hover {
display: flex; background: rgba(0, 212, 255, 0.2);
align-items: center; border-color: var(--accent-cyan);
gap: 8px; }
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1); .agent-indicator-dot {
border: 1px solid rgba(0, 212, 255, 0.3); width: 8px;
border-radius: 20px; height: 8px;
cursor: pointer; border-radius: 50%;
transition: all 0.2s; background: var(--accent-green);
} box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2); .agent-indicator-dot.remote {
border-color: var(--accent-cyan); background: var(--accent-cyan);
} box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px; .agent-indicator-dot.multiple {
height: 8px; background: var(--accent-orange);
border-radius: 50%; box-shadow: 0 0 6px var(--accent-orange);
background: var(--accent-green); }
box-shadow: 0 0 6px var(--accent-green);
} .agent-indicator-label {
font-size: 11px;
.agent-indicator-dot.remote { color: var(--text-primary);
background: var(--accent-cyan); font-family: var(--font-mono);
box-shadow: 0 0 6px var(--accent-cyan); }
}
.agent-indicator-count {
.agent-indicator-dot.multiple { font-size: 10px;
background: var(--accent-orange); padding: 2px 6px;
box-shadow: 0 0 6px var(--accent-orange); background: rgba(0, 212, 255, 0.2);
} border-radius: 10px;
color: var(--accent-cyan);
.agent-indicator-label { }
font-size: 11px;
color: var(--text-primary); /* Agent selector dropdown */
font-family: 'JetBrains Mono', monospace; .agent-selector {
} position: relative;
}
.agent-indicator-count {
font-size: 10px; .agent-selector-dropdown {
padding: 2px 6px; position: absolute;
background: rgba(0, 212, 255, 0.2); top: 100%;
border-radius: 10px; right: 0;
color: var(--accent-cyan); margin-top: 8px;
} min-width: 280px;
background: var(--bg-secondary);
/* Agent selector dropdown */ border: 1px solid var(--border-color);
.agent-selector { border-radius: 8px;
position: relative; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} z-index: 1000;
display: none;
.agent-selector-dropdown { }
position: absolute;
top: 100%; .agent-selector-dropdown.show {
right: 0; display: block;
margin-top: 8px; }
min-width: 280px;
background: var(--bg-secondary); .agent-selector-header {
border: 1px solid var(--border-color); display: flex;
border-radius: 8px; justify-content: space-between;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); align-items: center;
z-index: 1000; padding: 12px 15px;
display: none; border-bottom: 1px solid var(--border-color);
} }
.agent-selector-dropdown.show { .agent-selector-header h4 {
display: block; margin: 0;
} font-size: 12px;
color: var(--accent-cyan);
.agent-selector-header { text-transform: uppercase;
display: flex; letter-spacing: 1px;
justify-content: space-between; }
align-items: center;
padding: 12px 15px; .agent-selector-manage {
border-bottom: 1px solid var(--border-color); font-size: 11px;
} color: var(--accent-cyan);
text-decoration: none;
.agent-selector-header h4 { }
margin: 0;
font-size: 12px; .agent-selector-manage:hover {
color: var(--accent-cyan); text-decoration: underline;
text-transform: uppercase; }
letter-spacing: 1px;
} .agent-selector-list {
max-height: 300px;
.agent-selector-manage { overflow-y: auto;
font-size: 11px; }
color: var(--accent-cyan);
text-decoration: none; .agent-selector-item {
} display: flex;
align-items: center;
.agent-selector-manage:hover { gap: 10px;
text-decoration: underline; padding: 10px 15px;
} cursor: pointer;
transition: background 0.2s;
.agent-selector-list { border-bottom: 1px solid var(--border-color);
max-height: 300px; }
overflow-y: auto;
} .agent-selector-item:last-child {
border-bottom: none;
.agent-selector-item { }
display: flex;
align-items: center; .agent-selector-item:hover {
gap: 10px; background: rgba(0, 212, 255, 0.1);
padding: 10px 15px; }
cursor: pointer;
transition: background 0.2s; .agent-selector-item.selected {
border-bottom: 1px solid var(--border-color); background: rgba(0, 212, 255, 0.15);
} border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item:last-child {
border-bottom: none; .agent-selector-item.local {
} border-left: 3px solid var(--accent-green);
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1); .agent-selector-item-status {
} width: 8px;
height: 8px;
.agent-selector-item.selected { border-radius: 50%;
background: rgba(0, 212, 255, 0.15); flex-shrink: 0;
border-left: 3px solid var(--accent-cyan); }
}
.agent-selector-item-status.online {
.agent-selector-item.local { background: var(--accent-green);
border-left: 3px solid var(--accent-green); }
}
.agent-selector-item-status.offline {
.agent-selector-item-status { background: var(--accent-red);
width: 8px; }
height: 8px;
border-radius: 50%; .agent-selector-item-info {
flex-shrink: 0; flex: 1;
} min-width: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green); .agent-selector-item-name {
} font-size: 13px;
color: var(--text-primary);
.agent-selector-item-status.offline { white-space: nowrap;
background: var(--accent-red); overflow: hidden;
} text-overflow: ellipsis;
}
.agent-selector-item-info {
flex: 1; .agent-selector-item-url {
min-width: 0; font-size: 10px;
} color: var(--text-secondary);
font-family: var(--font-mono);
.agent-selector-item-name { white-space: nowrap;
font-size: 13px; overflow: hidden;
color: var(--text-primary); text-overflow: ellipsis;
white-space: nowrap; }
overflow: hidden;
text-overflow: ellipsis; .agent-selector-item-check {
} color: var(--accent-green);
opacity: 0;
.agent-selector-item-url { }
font-size: 10px;
color: var(--text-secondary); .agent-selector-item.selected .agent-selector-item-check {
font-family: 'JetBrains Mono', monospace; opacity: 1;
white-space: nowrap; }
overflow: hidden;
text-overflow: ellipsis; /* Agent badge in data displays */
} .agent-badge {
display: inline-flex;
.agent-selector-item-check { align-items: center;
color: var(--accent-green); gap: 4px;
opacity: 0; padding: 2px 8px;
} font-size: 10px;
background: rgba(0, 212, 255, 0.1);
.agent-selector-item.selected .agent-selector-item-check { color: var(--accent-cyan);
opacity: 1; border-radius: 10px;
} font-family: var(--font-mono);
}
/* Agent badge in data displays */
.agent-badge { .agent-badge.local,
display: inline-flex; .agent-badge.agent-local {
align-items: center; background: rgba(0, 255, 136, 0.1);
gap: 4px; color: var(--accent-green);
padding: 2px 8px; }
font-size: 10px;
background: rgba(0, 212, 255, 0.1); .agent-badge.agent-remote {
color: var(--accent-cyan); background: rgba(0, 212, 255, 0.1);
border-radius: 10px; color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; }
}
/* WiFi table agent column */
.agent-badge.local, .wifi-networks-table .col-agent {
.agent-badge.agent-local { width: 100px;
background: rgba(0, 255, 136, 0.1); text-align: center;
color: var(--accent-green); }
}
.wifi-networks-table th.col-agent {
.agent-badge.agent-remote { font-size: 10px;
background: rgba(0, 212, 255, 0.1); }
color: var(--accent-cyan);
} /* Bluetooth table agent column */
.bt-devices-table .col-agent {
/* WiFi table agent column */ width: 100px;
.wifi-networks-table .col-agent { text-align: center;
width: 100px; }
text-align: center;
} .agent-badge-dot {
width: 6px;
.wifi-networks-table th.col-agent { height: 6px;
font-size: 10px; border-radius: 50%;
} background: currentColor;
}
/* Bluetooth table agent column */
.bt-devices-table .col-agent { /* Agent column in data tables */
width: 100px; .data-table .agent-col {
text-align: center; width: 120px;
} max-width: 120px;
}
.agent-badge-dot {
width: 6px; /* Multi-agent stream indicator */
height: 6px; .multi-agent-indicator {
border-radius: 50%; position: fixed;
background: currentColor; bottom: 20px;
} left: 20px;
display: flex;
/* Agent column in data tables */ align-items: center;
.data-table .agent-col { gap: 8px;
width: 120px; padding: 8px 12px;
max-width: 120px; background: var(--bg-secondary);
} border: 1px solid var(--border-color);
border-radius: 20px;
/* Multi-agent stream indicator */ font-size: 11px;
.multi-agent-indicator { color: var(--text-secondary);
position: fixed; z-index: 100;
bottom: 20px; }
left: 20px;
display: flex; .multi-agent-indicator.active {
align-items: center; border-color: var(--accent-cyan);
gap: 8px; color: var(--accent-cyan);
padding: 8px 12px; }
background: var(--bg-secondary);
border: 1px solid var(--border-color); .multi-agent-indicator-pulse {
border-radius: 20px; width: 8px;
font-size: 11px; height: 8px;
color: var(--text-secondary); border-radius: 50%;
z-index: 100; background: var(--accent-cyan);
} animation: pulse 2s infinite;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan); @keyframes pulse {
color: var(--accent-cyan); 0%, 100% { opacity: 1; transform: scale(1); }
} 50% { opacity: 0.5; transform: scale(0.8); }
}
.multi-agent-indicator-pulse {
width: 8px; /* Agent connection status toast */
height: 8px; .agent-toast {
border-radius: 50%; position: fixed;
background: var(--accent-cyan); top: 80px;
animation: pulse 2s infinite; right: 20px;
} padding: 10px 15px;
background: var(--bg-secondary);
@keyframes pulse { border: 1px solid var(--border-color);
0%, 100% { opacity: 1; transform: scale(1); } border-radius: 6px;
50% { opacity: 0.5; transform: scale(0.8); } font-size: 12px;
} z-index: 1001;
animation: slideInRight 0.3s ease;
/* Agent connection status toast */ }
.agent-toast {
position: fixed; .agent-toast.connected {
top: 80px; border-color: var(--accent-green);
right: 20px; color: var(--accent-green);
padding: 10px 15px; }
background: var(--bg-secondary);
border: 1px solid var(--border-color); .agent-toast.disconnected {
border-radius: 6px; border-color: var(--accent-red);
font-size: 12px; color: var(--accent-red);
z-index: 1001; }
animation: slideInRight 0.3s ease;
} @keyframes slideInRight {
from {
.agent-toast.connected { transform: translateX(100%);
border-color: var(--accent-green); opacity: 0;
color: var(--accent-green); }
} to {
transform: translateX(0);
.agent-toast.disconnected { opacity: 1;
border-color: var(--accent-red); }
color: var(--accent-red); }
}
/* Responsive adjustments */
@keyframes slideInRight { @media (max-width: 768px) {
from { .agent-indicator {
transform: translateX(100%); padding: 4px 8px;
opacity: 0; }
}
to { .agent-indicator-label {
transform: translateX(0); display: none;
opacity: 1; }
}
} .agent-selector-dropdown {
position: fixed;
/* Responsive adjustments */ top: auto;
@media (max-width: 768px) { bottom: 0;
.agent-indicator { left: 0;
padding: 4px 8px; right: 0;
} margin: 0;
border-radius: 16px 16px 0 0;
.agent-indicator-label { max-height: 60vh;
display: none; }
}
.agents-grid {
.agent-selector-dropdown { grid-template-columns: 1fr;
position: fixed; }
top: auto; }
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}
+151 -33
View File
@@ -8,6 +8,8 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10; --bg-dark: #0a0c10;
--bg-panel: #0f1218; --bg-panel: #0f1218;
--bg-card: #151a23; --bg-card: #151a23;
@@ -28,7 +30,7 @@
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: var(--font-sans);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -97,7 +99,7 @@ body {
} }
.logo { .logo {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
letter-spacing: 2px; letter-spacing: 2px;
@@ -132,10 +134,49 @@ body {
.status-bar { .status-bar {
display: flex; display: flex;
gap: 20px; gap: 12px;
align-items: center; align-items: center;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
flex-wrap: nowrap;
}
.agent-selector-compact {
display: flex;
align-items: center;
gap: 8px;
}
.agent-selector-compact .agent-select-sm {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.agent-selector-compact .agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-selector-compact .agent-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.agent-selector-compact .show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
} }
.back-link { .back-link {
@@ -183,7 +224,7 @@ body {
} }
.strip-value { .strip-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -287,7 +328,7 @@ body {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
padding-left: 8px; padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2); border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap; white-space: nowrap;
@@ -320,14 +361,15 @@ body {
} }
/* Main dashboard grid - Mobile first */ /* Main dashboard grid - Mobile first */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard { .dashboard {
position: relative; position: relative;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
height: calc(100dvh - 95px); height: calc(100dvh - 160px);
height: calc(100vh - 95px); height: calc(100vh - 160px);
min-height: 400px; min-height: 400px;
} }
@@ -367,13 +409,10 @@ body {
/* Leaflet overrides - Dark map styling */ /* Leaflet overrides - Dark map styling */
.leaflet-container { .leaflet-container {
background: var(--bg-dark) !important; background: var(--bg-dark) !important;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.leaflet-tile-pane, /* Using actual dark tiles now - no filter needed */
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-panel) !important; background: var(--bg-panel) !important;
@@ -441,7 +480,7 @@ body {
padding: 10px 15px; padding: 10px 15px;
background: rgba(74, 158, 255, 0.05); background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1); border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', 'JetBrains Mono', monospace; font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
letter-spacing: 2px; letter-spacing: 2px;
@@ -513,7 +552,7 @@ body {
} }
.vessel-name { .vessel-name {
font-family: 'Orbitron', 'JetBrains Mono', monospace; font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -521,7 +560,7 @@ body {
} }
.vessel-mmsi { .vessel-mmsi {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
@@ -551,7 +590,7 @@ body {
} }
.detail-value { .detail-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: var(--accent-cyan); color: var(--accent-cyan);
} }
@@ -607,20 +646,20 @@ body {
} }
.vessel-item-name { .vessel-item-name {
font-family: 'Orbitron', 'JetBrains Mono', monospace; font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
} }
.vessel-item-type { .vessel-item-type {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.vessel-item-speed { .vessel-item-speed {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--accent-cyan); color: var(--accent-cyan);
text-align: right; text-align: right;
@@ -690,7 +729,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
} }
@@ -701,7 +740,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
} }
@@ -720,7 +759,7 @@ body {
border: none; border: none;
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: #fff;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -759,6 +798,55 @@ body {
filter: drop-shadow(0 0 8px rgba(255,255,255,0.8)) !important; filter: drop-shadow(0 0 8px rgba(255,255,255,0.8)) !important;
} }
/* ============================================
TRACKED VESSEL PULSATING RING
============================================ */
.vessel-marker.selected {
position: relative;
}
.tracking-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border: 2px solid var(--accent-cyan);
border-radius: 50%;
animation: tracking-pulse 1.5s ease-out infinite;
pointer-events: none;
}
.tracking-ring-inner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 28px;
border: 1px solid var(--accent-cyan);
border-radius: 50%;
animation: tracking-pulse 1.5s ease-out infinite 0.3s;
pointer-events: none;
}
@keyframes tracking-pulse {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
border-color: rgba(74, 158, 255, 1);
}
50% {
opacity: 0.6;
}
100% {
transform: translate(-50%, -50%) scale(1.8);
opacity: 0;
border-color: rgba(74, 158, 255, 0);
}
}
/* Range rings */ /* Range rings */
.range-ring { .range-ring {
fill: none; fill: none;
@@ -788,7 +876,7 @@ body {
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
height: auto !important; height: auto !important;
min-height: calc(100dvh - 95px); min-height: calc(100dvh - 160px);
overflow-y: auto !important; overflow-y: auto !important;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -958,7 +1046,7 @@ body {
padding: 6px 12px; padding: 6px 12px;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(245, 158, 11, 0.1); border-bottom: 1px solid rgba(245, 158, 11, 0.1);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
} }
@@ -1033,7 +1121,7 @@ body {
} }
.dsc-message-category { .dsc-message-category {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -1050,13 +1138,13 @@ body {
} }
.dsc-message-time { .dsc-message-time {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
color: var(--text-dim); color: var(--text-dim);
} }
.dsc-message-mmsi { .dsc-message-mmsi {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--accent-orange); color: var(--accent-orange);
} }
@@ -1074,7 +1162,7 @@ body {
} }
.dsc-message-pos { .dsc-message-pos {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -1102,7 +1190,7 @@ body {
} }
.dsc-distress-alert .dsc-alert-header { .dsc-distress-alert .dsc-alert-header {
font-family: 'Orbitron', 'JetBrains Mono', monospace; font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--accent-red); color: var(--accent-red);
@@ -1111,7 +1199,7 @@ body {
} }
.dsc-distress-alert .dsc-alert-mmsi { .dsc-distress-alert .dsc-alert-mmsi {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 16px; font-size: 16px;
color: var(--accent-cyan); color: var(--accent-cyan);
margin-bottom: 8px; margin-bottom: 8px;
@@ -1131,7 +1219,7 @@ body {
} }
.dsc-distress-alert .dsc-alert-position { .dsc-distress-alert .dsc-alert-position {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
color: var(--accent-cyan); color: var(--accent-cyan);
margin-bottom: 16px; margin-bottom: 16px;
@@ -1142,7 +1230,7 @@ body {
border: none; border: none;
color: white; color: white;
padding: 10px 24px; padding: 10px 24px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -1201,3 +1289,33 @@ body {
font-size: 18px; font-size: 18px;
} }
} }
/* GPS Indicator */
.gps-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: rgba(34, 197, 94, 0.15);
border: 1px solid #22c55e;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-left: 10px;
}
.gps-indicator .gps-dot {
width: 6px;
height: 6px;
background: #22c55e;
border-radius: 50%;
animation: gps-pulse 2s ease-in-out infinite;
}
@keyframes gps-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
/* Function Strip (Action Bar) - Shared across modes
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
*/
.function-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 10px;
overflow: visible;
min-height: 44px;
}
.function-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
/* Stats */
.function-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.function-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip .strip-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.function-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.function-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.function-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.function-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.function-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.function-strip .strip-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.function-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.function-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.function-strip .strip-input:hover,
.function-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
.function-strip .strip-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Wider input for frequency values */
.function-strip .strip-input.wide {
width: 70px;
}
/* Tool Status Indicators */
.function-strip .strip-tools {
display: flex;
gap: 4px;
}
.function-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.function-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip .strip-tool.warn {
background: rgba(255, 193, 7, 0.2);
color: var(--accent-yellow);
border-color: rgba(255, 193, 7, 0.3);
}
/* Buttons */
.function-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.function-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.function-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.function-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.function-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.function-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.function-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.function-strip .status-dot.inactive {
background: var(--text-muted);
}
.function-strip .status-dot.active,
.function-strip .status-dot.scanning,
.function-strip .status-dot.decoding {
background: var(--accent-cyan);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.listening,
.function-strip .status-dot.tracking,
.function-strip .status-dot.receiving {
background: var(--accent-green);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.sweeping {
background: var(--accent-orange);
animation: strip-pulse 1s ease-in-out infinite;
}
.function-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.function-strip .strip-time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* Mode-specific accent colors */
.function-strip.pager-strip .strip-stat {
background: rgba(255, 193, 7, 0.05);
border-color: rgba(255, 193, 7, 0.15);
}
.function-strip.pager-strip .strip-stat:hover {
background: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.3);
}
.function-strip.pager-strip .strip-value {
color: var(--accent-yellow);
}
.function-strip.sensor-strip .strip-stat {
background: rgba(0, 255, 136, 0.05);
border-color: rgba(0, 255, 136, 0.15);
}
.function-strip.sensor-strip .strip-stat:hover {
background: rgba(0, 255, 136, 0.1);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip.sensor-strip .strip-value {
color: var(--accent-green);
}
.function-strip.bt-strip .strip-stat {
background: rgba(0, 122, 255, 0.05);
border-color: rgba(0, 122, 255, 0.15);
}
.function-strip.bt-strip .strip-stat:hover {
background: rgba(0, 122, 255, 0.1);
border-color: rgba(0, 122, 255, 0.3);
}
.function-strip.bt-strip .strip-value {
color: #0a84ff;
}
.function-strip.wifi-strip .strip-stat {
background: rgba(255, 149, 0, 0.05);
border-color: rgba(255, 149, 0, 0.15);
}
.function-strip.wifi-strip .strip-stat:hover {
background: rgba(255, 149, 0, 0.1);
border-color: rgba(255, 149, 0, 0.3);
}
.function-strip.wifi-strip .strip-value {
color: var(--accent-orange);
}
.function-strip.tscm-strip {
margin-top: 4px; /* Extra clearance to prevent top clipping */
}
.function-strip.tscm-strip .strip-stat {
background: rgba(255, 59, 48, 0.15);
border: 1px solid rgba(255, 59, 48, 0.4);
}
.function-strip.tscm-strip .strip-stat:hover {
background: rgba(255, 59, 48, 0.25);
border-color: rgba(255, 59, 48, 0.6);
}
.function-strip.tscm-strip .strip-value {
color: #ef4444; /* Explicit red color */
}
.function-strip.tscm-strip .strip-label {
color: #9ca3af; /* Explicit light gray */
}
.function-strip.tscm-strip .strip-select {
color: #e8eaed; /* Explicit white for selects */
background: rgba(0, 0, 0, 0.4);
}
.function-strip.tscm-strip .strip-btn {
color: #e8eaed; /* Explicit white for buttons */
}
.function-strip.tscm-strip .strip-tool {
color: #e8eaed; /* Explicit white for tool indicators */
}
.function-strip.tscm-strip .strip-time,
.function-strip.tscm-strip .strip-status span {
color: #9ca3af; /* Explicit gray for status/time */
}
.function-strip.rtlamr-strip .strip-stat {
background: rgba(175, 82, 222, 0.05);
border-color: rgba(175, 82, 222, 0.15);
}
.function-strip.rtlamr-strip .strip-stat:hover {
background: rgba(175, 82, 222, 0.1);
border-color: rgba(175, 82, 222, 0.3);
}
.function-strip.rtlamr-strip .strip-value {
color: #af52de;
}
.function-strip.listening-strip .strip-stat {
background: rgba(74, 158, 255, 0.05);
border-color: rgba(74, 158, 255, 0.15);
}
.function-strip.listening-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip.listening-strip .strip-value {
color: var(--accent-cyan);
}
/* Threat-colored stats for TSCM */
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
+9 -1
View File
@@ -14,10 +14,18 @@
.radar-device { .radar-device {
transition: transform 0.2s ease; transition: transform 0.2s ease;
transform-origin: center center;
cursor: pointer;
} }
.radar-device:hover { .radar-device:hover {
transform: scale(1.3); transform: scale(1.2);
}
/* Invisible larger hit area to prevent hover flicker */
.radar-device-hitarea {
fill: transparent;
pointer-events: all;
} }
.radar-dot-pulse circle:first-child { .radar-dot-pulse circle:first-child {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+626
View File
@@ -0,0 +1,626 @@
/**
* Toast Notification System
* Reusable toast notifications for update alerts and other messages
*/
/* ============================================
TOAST CONTAINER
============================================ */
#toastContainer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10001;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
#toastContainer > * {
pointer-events: auto;
}
/* ============================================
UPDATE TOAST
============================================ */
.update-toast {
display: flex;
background: var(--bg-card, #121620);
border: 1px solid var(--border-color, #1f2937);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-width: 340px;
overflow: hidden;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.update-toast.show {
opacity: 1;
transform: translateX(0);
}
.update-toast-indicator {
width: 4px;
background: var(--accent-green, #22c55e);
flex-shrink: 0;
}
.update-toast-content {
flex: 1;
padding: 14px 16px;
}
.update-toast-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.update-toast-icon {
color: var(--accent-green, #22c55e);
display: flex;
align-items: center;
}
.update-toast-icon svg {
width: 18px;
height: 18px;
}
.update-toast-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #e8eaed);
flex: 1;
}
.update-toast-close {
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0;
margin: -4px;
transition: color 0.15s;
}
.update-toast-close:hover {
color: var(--text-secondary, #9ca3af);
}
.update-toast-body {
font-size: 12px;
color: var(--text-secondary, #9ca3af);
margin-bottom: 12px;
}
.update-toast-body strong {
color: var(--accent-cyan, #4a9eff);
}
.update-toast-actions {
display: flex;
gap: 8px;
}
.update-toast-btn {
font-family: inherit;
font-size: 11px;
font-weight: 500;
padding: 6px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.update-toast-btn-primary {
background: var(--accent-green, #22c55e);
color: #000;
}
.update-toast-btn-primary:hover {
background: #34d673;
}
.update-toast-btn-secondary {
background: var(--bg-secondary, #0f1218);
color: var(--text-secondary, #9ca3af);
border: 1px solid var(--border-color, #1f2937);
}
.update-toast-btn-secondary:hover {
background: var(--bg-tertiary, #151a23);
border-color: var(--border-light, #374151);
}
/* ============================================
UPDATE MODAL
============================================ */
.update-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 10002;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.update-modal-overlay.show {
opacity: 1;
visibility: visible;
}
.update-modal {
background: var(--bg-card, #121620);
border: 1px solid var(--border-color, #1f2937);
border-radius: 12px;
width: 90%;
max-width: 520px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
transform: scale(0.95);
transition: transform 0.2s ease;
}
.update-modal-overlay.show .update-modal {
transform: scale(1);
}
.update-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1f2937);
}
.update-modal-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e8eaed);
}
.update-modal-icon {
color: var(--accent-green, #22c55e);
display: flex;
}
.update-modal-icon svg {
width: 22px;
height: 22px;
}
.update-modal-close {
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 4px;
transition: color 0.15s;
}
.update-modal-close:hover {
color: var(--accent-red, #ef4444);
}
.update-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
/* Version Info */
.update-version-info {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px;
background: var(--bg-secondary, #0f1218);
border-radius: 8px;
margin-bottom: 20px;
}
.update-version-current,
.update-version-latest {
text-align: center;
}
.update-version-label {
display: block;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #4b5563);
margin-bottom: 4px;
}
.update-version-value {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
color: var(--text-secondary, #9ca3af);
}
.update-version-new {
color: var(--accent-green, #22c55e);
}
.update-version-arrow {
color: var(--text-dim, #4b5563);
}
.update-version-arrow svg {
width: 20px;
height: 20px;
}
/* Sections */
.update-section {
margin-bottom: 20px;
}
.update-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #4b5563);
margin-bottom: 10px;
}
.update-release-notes {
font-size: 13px;
color: var(--text-secondary, #9ca3af);
background: var(--bg-secondary, #0f1218);
border: 1px solid var(--border-color, #1f2937);
border-radius: 6px;
padding: 14px;
max-height: 200px;
overflow-y: auto;
line-height: 1.6;
}
.update-release-notes h2,
.update-release-notes h3,
.update-release-notes h4 {
color: var(--text-primary, #e8eaed);
margin: 16px 0 8px 0;
font-size: 14px;
}
.update-release-notes h2:first-child,
.update-release-notes h3:first-child,
.update-release-notes h4:first-child {
margin-top: 0;
}
.update-release-notes ul {
margin: 8px 0;
padding-left: 20px;
}
.update-release-notes li {
margin: 4px 0;
}
.update-release-notes code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-tertiary, #151a23);
padding: 2px 6px;
border-radius: 3px;
color: var(--accent-cyan, #4a9eff);
}
.update-release-notes p {
margin: 8px 0;
}
/* Warning */
.update-warning {
display: flex;
gap: 12px;
padding: 14px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 6px;
margin-bottom: 16px;
}
.update-warning-icon {
color: var(--accent-orange, #f59e0b);
flex-shrink: 0;
}
.update-warning-icon svg {
width: 20px;
height: 20px;
}
.update-warning-text {
font-size: 12px;
color: var(--text-secondary, #9ca3af);
}
.update-warning-text strong {
display: block;
color: var(--accent-orange, #f59e0b);
margin-bottom: 4px;
}
.update-warning-text p {
margin: 0;
}
/* Options */
.update-options {
margin-bottom: 16px;
}
.update-option {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
}
.update-option input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-cyan, #4a9eff);
}
/* Progress */
.update-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 20px;
font-size: 13px;
color: var(--text-secondary, #9ca3af);
}
.update-progress-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #1f2937);
border-top-color: var(--accent-cyan, #4a9eff);
border-radius: 50%;
animation: updateSpin 0.8s linear infinite;
}
@keyframes updateSpin {
to { transform: rotate(360deg); }
}
/* Results */
.update-result {
display: flex;
gap: 12px;
padding: 14px;
border-radius: 6px;
margin-top: 16px;
}
.update-result-icon {
flex-shrink: 0;
}
.update-result-icon svg {
width: 20px;
height: 20px;
}
.update-result-text {
font-size: 12px;
line-height: 1.5;
}
.update-result-text code {
font-family: var(--font-mono);
font-size: 11px;
background: rgba(0, 0, 0, 0.2);
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
word-break: break-all;
}
.update-result-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.update-result-success .update-result-icon {
color: var(--accent-green, #22c55e);
}
.update-result-success .update-result-text {
color: var(--accent-green, #22c55e);
}
.update-result-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.update-result-error .update-result-icon {
color: var(--accent-red, #ef4444);
}
.update-result-error .update-result-text {
color: var(--text-secondary, #9ca3af);
}
.update-result-error .update-result-text strong {
color: var(--accent-red, #ef4444);
}
.update-result-warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.update-result-warning .update-result-icon {
color: var(--accent-orange, #f59e0b);
}
.update-result-warning .update-result-text {
color: var(--text-secondary, #9ca3af);
}
.update-result-warning .update-result-text strong {
color: var(--accent-orange, #f59e0b);
}
.update-result-info {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.3);
}
.update-result-info .update-result-icon {
color: var(--accent-cyan, #4a9eff);
}
.update-result-info .update-result-text {
color: var(--text-secondary, #9ca3af);
}
/* Footer */
.update-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid var(--border-color, #1f2937);
background: var(--bg-secondary, #0f1218);
border-radius: 0 0 12px 12px;
}
.update-modal-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim, #4b5563);
text-decoration: none;
transition: color 0.15s;
}
.update-modal-link:hover {
color: var(--accent-cyan, #4a9eff);
}
.update-modal-actions {
display: flex;
gap: 10px;
}
.update-modal-btn {
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.update-modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.update-modal-btn-primary {
background: var(--accent-green, #22c55e);
color: #000;
}
.update-modal-btn-primary:hover:not(:disabled) {
background: #34d673;
}
.update-modal-btn-secondary {
background: var(--bg-tertiary, #151a23);
color: var(--text-secondary, #9ca3af);
border: 1px solid var(--border-color, #1f2937);
}
.update-modal-btn-secondary:hover:not(:disabled) {
background: var(--bg-elevated, #1a202c);
border-color: var(--border-light, #374151);
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 480px) {
#toastContainer {
bottom: 10px;
right: 10px;
left: 10px;
}
.update-toast {
max-width: none;
}
.update-modal {
width: 95%;
max-height: 90vh;
}
.update-version-info {
flex-direction: column;
gap: 10px;
}
.update-version-arrow {
transform: rotate(90deg);
}
.update-modal-footer {
flex-direction: column;
gap: 12px;
}
.update-modal-link {
order: 2;
}
.update-modal-actions {
width: 100%;
}
.update-modal-btn {
flex: 1;
}
}
+420
View File
@@ -0,0 +1,420 @@
/**
* INTERCEPT Base Styles
* Reset, typography, and foundational element styles
* Requires: variables.css to be imported first
*/
/* ============================================
CSS RESET
============================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
background: var(--bg-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ============================================
TYPOGRAPHY
============================================ */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
color: var(--text-primary);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h6 { font-size: var(--text-base); }
p {
margin-bottom: var(--space-4);
}
a {
color: var(--accent-cyan);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--accent-cyan-hover);
}
a:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
strong, b {
font-weight: var(--font-semibold);
}
small {
font-size: var(--text-sm);
}
code, kbd, pre, samp {
font-family: var(--font-mono);
font-size: 0.9em;
}
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
pre {
background: var(--bg-tertiary);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
}
pre code {
background: none;
padding: 0;
}
/* ============================================
FORM ELEMENTS
============================================ */
button,
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
input,
select,
textarea {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
}
input::placeholder,
textarea::placeholder {
color: var(--text-dim);
}
select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
input[type="checkbox"],
input[type="radio"] {
width: 16px;
height: 16px;
padding: 0;
cursor: pointer;
accent-color: var(--accent-cyan);
}
label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
/* ============================================
TABLES
============================================ */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
th,
td {
padding: var(--space-2) var(--space-3);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: var(--font-semibold);
color: var(--text-secondary);
background: var(--bg-secondary);
text-transform: uppercase;
font-size: var(--text-xs);
letter-spacing: 0.05em;
}
tr:hover td {
background: var(--bg-tertiary);
}
/* ============================================
LISTS
============================================ */
ul, ol {
padding-left: var(--space-6);
margin-bottom: var(--space-4);
}
li {
margin-bottom: var(--space-1);
}
/* ============================================
UTILITY CLASSES
============================================ */
/* Text colors */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-muted { color: var(--text-muted); }
.text-cyan { color: var(--accent-cyan); }
.text-green { color: var(--accent-green); }
.text-red { color: var(--accent-red); }
.text-orange { color: var(--accent-orange); }
.text-amber { color: var(--accent-amber); }
/* Font utilities */
.font-mono { font-family: var(--font-mono); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
/* Text sizes */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
/* Display */
.hidden { display: none !important; }
.block { display: block; }
.inline-block { display: inline-block; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.grid { display: grid; }
/* Flexbox */
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
/* Spacing */
.m-0 { margin: 0; }
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
/* Borders */
.rounded { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.border { border: 1px solid var(--border-color); }
/* Truncate text */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ============================================
SCROLLBAR STYLING
============================================ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-dim);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-light) var(--bg-secondary);
}
/* ============================================
SELECTION
============================================ */
::selection {
background: var(--accent-cyan-dim);
color: var(--text-primary);
}
/* ============================================
UX POLISH - TRANSITIONS & INTERACTIONS
============================================ */
/* Smooth page transitions */
html {
scroll-behavior: smooth;
}
/* Better focus ring for all interactive elements */
:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
/* Remove focus ring for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
/* Active state feedback */
button:active:not(:disabled),
a:active,
[role="button"]:active {
transform: scale(0.98);
}
/* Smooth transitions for all interactive elements */
button,
a,
input,
select,
textarea,
[role="button"] {
transition:
color var(--transition-fast),
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast),
opacity var(--transition-fast);
}
/* Subtle hover lift effect for cards and panels */
.card:hover,
.panel:hover {
box-shadow: var(--shadow-md);
}
/* Link underline on hover */
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Skip link for accessibility */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent-cyan);
color: var(--bg-primary);
padding: var(--space-2) var(--space-4);
z-index: 9999;
transition: top var(--transition-fast);
}
.skip-link:focus {
top: 0;
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--border-color: #4b5563;
--text-secondary: #d1d5db;
}
}
+723
View File
@@ -0,0 +1,723 @@
/**
* INTERCEPT UI Components
* Reusable component styles for buttons, cards, badges, etc.
* Requires: variables.css and base.css
*/
/* ============================================
BUTTONS
============================================ */
/* Base button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--font-medium);
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
text-decoration: none;
}
.btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Button variants */
.btn-primary {
background: var(--accent-cyan);
color: var(--text-inverse);
border-color: var(--accent-cyan);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-cyan-hover);
border-color: var(--accent-cyan-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-elevated);
border-color: var(--border-light);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border-color: transparent;
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-danger {
background: var(--accent-red);
color: white;
border-color: var(--accent-red);
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
border-color: #dc2626;
}
.btn-success {
background: var(--accent-green);
color: white;
border-color: var(--accent-green);
}
.btn-success:hover:not(:disabled) {
background: #16a34a;
border-color: #16a34a;
}
/* Button sizes */
.btn-sm {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
}
.btn-lg {
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
}
/* Icon button */
.btn-icon {
padding: var(--space-2);
width: 36px;
height: 36px;
}
.btn-icon.btn-sm {
width: 28px;
height: 28px;
padding: var(--space-1);
}
/* ============================================
CARDS / PANELS
============================================ */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.card-header-title {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.card-body {
padding: var(--space-4);
}
.card-footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
/* Panel variant (used in dashboards) */
.panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color);
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
.panel-indicator {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--status-offline);
}
.panel-indicator.active {
background: var(--status-online);
box-shadow: 0 0 8px var(--status-online);
}
.panel-content {
padding: var(--space-3);
}
/* ============================================
BADGES
============================================ */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
font-size: var(--text-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.badge-primary {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
.badge-success {
background: var(--accent-green-dim);
color: var(--accent-green);
}
.badge-warning {
background: var(--accent-orange-dim);
color: var(--accent-orange);
}
.badge-danger {
background: var(--accent-red-dim);
color: var(--accent-red);
}
/* ============================================
STATUS INDICATORS
============================================ */
.status-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--status-offline);
flex-shrink: 0;
}
.status-dot.online,
.status-dot.active {
background: var(--status-online);
box-shadow: 0 0 6px var(--status-online);
}
.status-dot.warning {
background: var(--status-warning);
box-shadow: 0 0 6px var(--status-warning);
}
.status-dot.error,
.status-dot.offline {
background: var(--status-error);
}
.status-dot.inactive {
background: var(--status-offline);
}
/* Pulse animation for active status */
.status-dot.pulse {
animation: statusPulse 2s ease-in-out infinite;
}
@keyframes statusPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ============================================
EMPTY STATE
============================================ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-4);
text-align: center;
color: var(--text-muted);
}
.empty-state-icon {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
opacity: 0.5;
}
.empty-state-title {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.empty-state-description {
font-size: var(--text-sm);
color: var(--text-dim);
max-width: 300px;
}
.empty-state-action {
margin-top: var(--space-4);
}
/* ============================================
LOADING STATES
============================================ */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-cyan);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
.spinner-sm {
width: 14px;
height: 14px;
border-width: 2px;
}
.spinner-lg {
width: 32px;
height: 32px;
border-width: 3px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Loading overlay */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-overlay);
z-index: var(--z-modal);
}
/* Skeleton loader */
.skeleton {
background: linear-gradient(
90deg,
var(--bg-tertiary) 25%,
var(--bg-elevated) 50%,
var(--bg-tertiary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ============================================
STATS STRIP
============================================ */
.stats-strip {
display: flex;
align-items: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 0 var(--space-4);
height: var(--stats-strip-height);
overflow-x: auto;
gap: var(--space-1);
}
.strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 var(--space-3);
min-width: fit-content;
}
.strip-value {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--accent-cyan);
line-height: 1;
}
.strip-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1;
margin-top: 2px;
}
.strip-divider {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 var(--space-2);
}
/* ============================================
FORM GROUPS
============================================ */
.form-group {
margin-bottom: var(--space-4);
}
.form-group label {
display: block;
margin-bottom: var(--space-1);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
.form-help {
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--text-dim);
}
.form-error {
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--accent-red);
}
/* Inline checkbox/radio */
.form-check {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.form-check input {
width: auto;
}
.form-check label {
margin-bottom: 0;
cursor: pointer;
}
/* ============================================
ALERTS / TOASTS
============================================ */
.alert {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid;
font-size: var(--text-sm);
}
.alert-info {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.alert-success {
background: var(--accent-green-dim);
border-color: var(--accent-green);
color: var(--accent-green);
}
.alert-warning {
background: var(--accent-orange-dim);
border-color: var(--accent-orange);
color: var(--accent-orange);
}
.alert-danger {
background: var(--accent-red-dim);
border-color: var(--accent-red);
color: var(--accent-red);
}
/* ============================================
TOOLTIPS
============================================ */
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: var(--space-1) var(--space-2);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: var(--text-xs);
border-radius: var(--radius-sm);
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
z-index: var(--z-tooltip);
pointer-events: none;
margin-bottom: var(--space-1);
box-shadow: var(--shadow-md);
}
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
}
/* ============================================
ICONS
============================================ */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.icon svg {
width: 100%;
height: 100%;
}
.icon--sm {
width: 16px;
height: 16px;
}
.icon--lg {
width: 24px;
height: 24px;
}
/* ============================================
SECTION HEADERS
============================================ */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-color);
}
.section-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
/* ============================================
DIVIDERS
============================================ */
.divider {
height: 1px;
background: var(--border-color);
margin: var(--space-4) 0;
}
.divider-vertical {
width: 1px;
height: 100%;
background: var(--border-color);
margin: 0 var(--space-3);
}
/* ============================================
UX POLISH - ENHANCED INTERACTIONS
============================================ */
/* Button hover lift */
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
/* Card/Panel hover effects */
.card,
.panel {
transition:
box-shadow var(--transition-base),
border-color var(--transition-base),
transform var(--transition-base);
}
.card:hover,
.panel:hover {
border-color: var(--border-light);
}
/* Stats strip value highlight on hover */
.strip-stat {
transition: background-color var(--transition-fast);
border-radius: var(--radius-sm);
cursor: default;
}
.strip-stat:hover {
background: var(--bg-tertiary);
}
/* Status dot pulse animation */
.status-dot.online,
.status-dot.active {
animation: statusGlow 2s ease-in-out infinite;
}
@keyframes statusGlow {
0%, 100% {
box-shadow: 0 0 6px var(--status-online);
}
50% {
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
}
}
/* Badge hover effect */
.badge {
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.badge:hover {
transform: scale(1.05);
}
/* Alert entrance animation */
.alert {
animation: alertSlideIn 0.3s ease-out;
}
@keyframes alertSlideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Loading spinner smooth appearance */
.spinner {
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Input focus glow */
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
}
/* Nav item active indicator */
.mode-nav-btn.active::after,
.mobile-nav-btn.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 2px;
background: currentColor;
border-radius: var(--radius-full);
}
/* Smooth tooltip appearance */
[data-tooltip]::after {
transition:
opacity var(--transition-fast),
visibility var(--transition-fast),
transform var(--transition-fast);
transform: translateX(-50%) translateY(-4px);
}
[data-tooltip]:hover::after {
transform: translateX(-50%) translateY(0);
}
/* Disabled state with better visual feedback */
:disabled,
.disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(30%);
}
+950
View File
@@ -0,0 +1,950 @@
/**
* INTERCEPT Layout Styles
* Global layout structure: header, navigation, sidebar, main content
* Requires: variables.css, base.css, components.css
*/
/* ============================================
APP SHELL
============================================ */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
}
/* ============================================
GLOBAL HEADER
============================================ */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height);
padding: 0 var(--space-4);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.app-header-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.app-header-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
/* Logo */
.app-logo {
display: flex;
align-items: center;
gap: var(--space-3);
text-decoration: none;
color: var(--text-primary);
}
.app-logo-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.app-logo-text {
display: flex;
flex-direction: column;
}
.app-logo-title {
font-size: var(--text-lg);
font-weight: var(--font-bold);
line-height: 1.2;
color: var(--text-primary);
}
.app-logo-tagline {
font-size: var(--text-xs);
color: var(--text-dim);
}
/* Page title in header */
.app-header-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.app-header-subtitle {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-left: var(--space-2);
}
/* Header utilities */
.header-utilities {
display: flex;
align-items: center;
gap: var(--space-2);
}
.header-clock {
display: flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.header-clock-label {
font-size: var(--text-xs);
color: var(--text-dim);
}
/* ============================================
GLOBAL NAVIGATION
============================================ */
.app-nav {
display: flex;
align-items: center;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
padding: 0 var(--space-4);
height: var(--nav-height);
gap: var(--space-1);
overflow-x: auto;
}
.app-nav::-webkit-scrollbar {
height: 0;
}
/* Nav groups */
.nav-group {
display: flex;
align-items: center;
position: relative;
}
/* Dropdown trigger */
.nav-dropdown-trigger {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.nav-dropdown-trigger:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.nav-dropdown-trigger.active {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
.nav-dropdown-arrow {
width: 12px;
height: 12px;
transition: transform var(--transition-fast);
}
.nav-group.open .nav-dropdown-arrow {
transform: rotate(180deg);
}
/* Dropdown menu */
.nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: var(--space-1);
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all var(--transition-fast);
z-index: var(--z-dropdown);
}
.nav-group.open .nav-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(4px);
}
/* Nav items */
.nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
text-decoration: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
.nav-item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Nav divider */
.nav-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 var(--space-2);
}
/* Nav utilities (right side) */
.nav-utilities {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
padding-left: var(--space-4);
}
.nav-tool-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
}
.nav-tool-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
/* ============================================
MOBILE NAVIGATION
============================================ */
.mobile-nav {
display: none;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
padding: var(--space-2) var(--space-3);
overflow-x: auto;
gap: var(--space-2);
}
.mobile-nav::-webkit-scrollbar {
height: 0;
}
.mobile-nav-btn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
white-space: nowrap;
text-decoration: none;
transition: all var(--transition-fast);
}
.mobile-nav-btn:hover,
.mobile-nav-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Hamburger button */
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 32px;
height: 32px;
padding: 6px;
background: transparent;
border: none;
cursor: pointer;
}
.hamburger-btn span {
display: block;
width: 100%;
height: 2px;
background: var(--text-secondary);
border-radius: 1px;
transition: all var(--transition-fast);
}
.hamburger-btn.open span:nth-child(1) {
transform: rotate(45deg) translate(4px, 4px);
}
.hamburger-btn.open span:nth-child(2) {
opacity: 0;
}
.hamburger-btn.open span:nth-child(3) {
transform: rotate(-45deg) translate(4px, -4px);
}
/* ============================================
CONTENT LAYOUTS
============================================ */
/* Main content with optional sidebar */
.content-wrapper {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.app-sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-section {
padding: var(--space-4);
border-bottom: 1px solid var(--border-color);
}
.sidebar-section:last-child {
border-bottom: none;
}
.sidebar-section-title {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
/* Main content area */
.app-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.app-content-full {
flex: 1;
overflow: hidden;
position: relative;
}
/* ============================================
DASHBOARD LAYOUTS
============================================ */
/* Full-screen dashboard (maps, etc.) */
.dashboard-layout {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.dashboard-header-logo {
font-size: var(--text-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
}
.dashboard-header-logo span {
font-size: var(--text-sm);
font-weight: var(--font-normal);
color: var(--text-dim);
margin-left: var(--space-2);
}
.dashboard-main {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.dashboard-map {
flex: 1;
position: relative;
}
.dashboard-sidebar {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
}
/* ============================================
PAGE LAYOUTS
============================================ */
.page-container {
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--space-6);
}
.page-header {
margin-bottom: var(--space-6);
}
.page-title {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.page-description {
font-size: var(--text-base);
color: var(--text-secondary);
}
/* ============================================
RESPONSIVE BREAKPOINTS
============================================ */
@media (max-width: 1024px) {
.app-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: var(--z-fixed);
transform: translateX(-100%);
transition: transform var(--transition-base);
}
.app-sidebar.open {
transform: translateX(0);
}
.dashboard-sidebar {
width: 280px;
}
}
@media (max-width: 768px) {
.app-nav {
display: none;
}
.mobile-nav {
display: flex;
}
.hamburger-btn {
display: flex;
}
.app-header {
padding: 0 var(--space-3);
}
.app-logo-text {
display: none;
}
.header-utilities {
gap: var(--space-1);
}
.page-container {
padding: var(--space-4);
}
.dashboard-sidebar {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: var(--z-fixed);
transform: translateX(100%);
transition: transform var(--transition-base);
}
.dashboard-sidebar.open {
transform: translateX(0);
}
}
/* ============================================
OVERLAY (for mobile drawers)
============================================ */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: calc(var(--z-fixed) - 1);
opacity: 0;
visibility: hidden;
transition: all var(--transition-base);
}
.drawer-overlay.visible {
opacity: 1;
visibility: visible;
}
/* ============================================
BACK LINK
============================================ */
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.back-link:hover {
color: var(--accent-cyan);
}
/* ============================================
MODE NAVIGATION (from index.css)
Used by nav.html partial across all pages
============================================ */
/* Mode Navigation Bar */
.mode-nav {
display: none;
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
border-bottom: 1px solid var(--border-color);
padding: 0 20px;
position: relative;
z-index: 100;
}
@media (min-width: 1024px) {
.mode-nav {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
}
}
.mode-nav-group {
display: flex;
align-items: center;
gap: 4px;
}
.mode-nav-label {
font-size: 9px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
margin-right: 8px;
font-weight: 500;
}
.mode-nav-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 12px;
}
.mode-nav-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-nav-btn .nav-icon {
font-size: 14px;
}
.mode-nav-btn .nav-icon svg {
width: 14px;
height: 14px;
}
.mode-nav-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mode-nav-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border-color);
}
.mode-nav-btn.active {
background: var(--accent-cyan);
color: var(--bg-primary);
border-color: var(--accent-cyan);
}
.mode-nav-btn.active .nav-icon {
filter: brightness(0);
}
.mode-nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.nav-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-elevated);
border: 1px solid var(--accent-cyan);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-action-btn .nav-icon {
font-size: 12px;
}
.nav-action-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-action-btn:hover {
background: var(--accent-cyan);
color: var(--bg-primary);
}
/* Dropdown Navigation */
.mode-nav-dropdown {
position: relative;
}
.mode-nav-dropdown-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-nav-dropdown-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border-color);
}
.mode-nav-dropdown-btn .nav-icon {
font-size: 14px;
}
.mode-nav-dropdown-btn .nav-icon svg {
width: 14px;
height: 14px;
}
.mode-nav-dropdown-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mode-nav-dropdown-btn .dropdown-arrow {
font-size: 8px;
margin-left: 4px;
transition: transform 0.2s ease;
}
.mode-nav-dropdown-btn .dropdown-arrow svg {
width: 10px;
height: 10px;
}
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border-color);
}
.mode-nav-dropdown.open .dropdown-arrow {
transform: rotate(180deg);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: var(--accent-cyan);
color: var(--bg-primary);
border-color: var(--accent-cyan);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
filter: brightness(0);
}
.mode-nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.15s ease;
z-index: 1000;
padding: 6px;
}
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.mode-nav-dropdown-menu .mode-nav-btn {
width: 100%;
justify-content: flex-start;
padding: 10px 12px;
border-radius: 4px;
margin: 0;
}
.mode-nav-dropdown-menu .mode-nav-btn:hover {
background: var(--bg-elevated);
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
background: var(--accent-cyan);
color: var(--bg-primary);
}
/* Nav Bar Utilities (clock, theme, tools) */
.nav-utilities {
display: none;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
@media (min-width: 1024px) {
.nav-utilities {
display: flex;
}
}
.nav-clock {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
}
.nav-clock .utc-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.nav-clock .utc-time {
color: var(--accent-cyan);
font-weight: 600;
}
.nav-divider {
width: 1px;
height: 20px;
background: var(--border-color);
}
.nav-tools {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.nav-tool-btn {
width: 28px;
height: 28px;
min-width: 28px;
border-radius: 4px;
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.nav-tool-btn:hover {
background: var(--bg-elevated);
border-color: var(--border-color);
color: var(--accent-cyan);
}
.nav-tool-btn svg {
width: 14px;
height: 14px;
}
.nav-tool-btn .icon svg {
width: 14px;
height: 14px;
}
/* Theme toggle icon states in nav bar */
.nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon {
position: absolute;
transition: opacity 0.2s, transform 0.2s;
font-size: 14px;
}
.nav-tool-btn .icon-sun {
opacity: 0;
transform: rotate(-90deg);
}
.nav-tool-btn .icon-moon {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-sun {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-moon {
opacity: 0;
transform: rotate(90deg);
}
/* Effects toggle icon states */
.nav-tool-btn .icon-effects-off {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-on {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-off {
display: flex;
}
+198
View File
@@ -0,0 +1,198 @@
/**
* INTERCEPT Design Tokens
* Single source of truth for colors, spacing, typography, and effects
* Import this file FIRST in any stylesheet that needs design tokens
*/
:root {
/* ============================================
COLOR PALETTE - Dark Theme (Default)
============================================ */
/* Backgrounds - layered depth system */
--bg-primary: #0a0c10;
--bg-secondary: #0f1218;
--bg-tertiary: #151a23;
--bg-card: #121620;
--bg-elevated: #1a202c;
--bg-overlay: rgba(0, 0, 0, 0.7);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
/* Accent colors */
--accent-cyan: #4a9eff;
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
--accent-cyan-hover: #6bb3ff;
--accent-green: #22c55e;
--accent-green-dim: rgba(34, 197, 94, 0.15);
--accent-red: #ef4444;
--accent-red-dim: rgba(239, 68, 68, 0.15);
--accent-orange: #f59e0b;
--accent-orange-dim: rgba(245, 158, 11, 0.15);
--accent-amber: #d4a853;
--accent-amber-dim: rgba(212, 168, 83, 0.15);
--accent-yellow: #eab308;
--accent-purple: #a855f7;
/* Text hierarchy */
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--text-muted: #374151;
--text-inverse: #0a0c10;
/* Borders */
--border-color: #1f2937;
--border-light: #374151;
--border-glow: rgba(74, 158, 255, 0.2);
--border-focus: var(--accent-cyan);
/* Status colors */
--status-online: #22c55e;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-offline: #6b7280;
--status-info: #3b82f6;
/* ============================================
SPACING SCALE
============================================ */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ============================================
TYPOGRAPHY
============================================ */
--font-sans: 'Space Mono', ui-monospace, 'SF Mono', monospace;
--font-mono: 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', monospace;
/* Font sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
/* Font weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ============================================
BORDERS & RADIUS
============================================ */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* ============================================
SHADOWS
============================================ */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
/* ============================================
TRANSITIONS
============================================ */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* ============================================
Z-INDEX SCALE
============================================ */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-toast: 600;
--z-tooltip: 700;
/* ============================================
LAYOUT
============================================ */
--header-height: 60px;
--nav-height: 44px;
--sidebar-width: 280px;
--stats-strip-height: 36px;
--content-max-width: 1400px;
}
/* ============================================
LIGHT THEME OVERRIDES
============================================ */
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--bg-tertiary: #e2e8f0;
--bg-card: #ffffff;
--bg-elevated: #f8fafc;
--bg-overlay: rgba(255, 255, 255, 0.9);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--accent-cyan: #2563eb;
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
--accent-cyan-hover: #1d4ed8;
--accent-green: #16a34a;
--accent-green-dim: rgba(22, 163, 74, 0.1);
--accent-red: #dc2626;
--accent-red-dim: rgba(220, 38, 38, 0.1);
--accent-orange: #d97706;
--accent-orange-dim: rgba(217, 119, 6, 0.1);
--accent-amber: #b45309;
--accent-amber-dim: rgba(180, 83, 9, 0.1);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-dim: #94a3b8;
--text-muted: #cbd5e1;
--text-inverse: #f8fafc;
--border-color: #e2e8f0;
--border-light: #cbd5e1;
--border-glow: rgba(37, 99, 235, 0.15);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
}
}
+18 -67
View File
@@ -1,67 +1,18 @@
/* Local font declarations for offline mode */ /* Local font declarations for offline mode */
/* Inter - Primary UI font */ /* Space Mono - Console font */
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Space Mono';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2'); src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
} }
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Space Mono';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 700;
font-display: swap; font-display: swap;
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2'); src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
} }
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono - Monospace/code font */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
+439
View File
@@ -0,0 +1,439 @@
/* ============================================
Global Navigation Styles
Shared across all pages using nav.html
============================================ */
/* Icon base (kept lightweight for nav usage) */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.icon svg {
width: 100%;
height: 100%;
}
.icon--sm {
width: 14px;
height: 14px;
}
/* Mode Navigation Bar */
.mode-nav {
display: none;
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
border-bottom: 1px solid var(--border-color, #202833);
padding: 0 20px;
position: relative;
z-index: 100;
backdrop-filter: blur(10px);
}
@media (min-width: 1024px) {
.mode-nav {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
}
}
.mode-nav-label {
font-size: 9px;
color: var(--text-secondary, #b7c1cf);
text-transform: uppercase;
letter-spacing: 1px;
margin-right: 8px;
font-weight: 500;
font-family: var(--font-mono);
}
.mode-nav-divider {
width: 1px;
height: 24px;
background: var(--border-color, #202833);
margin: 0 12px;
}
.mode-nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #b7c1cf);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.mode-nav-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.mode-nav-btn:hover {
background: rgba(27, 36, 51, 0.8);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-btn.active {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--accent-cyan, #4d7dbf);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
.mode-nav-btn.active .nav-icon {
color: var(--accent-cyan, #4d7dbf);
}
.mode-nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.nav-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: rgba(24, 31, 44, 0.85);
border: 1px solid var(--border-light, #2b3645);
border-radius: 6px;
color: var(--text-primary, #e7ebf2);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-action-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.nav-action-btn:hover {
background: rgba(27, 36, 51, 0.95);
color: var(--text-primary, #e7ebf2);
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
border-color: var(--accent-cyan, #4d7dbf);
}
/* Dropdown Navigation */
.mode-nav-dropdown {
position: relative;
}
.mode-nav-dropdown-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #b7c1cf);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-nav-dropdown-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.mode-nav-dropdown-btn .dropdown-arrow {
width: 12px;
height: 12px;
margin-left: 4px;
transition: transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mode-nav-dropdown-btn .dropdown-arrow svg {
width: 100%;
height: 100%;
}
.mode-nav-dropdown-btn:hover {
background: rgba(27, 36, 51, 0.8);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-dropdown.open .dropdown-arrow {
transform: rotate(180deg);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--accent-cyan, #4d7dbf);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
color: var(--accent-cyan, #4d7dbf);
}
.mode-nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
background: rgba(16, 22, 32, 0.98);
border: 1px solid var(--border-color, #202833);
border-radius: 8px;
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.15s ease;
z-index: 1000;
padding: 6px;
}
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.mode-nav-dropdown-menu .mode-nav-btn {
width: 100%;
justify-content: flex-start;
padding: 10px 12px;
border-radius: 6px;
}
.mode-nav-dropdown-menu .mode-nav-btn:hover {
background: rgba(27, 36, 51, 0.85);
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
background: rgba(27, 36, 51, 0.95);
color: var(--text-primary, #e7ebf2);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
/* Nav Bar Utilities */
.nav-utilities {
display: none;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
@media (min-width: 1024px) {
.nav-utilities {
display: flex;
}
}
.nav-clock {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
}
.nav-clock .utc-label {
font-size: 9px;
color: var(--text-dim, #8a97a8);
text-transform: uppercase;
letter-spacing: 1px;
}
.nav-clock .utc-time {
color: var(--accent-cyan, #4d7dbf);
font-weight: 600;
}
.nav-divider {
width: 1px;
height: 20px;
background: var(--border-color, #202833);
}
.nav-tools {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.nav-tool-btn {
width: 28px;
height: 28px;
min-width: 28px;
border-radius: 6px;
background: rgba(20, 33, 53, 0.6);
border: 1px solid rgba(77, 125, 191, 0.12);
color: var(--text-secondary, #b7c1cf);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-tool-btn:hover {
background: rgba(27, 36, 51, 0.9);
border-color: var(--accent-cyan, #4d7dbf);
color: var(--accent-cyan, #4d7dbf);
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
/* Position relative needed for absolute positioned icon children */
.nav-tool-btn {
position: relative;
}
.mode-nav-btn:focus-visible,
.mode-nav-dropdown-btn:focus-visible,
.nav-action-btn:focus-visible,
.nav-tool-btn:focus-visible {
outline: 2px solid var(--accent-cyan, #4d7dbf);
outline-offset: 2px;
}
/* Nav tool button SVG sizing and styling */
.nav-tool-btn svg {
width: 14px;
height: 14px;
stroke: currentColor;
}
.nav-tool-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-tool-btn .icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
}
/* Theme toggle icon states */
.nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon {
position: absolute;
transition: opacity 0.2s, transform 0.2s;
}
.nav-tool-btn .icon-sun {
opacity: 0;
transform: rotate(-90deg);
}
.nav-tool-btn .icon-moon {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-sun {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-moon {
opacity: 0;
transform: rotate(90deg);
}
/* Effects/animations toggle icon states */
.nav-tool-btn .icon-effects-off {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-on {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-off {
display: flex;
}
/* Main Dashboard Button in Nav */
a.nav-dashboard-btn,
a.nav-dashboard-btn:link,
a.nav-dashboard-btn:visited {
display: inline-flex !important;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
background: rgba(20, 33, 53, 0.6) !important;
border: 1px solid rgba(77, 125, 191, 0.12) !important;
color: #b7c1cf !important;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-decoration: none !important;
}
a.nav-dashboard-btn:hover {
background: rgba(27, 36, 51, 0.9) !important;
border-color: #4d7dbf !important;
color: #4d7dbf !important;
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
.nav-dashboard-btn .icon {
width: 14px;
height: 14px;
}
.nav-dashboard-btn .icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
}
.nav-dashboard-btn .nav-label {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
letter-spacing: 0.5px;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Help Modal Styles
* Shared across all pages that include the help modal partial
*/
.help-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
padding: 40px 20px;
}
.help-modal.active {
display: block;
}
.help-content {
max-width: 800px;
margin: 0 auto;
background: var(--bg-card, var(--bg-secondary, #0f1218));
border: 1px solid var(--border-color, #1f2937);
border-radius: 8px;
padding: 30px;
position: relative;
}
.help-content h2 {
color: var(--accent-cyan, #4a9eff);
margin-bottom: 20px;
font-size: 24px;
letter-spacing: 2px;
}
.help-content h3 {
color: var(--text-primary, #e8eaed);
margin: 25px 0 15px 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color, #1f2937);
padding-bottom: 8px;
}
.help-close {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 24px;
cursor: pointer;
transition: color 0.2s;
}
.help-close:hover {
color: var(--accent-red, #ef4444);
}
.help-modal .icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin: 15px 0;
}
.help-modal .icon-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-primary, #0a0c10);
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
font-size: 12px;
}
.help-modal .icon-item .icon {
font-size: 18px;
width: 30px;
text-align: center;
}
.help-modal .icon-item .desc {
color: var(--text-secondary, #9ca3af);
}
.help-modal .tip-list {
list-style: none;
padding: 0;
margin: 15px 0;
}
.help-modal .tip-list li {
padding: 8px 0;
padding-left: 20px;
position: relative;
color: var(--text-secondary, #9ca3af);
font-size: 13px;
border-bottom: 1px solid var(--border-color, #1f2937);
}
.help-modal .tip-list li:last-child {
border-bottom: none;
}
.help-modal .tip-list li::before {
content: '\203A';
position: absolute;
left: 0;
color: var(--accent-cyan, #4a9eff);
font-weight: bold;
}
.help-tabs {
display: flex;
gap: 0;
margin-bottom: 20px;
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
overflow: hidden;
}
.help-tab {
flex: 1;
padding: 10px;
background: var(--bg-primary, #0a0c10);
border: none;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.15s ease;
position: relative;
}
.help-tab:not(:last-child) {
border-right: 1px solid var(--border-color, #1f2937);
}
.help-tab:hover {
background: var(--bg-tertiary, #151a23);
color: var(--text-primary, #e8eaed);
}
.help-tab.active {
background: var(--bg-tertiary, #151a23);
color: var(--accent-cyan, #4a9eff);
}
.help-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #4a9eff);
}
.help-section {
display: none;
}
.help-section.active {
display: block;
}
/* Ensure code tags are styled */
.help-modal code {
background: var(--bg-tertiary, #151a23);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--accent-cyan, #4a9eff);
}
+222 -88
View File
@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -7,6 +5,8 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
/* Tactical dark palette */ /* Tactical dark palette */
--bg-primary: #0a0c10; --bg-primary: #0a0c10;
--bg-secondary: #0f1218; --bg-secondary: #0f1218;
@@ -73,7 +73,7 @@
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--font-sans);
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -259,7 +259,7 @@ body {
} }
.welcome-title { .welcome-title {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
@@ -269,7 +269,7 @@ body {
} }
.welcome-tagline { .welcome-tagline {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 0.9rem; font-size: 0.9rem;
color: var(--accent-cyan); color: var(--accent-cyan);
letter-spacing: 0.15em; letter-spacing: 0.15em;
@@ -278,7 +278,7 @@ body {
.welcome-version { .welcome-version {
display: inline-block; display: inline-block;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 0.65rem; font-size: 0.65rem;
color: var(--bg-primary); color: var(--bg-primary);
background: var(--accent-cyan); background: var(--accent-cyan);
@@ -297,7 +297,7 @@ body {
} }
.welcome-content h2 { .welcome-content h2 {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
@@ -313,7 +313,7 @@ body {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
max-height: 320px; max-height: calc(100vh - 300px);
overflow-y: auto; overflow-y: auto;
} }
@@ -333,14 +333,14 @@ body {
} }
.changelog-version { .changelog-version {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 0.8rem; font-size: 0.8rem;
color: var(--accent-cyan); color: var(--accent-cyan);
font-weight: 600; font-weight: 600;
} }
.changelog-date { .changelog-date {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-dim); color: var(--text-dim);
} }
@@ -352,7 +352,7 @@ body {
} }
.changelog-list li { .changelog-list li {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 6px; margin-bottom: 6px;
@@ -364,7 +364,7 @@ body {
position: absolute; position: absolute;
left: -15px; left: -15px;
color: var(--accent-green); color: var(--accent-green);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
/* Mode Selection Grid */ /* Mode Selection Grid */
@@ -435,7 +435,7 @@ body {
} }
.mode-card .mode-name { .mode-card .mode-name {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
@@ -444,7 +444,7 @@ body {
} }
.mode-card .mode-desc { .mode-card .mode-desc {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 0.65rem; font-size: 0.65rem;
color: var(--text-dim); color: var(--text-dim);
margin-top: 4px; margin-top: 4px;
@@ -463,7 +463,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
@@ -517,7 +517,7 @@ body {
} }
.welcome-footer p { .welcome-footer p {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-dim); color: var(--text-dim);
letter-spacing: 0.1em; letter-spacing: 0.1em;
@@ -731,7 +731,7 @@ header h1 {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 4px; border-radius: 4px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -778,7 +778,7 @@ header h1 {
border: 1px solid var(--accent-cyan); border: 1px solid var(--accent-cyan);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
@@ -814,7 +814,7 @@ header h1 {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 4px; border-radius: 4px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -922,7 +922,7 @@ header h1 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
@@ -978,6 +978,18 @@ header h1 {
color: var(--accent-cyan); color: var(--accent-cyan);
} }
/* Donate button - warm amber accent */
.nav-tool-btn--donate {
text-decoration: none;
color: var(--accent-amber);
}
.nav-tool-btn--donate:hover {
color: var(--accent-orange);
border-color: var(--accent-amber);
background: rgba(212, 168, 83, 0.1);
}
/* Theme toggle icon states in nav bar */ /* Theme toggle icon states in nav bar */
.nav-tool-btn .icon-sun, .nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon { .nav-tool-btn .icon-moon {
@@ -1018,7 +1030,7 @@ header h1 {
.version-badge { .version-badge {
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 500; font-weight: 500;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--text-secondary); color: var(--text-secondary);
background: var(--bg-tertiary); background: var(--bg-tertiary);
@@ -1077,7 +1089,7 @@ header h1 .tagline {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@@ -1566,7 +1578,7 @@ header h1 .tagline {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
color: var(--text-primary); color: var(--text-primary);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@@ -1578,6 +1590,11 @@ header h1 .tagline {
box-shadow: 0 0 0 2px var(--accent-cyan-dim); box-shadow: 0 0 0 2px var(--accent-cyan-dim);
} }
/* Ensure device select is wide enough for device name + serial */
#deviceSelect {
min-width: 280px;
}
.checkbox-group { .checkbox-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1620,7 +1637,7 @@ header h1 .tagline {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -1640,7 +1657,7 @@ header h1 .tagline {
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: #fff;
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -1677,7 +1694,7 @@ header h1 .tagline {
background: var(--accent-red); background: var(--accent-red);
border: none; border: none;
color: #fff; color: #fff;
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -1740,7 +1757,7 @@ header h1 .tagline {
gap: 8px; gap: 8px;
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.stats>div { .stats>div {
@@ -1766,11 +1783,10 @@ header h1 .tagline {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
overflow-y: auto; overflow-y: auto;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
background: var(--bg-primary); background: var(--bg-primary);
min-height: 400px; min-height: 0; /* Allow shrinking in flex context */
max-height: 600px;
} }
.output-content::-webkit-scrollbar { .output-content::-webkit-scrollbar {
@@ -1839,7 +1855,7 @@ header h1 .tagline {
.message .address { .message .address {
color: var(--accent-green); color: var(--accent-green);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -1852,7 +1868,7 @@ header h1 .tagline {
} }
.message .content.numeric { .message .content.numeric {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 15px; font-size: 15px;
letter-spacing: 2px; letter-spacing: 2px;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -2073,7 +2089,7 @@ header h1 .tagline {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
transition: all 0.2s ease; transition: all 0.2s ease;
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
} }
.control-btn:hover { .control-btn:hover {
@@ -2341,13 +2357,10 @@ header h1 .tagline {
/* Dark theme for Leaflet */ /* Dark theme for Leaflet */
.leaflet-container { .leaflet-container {
background: #0a0a0a; background: #0a0a0a;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.leaflet-tile-pane, /* Using actual dark tiles now - no filter needed */
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom { .leaflet-control-zoom {
margin-top: 45px !important; margin-top: 45px !important;
@@ -2381,7 +2394,7 @@ header h1 .tagline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
z-index: 1000; z-index: 1000;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--accent-cyan); color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan); text-shadow: 0 0 5px var(--accent-cyan);
@@ -2398,7 +2411,7 @@ header h1 .tagline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
z-index: 1000; z-index: 1000;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--accent-cyan); color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan); text-shadow: 0 0 5px var(--accent-cyan);
@@ -2419,7 +2432,7 @@ header h1 .tagline {
} }
.aircraft-popup { .aircraft-popup {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
} }
@@ -2463,7 +2476,7 @@ header h1 .tagline {
background: rgba(0, 0, 0, 0.8) !important; background: rgba(0, 0, 0, 0.8) !important;
border: 1px solid var(--accent-cyan) !important; border: 1px solid var(--accent-cyan) !important;
color: var(--accent-cyan) !important; color: var(--accent-cyan) !important;
font-family: 'JetBrains Mono', monospace !important; font-family: var(--font-mono) !important;
font-size: 10px !important; font-size: 10px !important;
padding: 2px 6px !important; padding: 2px 6px !important;
border-radius: 2px !important; border-radius: 2px !important;
@@ -2491,7 +2504,7 @@ header h1 .tagline {
border-radius: 4px; border-radius: 4px;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -2519,9 +2532,8 @@ header h1 .tagline {
/* Satellite Dashboard Embed */ /* Satellite Dashboard Embed */
.satellite-dashboard-embed { .satellite-dashboard-embed {
width: 100%; width: 100%;
height: calc(100vh - 200px); flex: 1;
min-height: 700px; min-height: 400px;
max-height: 900px;
background: var(--bg-primary); background: var(--bg-primary);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@@ -2708,7 +2720,7 @@ header h1 .tagline {
color: var(--accent-cyan); color: var(--accent-cyan);
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
text-shadow: 0 0 15px var(--accent-cyan-dim); text-shadow: 0 0 15px var(--accent-cyan-dim);
line-height: 1.2; line-height: 1.2;
} }
@@ -3102,7 +3114,7 @@ header h1 .tagline {
} }
.sensor-card .data-value { .sensor-card .data-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
color: var(--accent-cyan); color: var(--accent-cyan);
} }
@@ -3152,7 +3164,7 @@ header h1 .tagline {
display: flex; display: flex;
gap: 15px; gap: 15px;
font-size: 10px; font-size: 10px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.recon-stats span { .recon-stats span {
@@ -3202,14 +3214,14 @@ header h1 .tagline {
.device-id { .device-id {
color: var(--text-dim); color: var(--text-dim);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
} }
.device-meta { .device-meta {
text-align: right; text-align: right;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.device-meta.encrypted { .device-meta.encrypted {
@@ -3285,7 +3297,7 @@ header h1 .tagline {
} }
.hex-dump { .hex-dump {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--text-dim); color: var(--text-dim);
background: var(--bg-primary); background: var(--bg-primary);
@@ -3325,8 +3337,8 @@ header h1 .tagline {
background: var(--bg-secondary); background: var(--bg-secondary);
margin: 0 15px 10px 15px; margin: 0 15px 10px 15px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
height: calc(100vh - 200px); flex: 1;
min-height: 400px; min-height: 0; /* Allow shrinking in flex context */
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
} }
@@ -3376,7 +3388,7 @@ header h1 .tagline {
/* WiFi Main Content - 3 columns */ /* WiFi Main Content - 3 columns */
.wifi-main-content { .wifi-main-content {
display: grid; display: grid;
grid-template-columns: 1fr minmax(240px, 280px) minmax(240px, 280px); grid-template-columns: minmax(300px, 1fr) minmax(240px, 280px) minmax(240px, 280px);
gap: 10px; gap: 10px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -3391,6 +3403,7 @@ header h1 .tagline {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
min-width: 0; /* Prevent content from forcing panel wider */
} }
.wifi-networks-header { .wifi-networks-header {
@@ -3558,6 +3571,8 @@ header h1 .tagline {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
padding: 12px; padding: 12px;
min-width: 0; /* Prevent content from forcing panel wider */
overflow: hidden;
} }
.wifi-radar-panel h5 { .wifi-radar-panel h5 {
@@ -3793,17 +3808,97 @@ header h1 .tagline {
text-transform: uppercase; text-transform: uppercase;
} }
.wifi-client-count-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
background: var(--accent-cyan);
color: var(--bg-primary);
border-radius: 10px;
font-weight: 600;
margin-left: 6px;
vertical-align: middle;
}
.wifi-client-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.wifi-client-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.wifi-client-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.wifi-client-mac {
font-family: monospace;
font-size: 12px;
color: var(--text-primary);
}
.wifi-client-vendor {
font-size: 10px;
color: var(--text-dim);
}
.wifi-client-probes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.wifi-client-probe-badge {
font-size: 9px;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 3px;
color: var(--text-secondary);
}
.wifi-client-signal {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.wifi-client-rssi {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.wifi-client-lastseen {
font-size: 9px;
color: var(--text-dim);
}
/* WiFi Responsive */ /* WiFi Responsive */
@media (max-width: 1400px) { @media (max-width: 1400px) {
.wifi-main-content { .wifi-main-content {
grid-template-columns: 1fr 240px 240px; grid-template-columns: minmax(280px, 1fr) 240px 240px;
} }
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.wifi-layout-container { .wifi-layout-container {
height: auto; flex: 1;
max-height: calc(100vh - 200px); min-height: 0;
} }
.wifi-main-content { .wifi-main-content {
@@ -3839,8 +3934,8 @@ header h1 .tagline {
background: var(--bg-secondary); background: var(--bg-secondary);
margin: 0 15px 10px 15px; margin: 0 15px 10px 15px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
height: calc(100vh - 200px); flex: 1;
min-height: 400px; min-height: 0; /* Allow shrinking in flex context */
} }
.bt-visuals-column { .bt-visuals-column {
@@ -3954,7 +4049,7 @@ header h1 .tagline {
} }
.bt-detail-address { .bt-detail-address {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: #00d4ff; color: #00d4ff;
} }
@@ -3968,7 +4063,7 @@ header h1 .tagline {
} }
.bt-detail-rssi-value { .bt-detail-rssi-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
} }
@@ -4063,7 +4158,7 @@ header h1 .tagline {
} }
.bt-detail-services-list { .bt-detail-services-list {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 8px; font-size: 8px;
color: var(--text-dim); color: var(--text-dim);
white-space: nowrap; white-space: nowrap;
@@ -4097,10 +4192,37 @@ header h1 .tagline {
.bt-device-list { .bt-device-list {
border-left-color: var(--accent-purple) !important; border-left-color: var(--accent-purple) !important;
display: flex;
flex-direction: column;
min-width: 280px;
max-width: 320px;
max-height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.bt-device-list .wifi-device-list-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.bt-device-list .wifi-device-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
} }
.bt-device-list .wifi-device-list-header h5 { .bt-device-list .wifi-device-list-header h5 {
color: var(--accent-purple); color: var(--accent-purple);
margin: 0;
font-size: 13px;
font-weight: 600;
} }
/* Bluetooth Device Filters */ /* Bluetooth Device Filters */
@@ -4110,6 +4232,7 @@ header h1 .tagline {
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0;
} }
.bt-filter-btn { .bt-filter-btn {
@@ -4282,7 +4405,7 @@ header h1 .tagline {
} }
.bt-rssi-value { .bt-rssi-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
min-width: 28px; min-width: 28px;
@@ -4521,8 +4644,8 @@ header h1 .tagline {
@media (max-width: 1200px) { @media (max-width: 1200px) {
.bt-layout-container { .bt-layout-container {
flex-direction: column; flex-direction: column;
height: auto; flex: 1;
max-height: calc(100vh - 200px); min-height: 0;
} }
.bt-layout-container .wifi-visuals { .bt-layout-container .wifi-visuals {
@@ -4641,7 +4764,7 @@ header h1 .tagline {
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
font-size: 10px; font-size: 10px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
.security-legend-item { .security-legend-item {
@@ -4688,7 +4811,7 @@ header h1 .tagline {
} }
.signal-value { .signal-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 28px; font-size: 28px;
color: var(--accent-cyan); color: var(--accent-cyan);
text-shadow: 0 0 10px var(--accent-cyan-dim); text-shadow: 0 0 10px var(--accent-cyan-dim);
@@ -4841,7 +4964,7 @@ body::before {
color: #000; color: #000;
border: none; border: none;
padding: 12px 40px; padding: 12px 40px;
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
letter-spacing: 2px; letter-spacing: 2px;
@@ -5204,7 +5327,7 @@ body::before {
.meter-value { .meter-value {
font-size: 10px; font-size: 10px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
color: var(--text-secondary); color: var(--text-secondary);
width: 50px; width: 50px;
text-align: right; text-align: right;
@@ -5361,7 +5484,7 @@ body::before {
} }
.freq-digits { .freq-digits {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 56px; font-size: 56px;
font-weight: 700; font-weight: 700;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -5382,7 +5505,7 @@ body::before {
} }
.freq-unit { .freq-unit {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 20px; font-size: 20px;
color: var(--text-secondary); color: var(--text-secondary);
margin-left: 8px; margin-left: 8px;
@@ -5526,7 +5649,7 @@ body::before {
} }
.knob-value { .knob-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -5651,7 +5774,7 @@ body::before {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
border-radius: 4px; border-radius: 4px;
@@ -5713,13 +5836,13 @@ body::before {
} }
.signal-arc-label { .signal-arc-label {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 8px; font-size: 8px;
fill: var(--text-muted); fill: var(--text-muted);
} }
.signal-arc-value { .signal-arc-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
fill: var(--accent-cyan); fill: var(--accent-cyan);
@@ -5751,7 +5874,7 @@ body::before {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
@@ -5887,7 +6010,7 @@ body::before {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
padding: 10px 15px; padding: 10px 15px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
} }
@@ -5976,7 +6099,7 @@ body::before {
} }
.module-header { .module-header {
font-family: 'Orbitron', 'JetBrains Mono', monospace; font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -6001,7 +6124,7 @@ body::before {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
color: var(--text-primary); color: var(--text-primary);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
padding: 6px 8px; padding: 6px 8px;
text-align: center; text-align: center;
@@ -6040,16 +6163,27 @@ body::before {
cursor: not-allowed; cursor: not-allowed;
} }
.radio-action-btn.scan { .radio-action-btn.scan,
.radio-action-btn.listen {
background: var(--accent-green); background: var(--accent-green);
border-color: var(--accent-green); border-color: var(--accent-green);
color: #000; color: #000;
} }
.radio-action-btn.scan:hover:not(:disabled) { .radio-action-btn.scan:hover:not(:disabled),
.radio-action-btn.listen:hover:not(:disabled) {
box-shadow: 0 0 15px rgba(0, 255, 136, 0.4); box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
} }
.radio-action-btn.listen.active {
background: var(--accent-red);
border-color: var(--accent-red);
}
.radio-action-btn.listen.active:hover:not(:disabled) {
box-shadow: 0 0 20px var(--accent-red-dim);
}
/* Statistics Box */ /* Statistics Box */
.stat-box { .stat-box {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
@@ -6059,7 +6193,7 @@ body::before {
} }
.stat-value { .stat-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 22px; font-size: 22px;
font-weight: bold; font-weight: bold;
} }
@@ -6107,7 +6241,7 @@ body::before {
.tune-btn { .tune-btn {
padding: 4px 8px; padding: 4px 8px;
font-size: 10px; font-size: 10px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
@@ -6137,13 +6271,13 @@ body::before {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 8px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
/* Listening Mode Selector Buttons */ /* Listening Mode Selector Buttons */
.radio-mode-btn { .radio-mode-btn {
padding: 12px 24px; padding: 12px 24px;
font-family: 'Orbitron', 'JetBrains Mono', monospace; font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -6184,7 +6318,7 @@ body::before {
/* Frequency Preset Buttons */ /* Frequency Preset Buttons */
.preset-freq-btn { .preset-freq-btn {
padding: 8px 14px; padding: 8px 14px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -6248,4 +6382,4 @@ body::before {
[data-animations="off"] .logo-dot, [data-animations="off"] .logo-dot,
[data-animations="off"] .welcome-logo { [data-animations="off"] .welcome-logo {
animation: none !important; animation: none !important;
} }
+6 -6
View File
@@ -37,7 +37,7 @@
/* Typography */ /* Typography */
.landing-title { .landing-title {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 2.2rem; font-size: 2.2rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.4em; letter-spacing: 0.4em;
@@ -48,7 +48,7 @@
} }
.landing-tagline { .landing-tagline {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
color: var(--accent-cyan); color: var(--accent-cyan);
font-size: 0.9rem; font-size: 0.9rem;
letter-spacing: 0.15em; letter-spacing: 0.15em;
@@ -71,7 +71,7 @@
/* Hacker Style Error */ /* Hacker Style Error */
.flash-error { .flash-error {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--accent-red); color: var(--accent-red);
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
@@ -94,7 +94,7 @@
color: var(--accent-cyan); color: var(--accent-cyan);
padding: 12px; padding: 12px;
margin-bottom: 15px; margin-bottom: 15px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
outline: none; outline: none;
box-sizing: border-box; /* Crucial for visibility */ box-sizing: border-box; /* Crucial for visibility */
@@ -106,7 +106,7 @@
border: 2px solid var(--accent-cyan); border: 2px solid var(--accent-cyan);
color: var(--accent-cyan); color: var(--accent-cyan);
padding: 15px; padding: 15px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
letter-spacing: 3px; letter-spacing: 3px;
cursor: pointer; cursor: pointer;
@@ -116,7 +116,7 @@
.landing-version { .landing-version {
margin-top: 25px; margin-top: 25px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px; letter-spacing: 2px;
+328 -328
View File
@@ -1,328 +1,328 @@
/* APRS Function Bar (Stats Strip) Styles */ /* APRS Function Bar (Stats Strip) Styles */
.aprs-strip { .aprs-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%); background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
padding: 6px 12px; padding: 6px 12px;
margin-bottom: 10px; margin-bottom: 10px;
overflow-x: auto; overflow-x: auto;
} }
.aprs-strip-inner { .aprs-strip-inner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: max-content; min-width: max-content;
} }
.aprs-strip .strip-stat { .aprs-strip .strip-stat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 4px 10px; padding: 4px 10px;
background: rgba(74, 158, 255, 0.05); background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15); border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px; border-radius: 4px;
min-width: 55px; min-width: 55px;
} }
.aprs-strip .strip-stat:hover { .aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3); border-color: rgba(74, 158, 255, 0.3);
} }
.aprs-strip .strip-value { .aprs-strip .strip-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
line-height: 1.2; line-height: 1.2;
} }
.aprs-strip .strip-label { .aprs-strip .strip-label {
font-size: 8px; font-size: 8px;
font-weight: 600; font-weight: 600;
color: var(--text-dim); color: var(--text-dim);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-top: 1px; margin-top: 1px;
} }
.aprs-strip .strip-divider { .aprs-strip .strip-divider {
width: 1px; width: 1px;
height: 28px; height: 28px;
background: var(--border-color); background: var(--border-color);
margin: 0 4px; margin: 0 4px;
} }
/* Signal stat coloring */ /* Signal stat coloring */
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); } .aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); } .aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); } .aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */ /* Controls */
.aprs-strip .strip-control { .aprs-strip .strip-control {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.aprs-strip .strip-select { .aprs-strip .strip-select {
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-primary); color: var(--text-primary);
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
} }
.aprs-strip .strip-select:hover { .aprs-strip .strip-select:hover {
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
.aprs-strip .strip-input-label { .aprs-strip .strip-input-label {
font-size: 9px; font-size: 9px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 600; font-weight: 600;
} }
.aprs-strip .strip-input { .aprs-strip .strip-input {
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-primary); color: var(--text-primary);
padding: 4px 6px; padding: 4px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
width: 50px; width: 50px;
text-align: center; text-align: center;
} }
.aprs-strip .strip-input:hover, .aprs-strip .strip-input:hover,
.aprs-strip .strip-input:focus { .aprs-strip .strip-input:focus {
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
outline: none; outline: none;
} }
/* Tool Status Indicators */ /* Tool Status Indicators */
.aprs-strip .strip-tools { .aprs-strip .strip-tools {
display: flex; display: flex;
gap: 4px; gap: 4px;
} }
.aprs-strip .strip-tool { .aprs-strip .strip-tool {
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
padding: 3px 6px; padding: 3px 6px;
border-radius: 3px; border-radius: 3px;
background: rgba(255, 59, 48, 0.2); background: rgba(255, 59, 48, 0.2);
color: var(--accent-red); color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3); border: 1px solid rgba(255, 59, 48, 0.3);
} }
.aprs-strip .strip-tool.ok { .aprs-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1); background: rgba(0, 255, 136, 0.1);
color: var(--accent-green); color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3); border-color: rgba(0, 255, 136, 0.3);
} }
/* Buttons */ /* Buttons */
.aprs-strip .strip-btn { .aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2); border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary); color: var(--text-primary);
padding: 6px 12px; padding: 6px 12px;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.aprs-strip .strip-btn:hover:not(:disabled) { .aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2); background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4); border-color: rgba(74, 158, 255, 0.4);
} }
.aprs-strip .strip-btn.primary { .aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none; border: none;
color: #000; color: #000;
} }
.aprs-strip .strip-btn.primary:hover:not(:disabled) { .aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1); filter: brightness(1.1);
} }
.aprs-strip .strip-btn.stop { .aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none; border: none;
color: #fff; color: #fff;
} }
.aprs-strip .strip-btn.stop:hover:not(:disabled) { .aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1); filter: brightness(1.1);
} }
.aprs-strip .strip-btn:disabled { .aprs-strip .strip-btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Status indicator */ /* Status indicator */
.aprs-strip .strip-status { .aprs-strip .strip-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 8px; padding: 4px 8px;
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
} }
.aprs-strip .status-dot { .aprs-strip .status-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--text-muted); background: var(--text-muted);
} }
.aprs-strip .status-dot.listening { .aprs-strip .status-dot.listening {
background: var(--accent-cyan); background: var(--accent-cyan);
animation: aprs-strip-pulse 1.5s ease-in-out infinite; animation: aprs-strip-pulse 1.5s ease-in-out infinite;
} }
.aprs-strip .status-dot.tracking { .aprs-strip .status-dot.tracking {
background: var(--accent-green); background: var(--accent-green);
animation: aprs-strip-pulse 1.5s ease-in-out infinite; animation: aprs-strip-pulse 1.5s ease-in-out infinite;
} }
.aprs-strip .status-dot.error { .aprs-strip .status-dot.error {
background: var(--accent-red); background: var(--accent-red);
} }
@keyframes aprs-strip-pulse { @keyframes aprs-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; } 0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; } 50% { opacity: 0.6; box-shadow: none; }
} }
/* Time display */ /* Time display */
.aprs-strip .strip-time { .aprs-strip .strip-time {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--text-muted); color: var(--text-muted);
padding: 4px 8px; padding: 4px 8px;
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
border-radius: 4px; border-radius: 4px;
white-space: nowrap; white-space: nowrap;
} }
/* APRS Status Bar Styles (Sidebar - legacy) */ /* APRS Status Bar Styles (Sidebar - legacy) */
.aprs-status-bar { .aprs-status-bar {
margin-top: 12px; margin-top: 12px;
padding: 10px; padding: 10px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
} }
.aprs-status-indicator { .aprs-status-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.aprs-status-dot { .aprs-status-dot {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: var(--text-muted); background: var(--text-muted);
} }
.aprs-status-dot.standby { background: var(--text-muted); } .aprs-status-dot.standby { background: var(--text-muted); }
.aprs-status-dot.listening { .aprs-status-dot.listening {
background: var(--accent-cyan); background: var(--accent-cyan);
animation: aprs-pulse 1.5s ease-in-out infinite; animation: aprs-pulse 1.5s ease-in-out infinite;
} }
.aprs-status-dot.tracking { background: var(--accent-green); } .aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); } .aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse { @keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); } 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); } 50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
} }
.aprs-status-text { .aprs-status-text {
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
letter-spacing: 1px; letter-spacing: 1px;
} }
.aprs-status-stats { .aprs-status-stats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
font-size: 9px; font-size: 9px;
} }
.aprs-stat { .aprs-stat {
color: var(--text-secondary); color: var(--text-secondary);
} }
.aprs-stat-label { .aprs-stat-label {
color: var(--text-muted); color: var(--text-muted);
} }
/* Signal Meter Styles */ /* Signal Meter Styles */
.aprs-signal-meter { .aprs-signal-meter {
margin-top: 12px; margin-top: 12px;
padding: 10px; padding: 10px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
} }
.aprs-meter-header { .aprs-meter-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.aprs-meter-label { .aprs-meter-label {
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
letter-spacing: 1px; letter-spacing: 1px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.aprs-meter-value { .aprs-meter-value {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
font-family: monospace; font-family: monospace;
color: var(--accent-cyan); color: var(--accent-cyan);
min-width: 24px; min-width: 24px;
} }
.aprs-meter-burst { .aprs-meter-burst {
font-size: 9px; font-size: 9px;
font-weight: bold; font-weight: bold;
color: var(--accent-yellow); color: var(--accent-yellow);
background: rgba(255, 193, 7, 0.2); background: rgba(255, 193, 7, 0.2);
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
animation: burst-flash 0.3s ease-out; animation: burst-flash 0.3s ease-out;
} }
@keyframes burst-flash { @keyframes burst-flash {
0% { opacity: 1; transform: scale(1.1); } 0% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); } 100% { opacity: 1; transform: scale(1); }
} }
.aprs-meter-bar-container { .aprs-meter-bar-container {
position: relative; position: relative;
height: 16px; height: 16px;
background: rgba(0,0,0,0.4); background: rgba(0,0,0,0.4);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
margin-bottom: 4px; margin-bottom: 4px;
} }
.aprs-meter-bar { .aprs-meter-bar {
height: 100%; height: 100%;
width: 0%; width: 0%;
background: linear-gradient(90deg, background: linear-gradient(90deg,
var(--accent-green) 0%, var(--accent-green) 0%,
var(--accent-cyan) 50%, var(--accent-cyan) 50%,
var(--accent-yellow) 75%, var(--accent-yellow) 75%,
var(--accent-red) 100% var(--accent-red) 100%
); );
border-radius: 3px; border-radius: 3px;
transition: width 0.1s ease-out; transition: width 0.1s ease-out;
} }
.aprs-meter-bar.no-signal { .aprs-meter-bar.no-signal {
opacity: 0.3; opacity: 0.3;
} }
.aprs-meter-ticks { .aprs-meter-ticks {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 8px; font-size: 8px;
color: var(--text-muted); color: var(--text-muted);
padding: 0 2px; padding: 0 2px;
} }
.aprs-meter-status { .aprs-meter-status {
font-size: 9px; font-size: 9px;
color: var(--text-muted); color: var(--text-muted);
text-align: center; text-align: center;
margin-top: 6px; margin-top: 6px;
} }
.aprs-meter-status.active { .aprs-meter-status.active {
color: var(--accent-green); color: var(--accent-green);
} }
.aprs-meter-status.no-signal { .aprs-meter-status.no-signal {
color: var(--accent-yellow); color: var(--accent-yellow);
} }
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -27,7 +27,7 @@
} }
.spy-stations-title { .spy-stations-title {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
@@ -101,7 +101,7 @@
} }
.spy-station-name { .spy-station-name {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
@@ -117,7 +117,7 @@
/* Type Badge */ /* Type Badge */
.spy-station-badge { .spy-station-badge {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -173,7 +173,7 @@
} }
.spy-meta-mode { .spy-meta-mode {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--accent-orange); color: var(--accent-orange);
} }
@@ -186,7 +186,7 @@
} }
.spy-freq-list { .spy-freq-list {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--accent-cyan); color: var(--accent-cyan);
line-height: 1.6; line-height: 1.6;
@@ -199,7 +199,7 @@
} }
.spy-freq-item { .spy-freq-item {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--accent-cyan); color: var(--accent-cyan);
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -236,7 +236,7 @@
} }
.spy-freq-select { .spy-freq-select {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
padding: 6px 8px; padding: 6px 8px;
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -273,7 +273,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
+876
View File
@@ -0,0 +1,876 @@
/**
* SSTV Mode Styles
* ISS Slow-Scan Television decoder interface
*/
/* ============================================
MODE VISIBILITY
============================================ */
#sstvMode.active {
display: block !important;
}
/* ============================================
VISUALS CONTAINER
============================================ */
.sstv-visuals-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
min-height: 0;
flex: 1;
height: 100%;
overflow: hidden;
}
/* ============================================
MAIN ROW (Live Decode + Gallery)
============================================ */
.sstv-main-row {
display: flex;
flex-direction: row;
gap: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ============================================
STATS STRIP
============================================ */
.sstv-stats-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.sstv-strip-group {
display: flex;
align-items: center;
gap: 12px;
}
.sstv-strip-status {
display: flex;
align-items: center;
gap: 6px;
}
.sstv-strip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sstv-strip-dot.idle {
background: var(--text-dim);
}
.sstv-strip-dot.listening {
background: var(--accent-yellow);
animation: pulse 1s infinite;
}
.sstv-strip-dot.decoding {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
animation: pulse 0.5s infinite;
}
.sstv-strip-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.sstv-strip-btn {
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
text-transform: uppercase;
font-weight: 600;
transition: all 0.15s ease;
}
.sstv-strip-btn.start {
background: var(--accent-cyan);
color: var(--bg-primary);
}
.sstv-strip-btn.start:hover {
background: var(--accent-cyan-bright, #00d4ff);
}
.sstv-strip-btn.stop {
background: var(--accent-red, #ff3366);
color: white;
}
.sstv-strip-btn.stop:hover {
background: #ff1a53;
}
.sstv-strip-divider {
width: 1px;
height: 24px;
background: var(--border-color);
}
.sstv-strip-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 50px;
}
.sstv-strip-value {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-strip-value.accent-cyan {
color: var(--accent-cyan);
}
.sstv-strip-label {
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Location inputs in strip */
.sstv-strip-location {
display: flex;
align-items: center;
gap: 4px;
}
.sstv-loc-input {
width: 70px;
padding: 4px 6px;
font-family: var(--font-mono);
font-size: 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
text-align: right;
}
.sstv-loc-input:focus {
outline: none;
border-color: var(--accent-cyan);
}
.sstv-strip-btn.gps {
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.sstv-strip-btn.gps:hover {
background: var(--accent-green);
color: #000;
border-color: var(--accent-green);
}
.sstv-strip-btn.update-tle {
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.sstv-strip-btn.update-tle:hover {
background: var(--accent-orange);
color: #000;
border-color: var(--accent-orange);
}
/* ============================================
LIVE DECODE SECTION
============================================ */
.sstv-live-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-width: 300px;
}
.sstv-live-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-live-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-live-title svg {
color: var(--accent-cyan);
}
.sstv-live-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 0;
}
.sstv-canvas-container {
position: relative;
background: #000;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
#sstvCanvas {
display: block;
image-rendering: pixelated;
}
.sstv-decode-info {
width: 100%;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sstv-mode-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-align: center;
}
.sstv-progress-bar {
width: 100%;
height: 4px;
background: var(--bg-secondary);
border-radius: 2px;
overflow: hidden;
}
.sstv-progress-bar .progress {
height: 100%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
border-radius: 2px;
transition: width 0.3s ease;
}
.sstv-status-message {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
/* Idle state */
.sstv-idle-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-dim);
}
.sstv-idle-state svg {
width: 64px;
height: 64px;
opacity: 0.3;
margin-bottom: 16px;
}
.sstv-idle-state h4 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.sstv-idle-state p {
font-size: 12px;
max-width: 250px;
}
/* ============================================
GALLERY SECTION
============================================ */
.sstv-gallery-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1.5;
min-width: 300px;
}
.sstv-gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-gallery-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-gallery-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 10px;
}
.sstv-gallery-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 12px;
overflow-y: auto;
align-content: start;
}
.sstv-image-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
cursor: pointer;
}
.sstv-image-card:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
}
.sstv-image-preview {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
background: #000;
display: block;
}
.sstv-image-info {
padding: 8px 10px;
border-top: 1px solid var(--border-color);
}
.sstv-image-mode {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
margin-bottom: 4px;
}
.sstv-image-timestamp {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
}
/* Empty gallery state */
.sstv-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
grid-column: 1 / -1;
}
.sstv-gallery-empty svg {
width: 48px;
height: 48px;
opacity: 0.3;
margin-bottom: 12px;
}
/* ============================================
TOP ROW (Map + Countdown)
============================================ */
.sstv-top-row {
display: flex;
gap: 12px;
height: 220px;
flex-shrink: 0;
}
/* ============================================
ISS MAP ROW
============================================ */
.sstv-map-row {
flex: 1.5;
min-width: 0;
height: 100%;
}
.sstv-map-container {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
height: 100%;
}
.sstv-iss-map {
width: 100%;
height: 100%;
background: #0a1628;
}
.sstv-map-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
pointer-events: none;
z-index: 1000;
}
.sstv-map-info {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-mono);
}
.sstv-map-label {
font-size: 10px;
font-weight: bold;
color: #ffcc00;
background: rgba(255, 204, 0, 0.2);
padding: 2px 6px;
border-radius: 3px;
}
.sstv-map-coords {
font-size: 11px;
color: var(--accent-cyan);
}
.sstv-map-alt {
font-size: 10px;
color: var(--text-secondary);
}
.sstv-pass-info {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
}
.sstv-pass-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
}
.sstv-pass-value {
font-size: 11px;
color: var(--text-primary);
}
/* ============================================
ISS MAP MARKER
============================================ */
.sstv-iss-marker {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.sstv-iss-dot {
width: 16px;
height: 16px;
background: #ffcc00;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
animation: iss-pulse 2s ease-in-out infinite;
}
.sstv-iss-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: bold;
color: #ffcc00;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.5);
margin-top: 2px;
}
@keyframes iss-pulse {
0%, 100% {
box-shadow: 0 0 15px rgba(255, 204, 0, 0.8), 0 0 30px rgba(255, 204, 0, 0.4);
}
50% {
box-shadow: 0 0 25px rgba(255, 204, 0, 1), 0 0 50px rgba(255, 204, 0, 0.6);
}
}
/* Override Leaflet default marker styles */
.leaflet-marker-icon.sstv-iss-marker {
background: transparent;
border: none;
}
/* ============================================
COUNTDOWN PANEL
============================================ */
.sstv-countdown-panel {
flex: 1;
min-width: 280px;
max-width: 380px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.sstv-countdown-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-countdown-header svg {
color: var(--accent-cyan);
}
.sstv-countdown-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
gap: 10px;
}
.sstv-countdown-timer {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.sstv-countdown-value {
font-family: var(--font-mono);
font-size: 28px;
font-weight: 700;
color: var(--accent-cyan);
letter-spacing: 2px;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
}
.sstv-countdown-value.imminent {
color: var(--accent-green);
text-shadow: 0 0 20px rgba(0, 255, 136, 0.4);
animation: countdown-pulse 1s ease-in-out infinite;
}
.sstv-countdown-value.active {
color: var(--accent-yellow);
text-shadow: 0 0 20px rgba(255, 204, 0, 0.4);
animation: countdown-pulse 0.5s ease-in-out infinite;
}
@keyframes countdown-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.sstv-countdown-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.sstv-countdown-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px 12px;
width: 100%;
padding: 10px;
background: var(--bg-secondary);
border-radius: 6px;
}
.sstv-countdown-detail {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.sstv-detail-label {
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sstv-detail-value {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-countdown-status {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
background: rgba(0, 0, 0, 0.15);
border-top: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
}
.sstv-countdown-status .sstv-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.sstv-countdown-status.has-pass .sstv-status-dot {
background: var(--accent-cyan);
}
.sstv-countdown-status.imminent .sstv-status-dot {
background: var(--accent-green);
animation: pulse 1s infinite;
}
.sstv-countdown-status.active .sstv-status-dot {
background: var(--accent-yellow);
box-shadow: 0 0 8px var(--accent-yellow);
animation: pulse 0.5s infinite;
}
/* ============================================
IMAGE MODAL
============================================ */
.sstv-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 40px;
}
.sstv-image-modal.show {
display: flex;
}
.sstv-image-modal img {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.sstv-modal-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
}
.sstv-modal-close:hover {
opacity: 1;
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.sstv-main-row {
flex-direction: column;
overflow-y: auto;
}
.sstv-live-section {
max-width: none;
min-height: 350px;
}
.sstv-gallery-section {
min-height: 300px;
}
}
@media (max-width: 1024px) {
.sstv-top-row {
flex-direction: column;
height: auto;
}
.sstv-map-row {
flex: none;
height: 180px;
}
.sstv-countdown-panel {
min-width: auto;
max-width: none;
height: auto;
}
.sstv-countdown-value {
font-size: 24px;
}
.sstv-iss-map {
height: 180px;
}
.sstv-map-overlay {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
@media (max-width: 768px) {
.sstv-stats-strip {
padding: 8px 12px;
gap: 8px;
flex-wrap: wrap;
}
.sstv-strip-divider {
display: none;
}
.sstv-strip-location {
flex-wrap: wrap;
}
.sstv-loc-input {
width: 55px;
}
.sstv-gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
padding: 8px;
}
.sstv-iss-map {
height: 150px;
}
.sstv-map-info {
gap: 8px;
}
.sstv-map-overlay {
padding: 6px 10px;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
+1463 -1463
View File
File diff suppressed because it is too large Load Diff
+660 -660
View File
File diff suppressed because it is too large Load Diff
+50 -15
View File
@@ -5,6 +5,8 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
--bg-dark: #0a0c10; --bg-dark: #0a0c10;
--bg-panel: #0f1218; --bg-panel: #0f1218;
--bg-card: #151a23; --bg-card: #151a23;
@@ -23,7 +25,7 @@
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: var(--font-sans);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -93,7 +95,7 @@ body {
} }
.logo { .logo {
font-family: 'Inter', sans-serif; font-family: var(--font-sans);
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
letter-spacing: 3px; letter-spacing: 3px;
@@ -142,7 +144,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3); border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px; border-radius: 4px;
padding: 4px 10px; padding: 4px 10px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
} }
@@ -162,10 +164,45 @@ body {
.status-bar { .status-bar {
display: flex; display: flex;
gap: 20px; gap: 12px;
align-items: center; align-items: center;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
flex-wrap: nowrap;
}
.location-selector {
display: flex;
align-items: center;
gap: 8px;
}
.location-selector .location-label {
color: var(--text-secondary);
font-size: 10px;
}
.location-selector .location-select {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.location-selector .location-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.location-selector .location-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
} }
.status-item { .status-item {
@@ -211,6 +248,7 @@ body {
} }
/* Main dashboard grid */ /* Main dashboard grid */
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
.dashboard { .dashboard {
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -218,7 +256,7 @@ body {
grid-template-columns: 1fr 1fr 340px; grid-template-columns: 1fr 1fr 340px;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
gap: 0; gap: 0;
height: calc(100vh - 60px); height: calc(100vh - 100px);
min-height: 500px; min-height: 500px;
} }
@@ -457,7 +495,7 @@ body {
} }
.telemetry-value { .telemetry-value {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: var(--accent-cyan); color: var(--accent-cyan);
} }
@@ -543,7 +581,7 @@ body {
} }
.pass-time { .pass-time {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
} }
/* Bottom controls bar */ /* Bottom controls bar */
@@ -579,7 +617,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3); border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
} }
@@ -626,10 +664,7 @@ body {
background: var(--bg-dark) !important; background: var(--bg-dark) !important;
} }
.leaflet-tile-pane, /* Using actual dark tiles now - no filter needed */
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-panel) !important; background: var(--bg-panel) !important;
@@ -699,7 +734,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: auto; height: auto;
min-height: calc(100vh - 60px); min-height: calc(100vh - 100px);
} }
.polar-container, .polar-container,
@@ -751,4 +786,4 @@ body.embedded .panel {
body.embedded .controls-bar { body.embedded .controls-bar {
padding: 10px 15px; padding: 10px 15px;
} }
+444 -399
View File
@@ -1,399 +1,444 @@
/* Settings Modal Styles */ /* Settings Modal Styles */
.settings-modal { .settings-modal {
display: none; display: none;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
z-index: 10000; z-index: 10000;
overflow-y: auto; overflow-y: auto;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.settings-modal.active { .settings-modal.active {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: 40px 20px; padding: 40px 20px;
} }
.settings-content { .settings-content {
background: var(--bg-dark, #0a0a0f); background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e); border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px; border-radius: 8px;
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
position: relative; position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
} }
.settings-header { .settings-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 20px; padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1a1a2e); border-bottom: 1px solid var(--border-color, #1a1a2e);
} }
.settings-header h2 { .settings-header h2 {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.settings-header h2 .icon { .settings-header h2 .icon {
width: 20px; width: 20px;
height: 20px; height: 20px;
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
} }
.settings-close { .settings-close {
background: none; background: none;
border: none; border: none;
color: var(--text-muted, #666); color: var(--text-muted, #666);
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
line-height: 1; line-height: 1;
transition: color 0.2s; transition: color 0.2s;
} }
.settings-close:hover { .settings-close:hover {
color: var(--accent-red, #ff4444); color: var(--accent-red, #ff4444);
} }
/* Settings Tabs */ /* Settings Tabs */
.settings-tabs { .settings-tabs {
display: flex; display: flex;
border-bottom: 1px solid var(--border-color, #1a1a2e); border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px; padding: 0 20px;
gap: 4px; gap: 4px;
} }
.settings-tab { .settings-tab {
background: none; background: none;
border: none; border: none;
padding: 12px 16px; padding: 12px 16px;
color: var(--text-muted, #666); color: var(--text-muted, #666);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: color 0.2s; transition: color 0.2s;
} }
.settings-tab:hover { .settings-tab:hover {
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
} }
.settings-tab.active { .settings-tab.active {
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
} }
.settings-tab.active::after { .settings-tab.active::after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: -1px; bottom: -1px;
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 2px;
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
} }
/* Settings Sections */ /* Settings Sections */
.settings-section { .settings-section {
display: none; display: none;
padding: 20px; padding: 20px;
} }
.settings-section.active { .settings-section.active {
display: block; display: block;
} }
.settings-group { .settings-group {
margin-bottom: 24px; margin-bottom: 24px;
} }
.settings-group:last-child { .settings-group:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.settings-group-title { .settings-group-title {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-muted, #666); color: var(--text-muted, #666);
margin-bottom: 12px; margin-bottom: 12px;
} }
/* Settings Row */ /* Settings Row */
.settings-row { .settings-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.settings-row:last-child { .settings-row:last-child {
border-bottom: none; border-bottom: none;
} }
.settings-label { .settings-label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.settings-label-text { .settings-label-text {
font-size: 13px; font-size: 13px;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
} }
.settings-label-desc { .settings-label-desc {
font-size: 11px; font-size: 11px;
color: var(--text-muted, #666); color: var(--text-muted, #666);
} }
/* Toggle Switch */ /* Toggle Switch */
.toggle-switch { .toggle-switch {
position: relative; position: relative;
width: 44px; width: 44px;
height: 24px; height: 24px;
flex-shrink: 0; flex-shrink: 0;
} }
.toggle-switch input { .toggle-switch input {
opacity: 0; opacity: 0;
width: 0; width: 0;
height: 0; height: 0;
} }
.toggle-slider { .toggle-slider {
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--bg-tertiary, #1a1a2e); background-color: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e); border: 1px solid var(--border-color, #2a2a3e);
transition: 0.3s; transition: 0.3s;
border-radius: 24px; border-radius: 24px;
} }
.toggle-slider:before { .toggle-slider:before {
position: absolute; position: absolute;
content: ""; content: "";
height: 18px; height: 18px;
width: 18px; width: 18px;
left: 2px; left: 2px;
bottom: 2px; bottom: 2px;
background-color: var(--text-muted, #666); background-color: var(--text-muted, #666);
transition: 0.3s; transition: 0.3s;
border-radius: 50%; border-radius: 50%;
} }
.toggle-switch input:checked + .toggle-slider { .toggle-switch input:checked + .toggle-slider {
background-color: var(--accent-cyan, #00d4ff); background-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, #00d4ff); border-color: var(--accent-cyan, #00d4ff);
} }
.toggle-switch input:checked + .toggle-slider:before { .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px); transform: translateX(20px);
background-color: white; background-color: white;
} }
.toggle-switch input:focus + .toggle-slider { .toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3); box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
} }
/* Select Dropdown */ /* Select Dropdown */
.settings-select { .settings-select {
background: var(--bg-tertiary, #1a1a2e); background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e); border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px; border-radius: 4px;
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
min-width: 160px; min-width: 160px;
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 8px center; background-position: right 8px center;
padding-right: 32px; padding-right: 32px;
} }
.settings-select:focus { .settings-select:focus {
outline: none; outline: none;
border-color: var(--accent-cyan, #00d4ff); border-color: var(--accent-cyan, #00d4ff);
} }
/* Text Input */ /* Text Input */
.settings-input { .settings-input {
background: var(--bg-tertiary, #1a1a2e); background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e); border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px; border-radius: 4px;
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
width: 200px; width: 200px;
} }
.settings-input:focus { .settings-input:focus {
outline: none; outline: none;
border-color: var(--accent-cyan, #00d4ff); border-color: var(--accent-cyan, #00d4ff);
} }
.settings-input::placeholder { .settings-input::placeholder {
color: var(--text-muted, #666); color: var(--text-muted, #666);
} }
/* Asset Status */ /* Asset Status */
.asset-status { .asset-status {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
padding: 12px; padding: 12px;
background: var(--bg-secondary, #0f0f1a); background: var(--bg-secondary, #0f0f1a);
border-radius: 6px; border-radius: 6px;
} }
.asset-status-row { .asset-status-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 12px; font-size: 12px;
} }
.asset-name { .asset-name {
color: var(--text-muted, #888); color: var(--text-muted, #888);
} }
.asset-badge { .asset-badge {
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
} }
.asset-badge.available { .asset-badge.available {
background: rgba(0, 255, 136, 0.15); background: rgba(0, 255, 136, 0.15);
color: var(--accent-green, #00ff88); color: var(--accent-green, #00ff88);
} }
.asset-badge.missing { .asset-badge.missing {
background: rgba(255, 68, 68, 0.15); background: rgba(255, 68, 68, 0.15);
color: var(--accent-red, #ff4444); color: var(--accent-red, #ff4444);
} }
.asset-badge.checking { .asset-badge.checking {
background: rgba(255, 170, 0, 0.15); background: rgba(255, 170, 0, 0.15);
color: var(--accent-orange, #ffaa00); color: var(--accent-orange, #ffaa00);
} }
/* Check Assets Button */ /* Check Assets Button */
.check-assets-btn { .check-assets-btn {
background: var(--bg-tertiary, #1a1a2e); background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e); border: 1px solid var(--border-color, #2a2a3e);
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
padding: 8px 16px; padding: 8px 16px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
margin-top: 12px; margin-top: 12px;
transition: all 0.2s; transition: all 0.2s;
} }
.check-assets-btn:hover { .check-assets-btn:hover {
border-color: var(--accent-cyan, #00d4ff); border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
} }
.check-assets-btn:disabled { .check-assets-btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* About Section */ /* GPS Detection Spinner */
.about-info { .detecting-spinner {
font-size: 13px; display: inline-block;
color: var(--text-muted, #888); width: 12px;
line-height: 1.6; height: 12px;
} border: 2px solid currentColor;
border-top-color: transparent;
.about-info p { border-radius: 50%;
margin: 0 0 12px 0; animation: detecting-spin 0.8s linear infinite;
} vertical-align: middle;
margin-right: 6px;
.about-info a { }
color: var(--accent-cyan, #00d4ff);
text-decoration: none; @keyframes detecting-spin {
} to { transform: rotate(360deg); }
}
.about-info a:hover {
text-decoration: underline; /* About Section */
} .about-info {
font-size: 13px;
.about-version { color: var(--text-muted, #888);
font-family: 'JetBrains Mono', monospace; line-height: 1.6;
color: var(--accent-cyan, #00d4ff); }
}
.about-info p {
/* Tile Provider Custom URL */ margin: 0 0 12px 0;
.custom-url-row { }
margin-top: 8px;
padding-top: 8px; .about-info a {
} color: var(--accent-cyan, #00d4ff);
text-decoration: none;
.custom-url-row .settings-input { }
width: 100%;
} .about-info a:hover {
text-decoration: underline;
/* Info Callout */ }
.settings-info {
background: rgba(0, 212, 255, 0.1); .about-version {
border: 1px solid rgba(0, 212, 255, 0.2); font-family: var(--font-mono);
border-radius: 6px; color: var(--accent-cyan, #00d4ff);
padding: 12px; }
margin-top: 16px;
font-size: 12px; /* Donate Button */
color: var(--text-muted, #888); .donate-btn {
} display: inline-flex;
align-items: center;
.settings-info strong { justify-content: center;
color: var(--accent-cyan, #00d4ff); padding: 10px 20px;
} background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none;
/* Responsive */ border-radius: 6px;
@media (max-width: 640px) { color: #000;
.settings-modal.active { font-size: 13px;
padding: 20px 10px; font-weight: 600;
} text-decoration: none;
cursor: pointer;
.settings-content { transition: all 0.2s ease;
max-width: 100%; box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
} }
.settings-row { .donate-btn:hover {
flex-direction: column; transform: translateY(-1px);
align-items: flex-start; box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
gap: 8px; filter: brightness(1.1);
} }
.settings-select, .donate-btn:active {
.settings-input { transform: translateY(0);
width: 100%; }
}
} /* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
padding-top: 8px;
}
.custom-url-row .settings-input {
width: 100%;
}
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
font-size: 12px;
color: var(--text-muted, #888);
}
.settings-info strong {
color: var(--accent-cyan, #00d4ff);
}
/* Responsive */
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
}
.settings-content {
max-width: 100%;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-select,
.settings-input {
width: 100%;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 KiB

After

Width:  |  Height:  |  Size: 694 KiB

@@ -0,0 +1,235 @@
/**
* Consumption Sparkline Component
* SVG-based visualization for meter consumption deltas
* Adapted from RSSISparkline pattern
*/
const ConsumptionSparkline = (function() {
'use strict';
// Default configuration
const DEFAULT_CONFIG = {
width: 100,
height: 28,
maxSamples: 20,
strokeWidth: 1.5,
showGradient: true,
barMode: true // Use bars instead of line for consumption
};
// Color thresholds for consumption deltas
// Green = normal/expected, Yellow = elevated, Red = spike
const DELTA_COLORS = {
normal: '#22c55e', // Green
elevated: '#eab308', // Yellow
spike: '#ef4444' // Red
};
/**
* Classify a delta value relative to the average
* @param {number} delta - The delta value
* @param {number} avgDelta - Average delta for comparison
* @returns {string} - 'normal', 'elevated', or 'spike'
*/
function classifyDelta(delta, avgDelta) {
if (avgDelta === 0 || isNaN(avgDelta)) {
return delta === 0 ? 'normal' : 'elevated';
}
const ratio = Math.abs(delta) / Math.abs(avgDelta);
if (ratio <= 1.5) return 'normal';
if (ratio <= 3) return 'elevated';
return 'spike';
}
/**
* Get color for a delta value
*/
function getDeltaColor(delta, avgDelta) {
const classification = classifyDelta(delta, avgDelta);
return DELTA_COLORS[classification];
}
/**
* Create sparkline SVG for consumption deltas
* @param {Array<{timestamp, delta}>} deltas - Array of delta objects
* @param {Object} config - Configuration options
* @returns {string} - SVG HTML string
*/
function createSparklineSvg(deltas, config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const { width, height, strokeWidth, showGradient, barMode } = cfg;
if (!deltas || deltas.length < 1) {
return createEmptySparkline(width, height);
}
// Extract just the delta values
const values = deltas.map(d => d.delta);
// Calculate statistics for color classification
const avgDelta = values.reduce((a, b) => a + b, 0) / values.length;
const maxDelta = Math.max(...values.map(Math.abs), 1);
if (barMode) {
return createBarSparkline(values, avgDelta, maxDelta, cfg);
}
return createLineSparkline(values, avgDelta, maxDelta, cfg);
}
/**
* Create bar-style sparkline (better for discrete readings)
*/
function createBarSparkline(values, avgDelta, maxDelta, cfg) {
const { width, height } = cfg;
const barCount = Math.min(values.length, cfg.maxSamples);
const displayValues = values.slice(-barCount);
const barWidth = Math.max(3, (width / barCount) - 1);
const barGap = 1;
let bars = '';
displayValues.forEach((val, i) => {
const normalizedHeight = (Math.abs(val) / maxDelta) * (height - 4);
const barHeight = Math.max(2, normalizedHeight);
const x = i * (barWidth + barGap);
const y = height - barHeight - 2;
const color = getDeltaColor(val, avgDelta);
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
width="${barWidth.toFixed(1)}" height="${barHeight.toFixed(1)}"
fill="${color}" rx="1" opacity="0.85"/>`;
});
return `
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<line x1="0" y1="${height - 2}" x2="${width}" y2="${height - 2}"
stroke="#333" stroke-width="1" opacity="0.3"/>
${bars}
</svg>
`;
}
/**
* Create line-style sparkline
*/
function createLineSparkline(values, avgDelta, maxDelta, cfg) {
const { width, height, strokeWidth, showGradient } = cfg;
const displayValues = values.slice(-cfg.maxSamples);
if (displayValues.length < 2) {
return createEmptySparkline(width, height);
}
// Normalize values to 0-1 range
const normalized = displayValues.map(v => Math.abs(v) / maxDelta);
// Calculate path
const stepX = width / (normalized.length - 1);
let pathD = '';
let areaD = '';
const points = [];
normalized.forEach((val, i) => {
const x = i * stepX;
const y = height - (val * (height - 4)) - 2;
points.push({ x, y, value: displayValues[i] });
if (i === 0) {
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
} else {
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
}
});
areaD += ` L${width},${height} Z`;
// Get color based on latest value
const latestValue = displayValues[displayValues.length - 1];
const strokeColor = getDeltaColor(latestValue, avgDelta);
const gradientId = `consumption-gradient-${Math.random().toString(36).substr(2, 9)}`;
let gradientDef = '';
if (showGradient) {
gradientDef = `
<defs>
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
</linearGradient>
</defs>
`;
}
return `
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${gradientDef}
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
stroke-linecap="round" stroke-linejoin="round" />
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
r="2.5" fill="${strokeColor}" class="sparkline-dot" />
</svg>
`;
}
/**
* Create empty sparkline placeholder
*/
function createEmptySparkline(width, height) {
return `
<svg class="consumption-sparkline-svg consumption-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
stroke="#444" stroke-width="1" stroke-dasharray="3,3" />
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
fill="#555" font-size="9" font-family="monospace">Collecting...</text>
</svg>
`;
}
/**
* Create sparkline with summary stats
* @param {Array} deltas - Delta history
* @param {Object} options - Display options
* @returns {string} - HTML string
*/
function createSparklineWithStats(deltas, options = {}) {
const svg = createSparklineSvg(deltas, options);
if (!deltas || deltas.length < 2) {
return `<div class="consumption-sparkline-wrapper">${svg}</div>`;
}
// Calculate trend
const recentDeltas = deltas.slice(-5);
const avgRecent = recentDeltas.reduce((a, d) => a + d.delta, 0) / recentDeltas.length;
const trend = avgRecent > 0 ? 'up' : avgRecent < 0 ? 'down' : 'stable';
const trendIcon = trend === 'up' ? '&#8593;' : trend === 'down' ? '&#8595;' : '&#8596;';
const trendColor = trend === 'up' ? '#22c55e' : trend === 'down' ? '#ef4444' : '#888';
return `
<div class="consumption-sparkline-wrapper">
${svg}
<span class="consumption-trend" style="color: ${trendColor}" title="Recent trend">
${trendIcon}
</span>
</div>
`;
}
// Public API
return {
createSparklineSvg,
createEmptySparkline,
createSparklineWithStats,
classifyDelta,
getDeltaColor,
DEFAULT_CONFIG,
DELTA_COLORS
};
})();
// Make globally available
window.ConsumptionSparkline = ConsumptionSparkline;
+278
View File
@@ -0,0 +1,278 @@
/**
* Meter Aggregator Component
* Client-side aggregation for rtlamr meter readings
* Groups readings by meter ID and tracks consumption history
*/
const MeterAggregator = (function() {
'use strict';
// Configuration
const CONFIG = {
maxHistoryAge: 60 * 60 * 1000, // 60 minutes
maxHistoryLength: 50, // Max readings to keep per meter
rateWindowMs: 30 * 60 * 1000 // 30 minutes for rate calculation
};
// Storage for aggregated meters
// Map<meterId, MeterData>
const meters = new Map();
/**
* MeterData structure:
* {
* id: string,
* type: string,
* utility: string,
* manufacturer: string,
* firstSeen: number (timestamp),
* lastSeen: number (timestamp),
* readingCount: number,
* latestReading: object (full reading data),
* history: Array<{timestamp, consumption, raw}>,
* delta: number | null (change from previous reading),
* rate: number | null (units per hour)
* }
*/
/**
* Ingest a new meter reading
* @param {Object} data - The raw meter reading data
* @returns {Object} - { meter: MeterData, isNew: boolean }
*/
function ingest(data) {
const msgData = data.Message || {};
const meterId = String(msgData.ID || data.id || 'Unknown');
const timestamp = Date.now();
const consumption = msgData.Consumption !== undefined ? msgData.Consumption : data.consumption;
// Get meter type info if available
const meterInfo = typeof getMeterTypeInfo === 'function'
? getMeterTypeInfo(msgData.EndpointType, data.Type)
: { utility: 'Unknown', manufacturer: 'Unknown' };
const existing = meters.get(meterId);
const isNew = !existing;
if (isNew) {
// Create new meter entry
const meter = {
id: meterId,
type: data.Type || 'Unknown',
utility: meterInfo.utility,
manufacturer: meterInfo.manufacturer,
firstSeen: timestamp,
lastSeen: timestamp,
readingCount: 1,
latestReading: data,
history: [{
timestamp: timestamp,
consumption: consumption,
raw: data
}],
delta: null,
rate: null
};
meters.set(meterId, meter);
return { meter, isNew: true };
}
// Update existing meter
const previousConsumption = existing.history.length > 0
? existing.history[existing.history.length - 1].consumption
: null;
// Add to history
existing.history.push({
timestamp: timestamp,
consumption: consumption,
raw: data
});
// Prune old history
pruneHistory(existing);
// Calculate delta (change from previous reading)
if (previousConsumption !== null && consumption !== undefined && consumption !== null) {
existing.delta = consumption - previousConsumption;
} else {
existing.delta = null;
}
// Calculate rate (units per hour)
existing.rate = calculateRate(existing);
// Update meter data
existing.lastSeen = timestamp;
existing.readingCount++;
existing.latestReading = data;
existing.type = data.Type || existing.type;
if (meterInfo.utility !== 'Unknown') existing.utility = meterInfo.utility;
if (meterInfo.manufacturer !== 'Unknown') existing.manufacturer = meterInfo.manufacturer;
return { meter: existing, isNew: false };
}
/**
* Prune history older than maxHistoryAge and beyond maxHistoryLength
*/
function pruneHistory(meter) {
const cutoff = Date.now() - CONFIG.maxHistoryAge;
// Remove old entries
meter.history = meter.history.filter(h => h.timestamp >= cutoff);
// Limit length
if (meter.history.length > CONFIG.maxHistoryLength) {
meter.history = meter.history.slice(-CONFIG.maxHistoryLength);
}
}
/**
* Calculate consumption rate over the rate window
* @returns {number|null} Units per hour, or null if insufficient data
*/
function calculateRate(meter) {
if (meter.history.length < 2) return null;
const now = Date.now();
const windowStart = now - CONFIG.rateWindowMs;
// Find readings within the rate window
const recentHistory = meter.history.filter(h => h.timestamp >= windowStart);
if (recentHistory.length < 2) return null;
const oldest = recentHistory[0];
const newest = recentHistory[recentHistory.length - 1];
// Need both to have valid consumption values
if (oldest.consumption === undefined || oldest.consumption === null ||
newest.consumption === undefined || newest.consumption === null) {
return null;
}
const consumptionDiff = newest.consumption - oldest.consumption;
const timeDiffHours = (newest.timestamp - oldest.timestamp) / (1000 * 60 * 60);
if (timeDiffHours <= 0) return null;
return consumptionDiff / timeDiffHours;
}
/**
* Get consumption deltas for sparkline display
* @returns {Array<{timestamp, delta}>}
*/
function getConsumptionDeltas(meter) {
const deltas = [];
for (let i = 1; i < meter.history.length; i++) {
const prev = meter.history[i - 1];
const curr = meter.history[i];
if (prev.consumption !== undefined && prev.consumption !== null &&
curr.consumption !== undefined && curr.consumption !== null) {
deltas.push({
timestamp: curr.timestamp,
delta: curr.consumption - prev.consumption
});
}
}
return deltas;
}
/**
* Get a meter by ID
* @param {string} id
* @returns {Object|null}
*/
function getMeter(id) {
return meters.get(String(id)) || null;
}
/**
* Get all meters
* @returns {Array<Object>}
*/
function getAllMeters() {
return Array.from(meters.values());
}
/**
* Get meter count
* @returns {number}
*/
function getCount() {
return meters.size;
}
/**
* Clear all aggregated data
*/
function clear() {
meters.clear();
}
/**
* Get time since last reading for a meter
* @param {Object} meter
* @returns {string}
*/
function getTimeSinceLastReading(meter) {
const diff = Date.now() - meter.lastSeen;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'Just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
const hours = Math.floor(minutes / 60);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
/**
* Format rate for display
* @param {number|null} rate
* @returns {string}
*/
function formatRate(rate) {
if (rate === null || rate === undefined || isNaN(rate)) {
return '--';
}
// Format based on magnitude
const absRate = Math.abs(rate);
if (absRate >= 100) {
return rate.toFixed(0) + '/hr';
} else if (absRate >= 1) {
return rate.toFixed(1) + '/hr';
} else {
return rate.toFixed(2) + '/hr';
}
}
/**
* Format delta for display
* @param {number|null} delta
* @returns {string}
*/
function formatDelta(delta) {
if (delta === null || delta === undefined || isNaN(delta)) {
return '--';
}
const sign = delta >= 0 ? '+' : '';
return sign + delta.toLocaleString();
}
// Public API
return {
ingest,
getMeter,
getAllMeters,
getCount,
clear,
getConsumptionDeltas,
getTimeSinceLastReading,
formatRate,
formatDelta,
CONFIG
};
})();
// Make globally available
window.MeterAggregator = MeterAggregator;
+5
View File
@@ -207,9 +207,14 @@ const ProximityRadar = (function() {
const pulseClass = isNew ? 'radar-dot-pulse' : ''; const pulseClass = isNew ? 'radar-dot-pulse' : '';
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey; const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
// Hit area size (prevents hover flicker when scaling)
const hitAreaSize = Math.max(dotSize * 2, 15);
return ` return `
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}" <g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
transform="translate(${x}, ${y})" style="cursor: pointer;"> transform="translate(${x}, ${y})" style="cursor: pointer;">
<!-- Invisible hit area to prevent hover flicker -->
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8"> ${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
+328 -5
View File
@@ -995,6 +995,24 @@ const SignalCards = (function() {
let html = ''; let html = '';
const rawMessage = msg.rawMessage || {}; const rawMessage = msg.rawMessage || {};
// Add device intelligence info at the top
if (msg.utility && msg.utility !== 'Unknown') {
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Utility Type</span>
<span class="signal-advanced-value">${escapeHtml(msg.utility)}</span>
</div>
`;
}
if (msg.manufacturer && msg.manufacturer !== 'Unknown') {
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Manufacturer</span>
<span class="signal-advanced-value">${escapeHtml(msg.manufacturer)}</span>
</div>
`;
}
// Display all fields from the raw rtlamr message // Display all fields from the raw rtlamr message
for (const [key, value] of Object.entries(rawMessage)) { for (const [key, value] of Object.entries(rawMessage)) {
if (value === null || value === undefined) continue; if (value === null || value === undefined) continue;
@@ -1066,19 +1084,24 @@ const SignalCards = (function() {
const stats = getAddressStats('meter', msg.id); const stats = getAddressStats('meter', msg.id);
const seenCount = stats ? stats.count : 1; const seenCount = stats ? stats.count : 1;
// Determine meter type color // Determine meter type color based on utility type
let meterTypeClass = 'electric'; let meterTypeClass = 'electric';
const utility = (msg.utility || '').toLowerCase();
const meterType = (msg.type || '').toLowerCase(); const meterType = (msg.type || '').toLowerCase();
if (meterType.includes('gas')) { if (utility === 'gas' || meterType.includes('gas')) {
meterTypeClass = 'gas'; meterTypeClass = 'gas';
} else if (meterType.includes('water')) { } else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
meterTypeClass = 'water'; meterTypeClass = 'water';
} }
// Format utility display
const utilityDisplay = msg.utility && msg.utility !== 'Unknown' ? msg.utility : null;
const manufacturerDisplay = msg.manufacturer && msg.manufacturer !== 'Unknown' ? msg.manufacturer : null;
card.innerHTML = ` card.innerHTML = `
<div class="signal-card-header"> <div class="signal-card-header">
<div class="signal-card-badges"> <div class="signal-card-badges">
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(msg.type || 'Meter')}</span> <span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(utilityDisplay || msg.type || 'Meter')}</span>
<span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span> <span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span>
</div> </div>
${status !== 'baseline' ? ` ${status !== 'baseline' ? `
@@ -1090,7 +1113,8 @@ const SignalCards = (function() {
</div> </div>
<div class="signal-card-body"> <div class="signal-card-body">
<div class="signal-meta-row"> <div class="signal-meta-row">
${msg.endpoint_type ? `<span class="signal-msg-type">${escapeHtml(msg.endpoint_type)}</span>` : ''} ${manufacturerDisplay ? `<span class="signal-msg-type">${escapeHtml(manufacturerDisplay)}</span>` : ''}
${msg.type ? `<span class="signal-msg-type" style="opacity: 0.7">${escapeHtml(msg.type)}</span>` : ''}
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''} ${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span> <span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div> </div>
@@ -1131,6 +1155,303 @@ const SignalCards = (function() {
return card; return card;
} }
/**
* Create an aggregated utility meter card (grouped by meter ID)
* Shows consumption history, sparkline, delta, and rate
* @param {Object} meter - Aggregated meter data from MeterAggregator
* @param {Object} options - Optional configuration
* @returns {HTMLElement}
*/
function createAggregatedMeterCard(meter, options = {}) {
const status = meter.readingCount === 1 ? 'new' : 'baseline';
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
const card = document.createElement('article');
card.className = 'signal-card meter-aggregated';
card.dataset.status = status;
card.dataset.type = 'meter';
card.dataset.protocol = meter.type || 'unknown';
card.dataset.meterId = meter.id;
card.id = 'metercard_' + meter.id;
// Determine meter type color
let meterTypeClass = 'electric';
const utility = (meter.utility || '').toLowerCase();
const meterType = (meter.type || '').toLowerCase();
if (utility === 'gas' || meterType.includes('gas')) {
meterTypeClass = 'gas';
} else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
meterTypeClass = 'water';
}
// Format utility display
const utilityDisplay = meter.utility && meter.utility !== 'Unknown' ? meter.utility : null;
const manufacturerDisplay = meter.manufacturer && meter.manufacturer !== 'Unknown' ? meter.manufacturer : null;
// Get consumption deltas for sparkline
const deltas = typeof MeterAggregator !== 'undefined'
? MeterAggregator.getConsumptionDeltas(meter)
: [];
// Create sparkline
const sparklineHtml = typeof ConsumptionSparkline !== 'undefined'
? ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 })
: '<span class="meter-sparkline-placeholder">--</span>';
// Format delta and rate
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
const rateFormatted = MeterAggregator.formatRate(meter.rate);
const deltaClass = meter.delta === null ? '' : (meter.delta >= 0 ? 'positive' : 'negative');
// Get latest consumption
const latestConsumption = meter.history.length > 0
? meter.history[meter.history.length - 1].consumption
: null;
card.innerHTML = `
<div class="signal-card-header">
<div class="signal-card-badges">
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(utilityDisplay || meter.type || 'Meter')}</span>
<span class="signal-freq-badge">ID: ${escapeHtml(meter.id || 'N/A')}</span>
${meter.readingCount > 1 ? `<span class="signal-seen-count">&times;${meter.readingCount}</span>` : ''}
</div>
${status === 'new' ? `
<span class="signal-status-pill" data-status="new">
<span class="status-dot"></span>
New
</span>
` : ''}
</div>
<div class="signal-card-body">
<div class="signal-meta-row">
${manufacturerDisplay ? `<span class="signal-msg-type">${escapeHtml(manufacturerDisplay)}</span>` : ''}
${meter.type ? `<span class="signal-msg-type" style="opacity: 0.7">${escapeHtml(meter.type)}</span>` : ''}
<span class="signal-timestamp meter-last-seen" data-timestamp="${meter.lastSeen}">${escapeHtml(relativeTime)}</span>
</div>
<div class="meter-aggregated-grid">
<div class="meter-aggregated-col consumption-col">
<span class="meter-aggregated-label">Consumption</span>
<span class="meter-aggregated-value consumption-value">${latestConsumption !== null ? latestConsumption.toLocaleString() : '--'}</span>
<span class="meter-delta ${deltaClass}" title="Change from previous reading">${deltaFormatted}</span>
</div>
<div class="meter-aggregated-col trend-col">
<span class="meter-aggregated-label">Trend</span>
<div class="meter-sparkline-container">
${sparklineHtml}
</div>
</div>
<div class="meter-aggregated-col rate-col">
<span class="meter-aggregated-label">Rate</span>
<span class="meter-rate-value">${rateFormatted}</span>
</div>
</div>
</div>
<div class="signal-card-footer">
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Details
</button>
<div class="signal-card-actions">
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(meter.id)}')">Mute</button>
</div>
</div>
<div class="signal-advanced-panel">
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
<div class="signal-advanced-section">
<div class="signal-advanced-title">Meter Details</div>
<div class="signal-advanced-grid">
${buildAggregatedMeterDetailsHtml(meter)}
</div>
</div>
</div>
</div>
</div>
`;
return card;
}
/**
* Update an existing aggregated meter card in place
* @param {HTMLElement} card - The card element to update
* @param {Object} meter - Updated meter data from MeterAggregator
*/
function updateAggregatedMeterCard(card, meter) {
if (!card || !meter) return;
// Update timestamp
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
const timestampEl = card.querySelector('.meter-last-seen');
if (timestampEl) {
timestampEl.dataset.timestamp = meter.lastSeen;
timestampEl.textContent = relativeTime;
}
// Update seen count badge
const seenCountEl = card.querySelector('.signal-seen-count');
if (seenCountEl) {
seenCountEl.innerHTML = `&times;${meter.readingCount}`;
} else if (meter.readingCount > 1) {
// Add seen count if it doesn't exist
const badges = card.querySelector('.signal-card-badges');
if (badges) {
const countSpan = document.createElement('span');
countSpan.className = 'signal-seen-count';
countSpan.innerHTML = `&times;${meter.readingCount}`;
badges.appendChild(countSpan);
}
}
// Remove "new" status pill after first update
if (meter.readingCount > 1) {
card.dataset.status = 'baseline';
const statusPill = card.querySelector('.signal-status-pill[data-status="new"]');
if (statusPill) {
statusPill.remove();
}
}
// Update consumption value
const latestConsumption = meter.history.length > 0
? meter.history[meter.history.length - 1].consumption
: null;
const consumptionEl = card.querySelector('.consumption-value');
if (consumptionEl) {
consumptionEl.textContent = latestConsumption !== null ? latestConsumption.toLocaleString() : '--';
}
// Update delta
const deltaEl = card.querySelector('.meter-delta');
if (deltaEl) {
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
deltaEl.textContent = deltaFormatted;
deltaEl.classList.remove('positive', 'negative');
if (meter.delta !== null) {
deltaEl.classList.add(meter.delta >= 0 ? 'positive' : 'negative');
}
}
// Update sparkline
const sparklineContainer = card.querySelector('.meter-sparkline-container');
if (sparklineContainer && typeof ConsumptionSparkline !== 'undefined') {
const deltas = MeterAggregator.getConsumptionDeltas(meter);
sparklineContainer.innerHTML = ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 });
}
// Update rate
const rateEl = card.querySelector('.meter-rate-value');
if (rateEl) {
rateEl.textContent = MeterAggregator.formatRate(meter.rate);
}
// Update details panel
const detailsGrid = card.querySelector('.signal-advanced-grid');
if (detailsGrid) {
detailsGrid.innerHTML = buildAggregatedMeterDetailsHtml(meter);
}
// Add subtle update animation
card.classList.add('meter-updated');
setTimeout(() => card.classList.remove('meter-updated'), 300);
}
/**
* Build HTML for aggregated meter detail fields
* @param {Object} meter - Aggregated meter data
* @returns {string} - HTML string
*/
function buildAggregatedMeterDetailsHtml(meter) {
let html = '';
const latestReading = meter.latestReading || {};
const rawMessage = latestReading.Message || {};
// Add device intelligence info at the top
if (meter.utility && meter.utility !== 'Unknown') {
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Utility Type</span>
<span class="signal-advanced-value">${escapeHtml(meter.utility)}</span>
</div>
`;
}
if (meter.manufacturer && meter.manufacturer !== 'Unknown') {
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Manufacturer</span>
<span class="signal-advanced-value">${escapeHtml(meter.manufacturer)}</span>
</div>
`;
}
// Add aggregation stats
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Total Readings</span>
<span class="signal-advanced-value">${meter.readingCount}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">First Seen</span>
<span class="signal-advanced-value">${new Date(meter.firstSeen).toLocaleTimeString()}</span>
</div>
`;
// Add rate info if available
if (meter.rate !== null) {
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Consumption Rate</span>
<span class="signal-advanced-value">${MeterAggregator.formatRate(meter.rate)}</span>
</div>
`;
}
// Display fields from the raw rtlamr message
for (const [key, value] of Object.entries(rawMessage)) {
if (value === null || value === undefined) continue;
// Format the label
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
// Format the value
let displayValue;
if (Array.isArray(value)) {
if (value.length > 10) {
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
} else {
displayValue = value.join(', ');
}
} else if (typeof value === 'object') {
displayValue = JSON.stringify(value);
} else if (key === 'Consumption') {
displayValue = `${value.toLocaleString()} units`;
} else {
displayValue = String(value);
}
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">${escapeHtml(label)}</span>
<span class="signal-advanced-value">${escapeHtml(displayValue)}</span>
</div>
`;
}
// Add message type if not in raw message
if (!rawMessage.Type && meter.type) {
html += `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Message Type</span>
<span class="signal-advanced-value">${escapeHtml(meter.type)}</span>
</div>
`;
}
return html;
}
/** /**
* Toggle advanced panel on a card * Toggle advanced panel on a card
*/ */
@@ -1922,6 +2243,8 @@ const SignalCards = (function() {
createSensorCard, createSensorCard,
createAcarsCard, createAcarsCard,
createMeterCard, createMeterCard,
createAggregatedMeterCard,
updateAggregatedMeterCard,
// Signal classification // Signal classification
SignalClassification, SignalClassification,
+507 -504
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
(() => {
const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
if (!dropdowns.length) return;
const closeAll = () => {
dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
};
const openDropdown = (dropdown) => {
if (!dropdown.classList.contains('open')) {
closeAll();
dropdown.classList.add('open');
}
};
document.addEventListener('click', (event) => {
const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
if (menuLink) {
event.preventDefault();
event.stopPropagation();
window.location.href = menuLink.href;
return;
}
const button = event.target.closest('.mode-nav-dropdown-btn');
if (button) {
event.preventDefault();
const dropdown = button.closest('.mode-nav-dropdown');
if (!dropdown) return;
if (dropdown.classList.contains('open')) {
dropdown.classList.remove('open');
} else {
openDropdown(dropdown);
}
return;
}
if (!event.target.closest('.mode-nav-dropdown')) {
closeAll();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAll();
}
});
})();
+103
View File
@@ -0,0 +1,103 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() {
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
const LEGACY_LON_KEY = 'observerLon';
function isSharedEnabled() {
return window.INTERCEPT_SHARED_OBSERVER_LOCATION !== false;
}
function normalize(lat, lon) {
const latNum = parseFloat(lat);
const lonNum = parseFloat(lon);
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null;
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null;
return { lat: latNum, lon: lonNum };
}
function parseLocation(raw) {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.lat !== undefined && parsed.lon !== undefined) {
return normalize(parsed.lat, parsed.lon);
}
} catch (e) {}
return null;
}
function readKey(key) {
return parseLocation(localStorage.getItem(key));
}
function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null;
return normalize(lat, lon);
}
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
const legacy = readKey(AIS_KEY) || readLegacyLatLon();
if (legacy) {
setShared(legacy);
return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setShared(location, options = {}) {
if (!location) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
function getForModule(moduleKey, options = {}) {
if (isSharedEnabled()) {
return getShared();
}
if (moduleKey) {
const moduleLocation = readKey(moduleKey);
if (moduleLocation) return moduleLocation;
}
if (options.fallbackToLatLon) {
const legacy = readLegacyLatLon();
if (legacy) return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setForModule(moduleKey, location, options = {}) {
if (!location) return;
if (isSharedEnabled()) {
setShared(location, options);
return;
}
if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location));
} else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
return {
isSharedEnabled,
getShared,
setShared,
getForModule,
setForModule,
normalize,
DEFAULT_LOCATION: { ...DEFAULT_LOCATION }
};
})();
File diff suppressed because it is too large Load Diff
+498
View File
@@ -0,0 +1,498 @@
/**
* Updater Module - GitHub update checking and notification system
*/
const Updater = {
// State
_checkInterval: null,
_toastElement: null,
_modalElement: null,
_updateData: null,
// Configuration
CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
/**
* Initialize the updater module
*/
init() {
// Create toast container if it doesn't exist
this._ensureToastContainer();
// Check for updates on page load
this.checkForUpdates();
// Set up periodic checks
this._checkInterval = setInterval(() => {
this.checkForUpdates();
}, this.CHECK_INTERVAL_MS);
},
/**
* Ensure toast container exists in DOM
*/
_ensureToastContainer() {
if (!document.getElementById('toastContainer')) {
const container = document.createElement('div');
container.id = 'toastContainer';
document.body.appendChild(container);
}
},
/**
* Check for updates from the server
* @param {boolean} force - Bypass cache and check GitHub directly
*/
async checkForUpdates(force = false) {
try {
const url = force ? '/updater/check?force=true' : '/updater/check';
const response = await fetch(url);
const data = await response.json();
if (data.success && data.show_notification) {
this._updateData = data;
this.showUpdateToast(data);
}
return data;
} catch (error) {
console.warn('Failed to check for updates:', error);
return { success: false, error: error.message };
}
},
/**
* Get cached update status without triggering a check
*/
async getStatus() {
try {
const response = await fetch('/updater/status');
return await response.json();
} catch (error) {
console.warn('Failed to get update status:', error);
return { success: false, error: error.message };
}
},
/**
* Show update toast notification
* @param {Object} data - Update data from server
*/
showUpdateToast(data) {
// Remove existing toast if present
this.hideToast();
const toast = document.createElement('div');
toast.className = 'update-toast';
toast.innerHTML = `
<div class="update-toast-indicator"></div>
<div class="update-toast-content">
<div class="update-toast-header">
<span class="update-toast-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</span>
<span class="update-toast-title">Update Available</span>
<button class="update-toast-close" onclick="Updater.dismissUpdate()">&times;</button>
</div>
<div class="update-toast-body">
Version <strong>${data.latest_version}</strong> is ready
</div>
<div class="update-toast-actions">
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
View Details
</button>
<button class="update-toast-btn update-toast-btn-secondary" onclick="Updater.hideToast()">
Later
</button>
</div>
</div>
`;
const container = document.getElementById('toastContainer');
if (container) {
container.appendChild(toast);
} else {
document.body.appendChild(toast);
}
this._toastElement = toast;
// Trigger animation
requestAnimationFrame(() => {
toast.classList.add('show');
});
},
/**
* Hide the update toast
*/
hideToast() {
if (this._toastElement) {
this._toastElement.classList.remove('show');
setTimeout(() => {
if (this._toastElement && this._toastElement.parentNode) {
this._toastElement.parentNode.removeChild(this._toastElement);
}
this._toastElement = null;
}, 300);
}
},
/**
* Dismiss update notification for this version
*/
async dismissUpdate() {
this.hideToast();
if (this._updateData && this._updateData.latest_version) {
try {
await fetch('/updater/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: this._updateData.latest_version })
});
} catch (error) {
console.warn('Failed to dismiss update:', error);
}
}
},
/**
* Show the full update modal with details
*/
showUpdateModal() {
this.hideToast();
if (!this._updateData) {
console.warn('No update data available');
return;
}
// Remove existing modal if present
this.hideModal();
const data = this._updateData;
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
const modal = document.createElement('div');
modal.className = 'update-modal-overlay';
modal.onclick = (e) => {
if (e.target === modal) this.hideModal();
};
modal.innerHTML = `
<div class="update-modal">
<div class="update-modal-header">
<div class="update-modal-title">
<span class="update-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</span>
Update Available
</div>
<button class="update-modal-close" onclick="Updater.hideModal()">&times;</button>
</div>
<div class="update-modal-body">
<div class="update-version-info">
<div class="update-version-current">
<span class="update-version-label">Current</span>
<span class="update-version-value">v${data.current_version}</span>
</div>
<div class="update-version-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</div>
<div class="update-version-latest">
<span class="update-version-label">Latest</span>
<span class="update-version-value update-version-new">v${data.latest_version}</span>
</div>
</div>
<div class="update-section">
<div class="update-section-title">Release Notes</div>
<div class="update-release-notes">${releaseNotes}</div>
</div>
<div class="update-warning" id="updateWarning" style="display: none;">
<div class="update-warning-icon">
<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>
</div>
<div class="update-warning-text">
<strong>Local changes detected</strong>
<p id="updateWarningText"></p>
</div>
</div>
<div class="update-options" id="updateOptions" style="display: none;">
<label class="update-option">
<input type="checkbox" id="stashChanges">
<span>Stash local changes before updating</span>
</label>
</div>
<div class="update-progress" id="updateProgress" style="display: none;">
<div class="update-progress-spinner"></div>
<span id="updateProgressText">Updating...</span>
</div>
<div class="update-result" id="updateResult" style="display: none;"></div>
</div>
<div class="update-modal-footer">
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
View on GitHub
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<div class="update-modal-actions">
<button class="update-modal-btn update-modal-btn-secondary" onclick="Updater.hideModal()">
Cancel
</button>
<button class="update-modal-btn update-modal-btn-primary" id="updateNowBtn" onclick="Updater.performUpdate()">
Update Now
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
this._modalElement = modal;
// Trigger animation
requestAnimationFrame(() => {
modal.classList.add('show');
});
},
/**
* Hide the update modal
*/
hideModal() {
if (this._modalElement) {
this._modalElement.classList.remove('show');
setTimeout(() => {
if (this._modalElement && this._modalElement.parentNode) {
this._modalElement.parentNode.removeChild(this._modalElement);
}
this._modalElement = null;
}, 200);
}
},
/**
* Perform the update
*/
async performUpdate() {
const progressEl = document.getElementById('updateProgress');
const progressText = document.getElementById('updateProgressText');
const resultEl = document.getElementById('updateResult');
const updateBtn = document.getElementById('updateNowBtn');
const warningEl = document.getElementById('updateWarning');
const optionsEl = document.getElementById('updateOptions');
const stashCheckbox = document.getElementById('stashChanges');
// Show progress
if (progressEl) progressEl.style.display = 'flex';
if (progressText) progressText.textContent = 'Checking repository status...';
if (updateBtn) updateBtn.disabled = true;
if (resultEl) resultEl.style.display = 'none';
try {
const stashChanges = stashCheckbox ? stashCheckbox.checked : false;
if (progressText) progressText.textContent = 'Fetching and applying updates...';
const response = await fetch('/updater/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stash_changes: stashChanges })
});
const data = await response.json();
if (progressEl) progressEl.style.display = 'none';
if (data.success) {
this._showResult(resultEl, true, data);
} else {
// Handle specific error cases
if (data.error === 'local_changes') {
if (warningEl) {
warningEl.style.display = 'flex';
const warningText = document.getElementById('updateWarningText');
if (warningText) {
warningText.textContent = data.message;
}
}
if (optionsEl) optionsEl.style.display = 'block';
if (updateBtn) updateBtn.disabled = false;
} else if (data.manual_update) {
this._showResult(resultEl, false, data, true);
} else {
this._showResult(resultEl, false, data);
}
}
} catch (error) {
if (progressEl) progressEl.style.display = 'none';
this._showResult(resultEl, false, { error: error.message });
}
},
/**
* Show update result
*/
_showResult(resultEl, success, data, isManual = false) {
if (!resultEl) return;
resultEl.style.display = 'block';
if (success) {
if (data.updated) {
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
if (data.requirements_changed) {
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
}
resultEl.className = 'update-result update-result-success';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="update-result-text">${message}</div>
`;
} else {
resultEl.className = 'update-result update-result-info';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</div>
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
`;
}
} else {
if (isManual) {
resultEl.className = 'update-result update-result-warning';
resultEl.innerHTML = `
<div class="update-result-icon">
<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>
</div>
<div class="update-result-text">
<strong>Manual update required</strong><br>
${data.message || 'Please download the latest release from GitHub.'}
</div>
`;
} else {
resultEl.className = 'update-result update-result-error';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<div class="update-result-text">
<strong>Update failed</strong><br>
${data.message || data.error || 'An error occurred during the update.'}
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
</div>
`;
}
}
},
/**
* Format release notes (basic markdown to HTML)
*/
_formatReleaseNotes(notes) {
if (!notes) return '<p>No release notes available.</p>';
// Escape HTML
let html = notes
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Convert markdown-style formatting
html = html
// Headers
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Code
.replace(/`(.+?)`/g, '<code>$1</code>')
// Lists
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
// Line breaks
.replace(/\n/g, '<br>');
// Wrap list items
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
return '<p>' + html + '</p>';
},
/**
* Manual trigger for settings panel
*/
async checkNow() {
return await this.checkForUpdates(true);
},
/**
* Clean up on page unload
*/
destroy() {
if (this._checkInterval) {
clearInterval(this._checkInterval);
this._checkInterval = null;
}
this.hideToast();
this.hideModal();
}
};
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
Updater.init();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
Updater.destroy();
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -84,7 +84,7 @@ const SpyStations = (function() {
modeContainer.innerHTML = modes.map(m => ` modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox"> <label class="inline-checkbox">
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()"> <input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span> <span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
</label> </label>
`).join(''); `).join('');
} }
+977
View File
@@ -0,0 +1,977 @@
/**
* SSTV Mode
* ISS Slow-Scan Television decoder interface
*/
const SSTV = (function() {
// State
let isRunning = false;
let eventSource = null;
let images = [];
let currentMode = null;
let progress = 0;
let issMap = null;
let issMarker = null;
let issTrackLine = null;
let issPosition = null;
let issUpdateInterval = null;
let countdownInterval = null;
let nextPassData = null;
// ISS frequency
const ISS_FREQ = 145.800;
/**
* Initialize the SSTV mode
*/
function init() {
checkStatus();
loadImages();
loadLocationInputs();
loadIssSchedule();
initMap();
startIssTracking();
startCountdown();
}
/**
* Load location into input fields
*/
function loadLocationInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
let storedLat = localStorage.getItem('observerLat');
let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
// Add change handlers to save and refresh
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
}
/**
* Save location from input fields
*/
function saveLocationFromInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
const lat = parseFloat(latInput?.value);
const lon = parseFloat(lonInput?.value);
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
loadIssSchedule(); // Refresh pass predictions
}
}
/**
* Use GPS to get location
*/
function useGPS(btn) {
if (!navigator.geolocation) {
showNotification('SSTV', 'GPS not available in this browser');
return;
}
const originalText = btn.innerHTML;
btn.innerHTML = '<span style="opacity: 0.7;">...</span>';
btn.disabled = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
const lat = pos.coords.latitude.toFixed(4);
const lon = pos.coords.longitude.toFixed(4);
if (latInput) latInput.value = lat;
if (lonInput) lonInput.value = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
btn.innerHTML = originalText;
btn.disabled = false;
showNotification('SSTV', 'Location updated from GPS');
loadIssSchedule();
},
(err) => {
btn.innerHTML = originalText;
btn.disabled = false;
let msg = 'Failed to get location';
if (err.code === 1) msg = 'Location access denied';
else if (err.code === 2) msg = 'Location unavailable';
showNotification('SSTV', msg);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
/**
* Update TLE data from CelesTrak
*/
async function updateTLE(btn) {
const originalText = btn.innerHTML;
btn.innerHTML = '<span style="opacity: 0.7;">Updating...</span>';
btn.disabled = true;
try {
const response = await fetch('/satellite/update-tle', { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
showNotification('SSTV', `TLE updated: ${data.updated?.length || 0} satellites`);
loadIssSchedule(); // Refresh predictions with new TLE
} else {
showNotification('SSTV', data.message || 'TLE update failed');
}
} catch (err) {
console.error('TLE update error:', err);
showNotification('SSTV', 'Failed to update TLE');
}
btn.innerHTML = originalText;
btn.disabled = false;
}
/**
* Initialize Leaflet map for ISS tracking
*/
async function initMap() {
const mapContainer = document.getElementById('sstvIssMap');
if (!mapContainer || issMap) return;
// Create map
issMap = L.map('sstvIssMap', {
center: [0, 0],
zoom: 1,
minZoom: 1,
maxZoom: 6,
zoomControl: true,
attributionControl: false,
worldCopyJump: true
});
window.issMap = issMap;
// Add tile layer using settings manager if available
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
Settings.createTileLayer().addTo(issMap);
Settings.registerMap(issMap);
} else {
// Fallback to dark theme tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19
}).addTo(issMap);
}
// Create ISS icon
const issIcon = L.divIcon({
className: 'sstv-iss-marker',
html: `<div class="sstv-iss-dot"></div><div class="sstv-iss-label">ISS</div>`,
iconSize: [40, 40],
iconAnchor: [20, 20]
});
// Create ISS marker (will be positioned when we get data)
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
// Create ground track line
issTrackLine = L.polyline([], {
color: '#00d4ff',
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
}).addTo(issMap);
}
/**
* Start ISS position tracking
*/
function startIssTracking() {
updateIssPosition();
// Update every 5 seconds
if (issUpdateInterval) clearInterval(issUpdateInterval);
issUpdateInterval = setInterval(updateIssPosition, 5000);
}
/**
* Stop ISS tracking
*/
function stopIssTracking() {
if (issUpdateInterval) {
clearInterval(issUpdateInterval);
issUpdateInterval = null;
}
}
/**
* Start countdown timer
*/
function startCountdown() {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(updateCountdown, 1000);
updateCountdown();
}
/**
* Stop countdown timer
*/
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
/**
* Update countdown display
*/
function updateCountdown() {
const valueEl = document.getElementById('sstvCountdownValue');
const labelEl = document.getElementById('sstvCountdownLabel');
const statusEl = document.getElementById('sstvCountdownStatus');
if (!nextPassData || !nextPassData.startTimestamp) {
if (valueEl) {
valueEl.textContent = '--:--:--';
valueEl.className = 'sstv-countdown-value';
}
if (labelEl) {
const hasLocation = localStorage.getItem('observerLat') !== null;
labelEl.textContent = hasLocation ? 'No passes in 48h' : 'Set location';
}
if (statusEl) {
statusEl.className = 'sstv-countdown-status';
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>Waiting for pass data...</span>';
}
return;
}
const now = Date.now();
const startTime = nextPassData.startTimestamp;
const endTime = nextPassData.endTimestamp || (startTime + (nextPassData.durationMinutes || 10) * 60 * 1000);
const diff = startTime - now;
if (now >= startTime && now < endTime) {
// Pass is currently active
const remaining = endTime - now;
const mins = Math.floor(remaining / 60000);
const secs = Math.floor((remaining % 60000) / 1000);
if (valueEl) {
valueEl.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
valueEl.className = 'sstv-countdown-value active';
}
if (labelEl) labelEl.textContent = 'Pass in progress!';
if (statusEl) {
statusEl.className = 'sstv-countdown-status active';
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>ISS overhead now!</span>';
}
} else if (diff > 0) {
// Countdown to next pass
const hours = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
const secs = Math.floor((diff % 60000) / 1000);
if (valueEl) {
if (hours > 0) {
valueEl.textContent = `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
valueEl.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Highlight when pass is imminent (< 5 minutes)
if (diff < 300000) {
valueEl.className = 'sstv-countdown-value imminent';
} else {
valueEl.className = 'sstv-countdown-value';
}
}
if (labelEl) {
if (diff < 60000) {
labelEl.textContent = 'Starting soon!';
} else if (diff < 300000) {
labelEl.textContent = 'Get ready!';
} else if (diff < 3600000) {
labelEl.textContent = 'Until next pass';
} else {
labelEl.textContent = 'Until next pass';
}
}
if (statusEl) {
if (diff < 300000) {
statusEl.className = 'sstv-countdown-status imminent';
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>Pass imminent!</span>';
} else {
statusEl.className = 'sstv-countdown-status has-pass';
statusEl.innerHTML = '<span class="sstv-status-dot"></span><span>Next pass scheduled</span>';
}
}
} else {
// Pass has ended, need to refresh schedule
loadIssSchedule();
}
}
/**
* Update countdown panel details
*/
function updateCountdownDetails(pass) {
const startEl = document.getElementById('sstvPassStart');
const maxElEl = document.getElementById('sstvPassMaxEl');
const durationEl = document.getElementById('sstvPassDuration');
const directionEl = document.getElementById('sstvPassDirection');
if (!pass) {
if (startEl) startEl.textContent = '--:--';
if (maxElEl) maxElEl.textContent = '--°';
if (durationEl) durationEl.textContent = '-- min';
if (directionEl) directionEl.textContent = '--';
return;
}
if (startEl) startEl.textContent = pass.startTime || '--:--';
if (maxElEl) maxElEl.textContent = (pass.maxEl || '--') + '°';
if (durationEl) durationEl.textContent = (pass.duration || '--') + ' min';
if (directionEl) directionEl.textContent = pass.direction || (pass.azStart ? getDirection(pass.azStart) : '--');
}
/**
* Get compass direction from azimuth
*/
function getDirection(azimuth) {
if (azimuth === undefined || azimuth === null) return '--';
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
const index = Math.round(azimuth / 22.5) % 16;
return directions[index];
}
/**
* Fetch current ISS position
*/
async function updateIssPosition() {
const storedLat = localStorage.getItem('observerLat') || '51.5074';
const storedLon = localStorage.getItem('observerLon') || '-0.1278';
try {
const url = `/sstv/iss-position?latitude=${storedLat}&longitude=${storedLon}`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'ok') {
issPosition = data;
updateIssDisplay();
updateMap();
console.log('ISS position updated:', data.lat.toFixed(1), data.lon.toFixed(1));
} else {
console.warn('ISS position error:', data.message);
}
} catch (err) {
console.error('Failed to get ISS position:', err);
}
}
/**
* Update ISS position display
*/
function updateIssDisplay() {
if (!issPosition) return;
const latEl = document.getElementById('sstvIssLat');
const lonEl = document.getElementById('sstvIssLon');
const altEl = document.getElementById('sstvIssAlt');
if (latEl) latEl.textContent = issPosition.lat.toFixed(1) + '°';
if (lonEl) lonEl.textContent = issPosition.lon.toFixed(1) + '°';
if (altEl) altEl.textContent = Math.round(issPosition.altitude);
}
/**
* Update map with ISS position
*/
function updateMap() {
if (!issMap || !issPosition) return;
const lat = issPosition.lat;
const lon = issPosition.lon;
// Update marker position
if (issMarker) {
issMarker.setLatLng([lat, lon]);
}
// Calculate and draw ground track
if (issTrackLine) {
const trackPoints = [];
const inclination = 51.6; // ISS orbital inclination in degrees
// Generate orbit track points
for (let offset = -180; offset <= 180; offset += 3) {
let trackLon = lon + offset;
// Normalize longitude
while (trackLon > 180) trackLon -= 360;
while (trackLon < -180) trackLon += 360;
// Calculate latitude based on orbital inclination
const phase = (offset / 360) * 2 * Math.PI;
const currentPhase = Math.asin(Math.max(-1, Math.min(1, lat / inclination)));
let trackLat = inclination * Math.sin(phase + currentPhase);
// Clamp to valid range
trackLat = Math.max(-inclination, Math.min(inclination, trackLat));
trackPoints.push([trackLat, trackLon]);
}
// Split track at antimeridian to avoid line across map
const segments = [];
let currentSegment = [];
for (let i = 0; i < trackPoints.length; i++) {
if (i > 0) {
const prevLon = trackPoints[i - 1][1];
const currLon = trackPoints[i][1];
if (Math.abs(currLon - prevLon) > 180) {
// Crossed antimeridian
if (currentSegment.length > 0) {
segments.push(currentSegment);
}
currentSegment = [];
}
}
currentSegment.push(trackPoints[i]);
}
if (currentSegment.length > 0) {
segments.push(currentSegment);
}
// Use only the longest segment or combine if needed
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
}
// Pan map to follow ISS
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
}
/**
* Check current decoder status
*/
async function checkStatus() {
try {
const response = await fetch('/sstv/status');
const data = await response.json();
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
return;
}
if (data.running) {
isRunning = true;
updateStatusUI('listening', 'Listening...');
startStream();
} else {
updateStatusUI('idle', 'Idle');
}
// Update image count
updateImageCount(data.image_count || 0);
} catch (err) {
console.error('Failed to check SSTV status:', err);
}
}
/**
* Start SSTV decoder
*/
async function start() {
const freqInput = document.getElementById('sstvFrequency');
// Use the global SDR device selector
const deviceSelect = document.getElementById('deviceSelect');
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
const device = parseInt(deviceSelect?.value || '0', 10);
updateStatusUI('connecting', 'Starting...');
try {
const response = await fetch('/sstv/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, device })
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
updateStatusUI('listening', `${frequency} MHz`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz`);
} else {
updateStatusUI('idle', 'Start failed');
showStatusMessage(data.message || 'Failed to start decoder', 'error');
}
} catch (err) {
console.error('Failed to start SSTV:', err);
updateStatusUI('idle', 'Error');
showStatusMessage('Connection error: ' + err.message, 'error');
}
}
/**
* Stop SSTV decoder
*/
async function stop() {
try {
await fetch('/sstv/stop', { method: 'POST' });
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
} catch (err) {
console.error('Failed to stop SSTV:', err);
}
}
/**
* Update status UI elements
*/
function updateStatusUI(status, text) {
const dot = document.getElementById('sstvStripDot');
const statusText = document.getElementById('sstvStripStatus');
const startBtn = document.getElementById('sstvStartBtn');
const stopBtn = document.getElementById('sstvStopBtn');
if (dot) {
dot.className = 'sstv-strip-dot';
if (status === 'listening' || status === 'detecting') {
dot.classList.add('listening');
} else if (status === 'decoding') {
dot.classList.add('decoding');
} else {
dot.classList.add('idle');
}
}
if (statusText) {
statusText.textContent = text || status;
}
if (startBtn && stopBtn) {
if (status === 'listening' || status === 'decoding') {
startBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
} else {
startBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
}
// Update live content area
const liveContent = document.getElementById('sstvLiveContent');
if (liveContent) {
if (status === 'idle' || status === 'unavailable') {
liveContent.innerHTML = renderIdleState();
}
}
}
/**
* Render idle state HTML
*/
function renderIdleState() {
return `
<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>
`;
}
/**
* Start SSE stream
*/
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/sstv/stream');
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
}
};
eventSource.onerror = () => {
console.warn('SSTV SSE error, will reconnect...');
setTimeout(() => {
if (isRunning) startStream();
}, 3000);
};
}
/**
* Stop SSE stream
*/
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
/**
* Handle progress update
*/
function handleProgress(data) {
currentMode = data.mode || currentMode;
progress = data.progress || 0;
// Update status based on decode state
if (data.status === 'decoding') {
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
renderDecodeProgress(data);
} else if (data.status === 'complete' && data.image) {
// New image decoded
images.unshift(data.image);
updateImageCount(images.length);
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
} else if (data.status === 'detecting') {
updateStatusUI('listening', data.message || 'Listening...');
}
}
/**
* Render decode progress in live area
*/
function renderDecodeProgress(data) {
const liveContent = document.getElementById('sstvLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-canvas-container">
<canvas id="sstvCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
</div>
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
}
/**
* Load decoded images
*/
async function loadImages() {
try {
const response = await fetch('/sstv/images');
const data = await response.json();
if (data.status === 'ok') {
images = data.images || [];
updateImageCount(images.length);
renderGallery();
}
} catch (err) {
console.error('Failed to load SSTV images:', err);
}
}
/**
* Update image count display
*/
function updateImageCount(count) {
const countEl = document.getElementById('sstvImageCount');
const stripCount = document.getElementById('sstvStripImageCount');
if (countEl) countEl.textContent = count;
if (stripCount) stripCount.textContent = count;
}
/**
* Render image gallery
*/
function renderGallery() {
const gallery = document.getElementById('sstvGallery');
if (!gallery) return;
if (images.length === 0) {
gallery.innerHTML = `
<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>
`;
return;
}
gallery.innerHTML = images.map(img => `
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
<div class="sstv-image-info">
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
</div>
`).join('');
}
/**
* Load ISS pass schedule
*/
async function loadIssSchedule() {
// Try to get user's location from settings
const storedLat = localStorage.getItem('observerLat');
const storedLon = localStorage.getItem('observerLon');
// Check if location is actually set
const hasLocation = storedLat !== null && storedLon !== null;
const lat = storedLat || 51.5074;
const lon = storedLon || -0.1278;
try {
const response = await fetch(`/sstv/iss-schedule?latitude=${lat}&longitude=${lon}&hours=48`);
const data = await response.json();
if (data.status === 'ok' && data.passes && data.passes.length > 0) {
const pass = data.passes[0];
// Parse the pass data to get timestamps
nextPassData = parsePassData(pass);
updateCountdownDetails(pass);
updateCountdown();
} else {
nextPassData = null;
updateCountdownDetails(null);
updateCountdown();
}
} catch (err) {
console.error('Failed to load ISS schedule:', err);
nextPassData = null;
updateCountdownDetails(null);
updateCountdown();
}
}
/**
* Parse pass data to extract timestamps
*/
function parsePassData(pass) {
if (!pass) return null;
let startTimestamp = null;
let endTimestamp = null;
const durationMinutes = parseInt(pass.duration) || 10;
// Try to parse the startTime
if (pass.startTimestamp) {
// If timestamp is provided directly
startTimestamp = pass.startTimestamp;
} else if (pass.startTime) {
// Parse time string (format: "HH:MM" or "HH:MM:SS" or with date)
startTimestamp = parseTimeString(pass.startTime, pass.date);
}
if (startTimestamp) {
endTimestamp = startTimestamp + durationMinutes * 60 * 1000;
}
return {
startTimestamp,
endTimestamp,
durationMinutes,
maxEl: pass.maxEl,
azStart: pass.azStart
};
}
/**
* Parse time string to timestamp
*/
function parseTimeString(timeStr, dateStr) {
if (!timeStr) return null;
// Try to parse as a full datetime string first (e.g., "2026-01-30 03:01 UTC")
// Remove UTC suffix for parsing
const cleanedStr = timeStr.replace(' UTC', '').replace('UTC', '');
// Try full datetime parse
let parsed = new Date(cleanedStr);
if (!isNaN(parsed.getTime())) {
return parsed.getTime();
}
// Try with T separator (ISO format)
parsed = new Date(cleanedStr.replace(' ', 'T'));
if (!isNaN(parsed.getTime())) {
return parsed.getTime();
}
// Fallback: parse as time only (HH:MM or HH:MM:SS)
const now = new Date();
let targetDate = new Date();
// If a date string is provided
if (dateStr) {
const parsedDate = new Date(dateStr);
if (!isNaN(parsedDate)) {
targetDate = parsedDate;
}
}
// Parse time (HH:MM or HH:MM:SS format)
const timeParts = cleanedStr.split(':');
if (timeParts.length >= 2) {
const hours = parseInt(timeParts[0]);
const minutes = parseInt(timeParts[1]);
const seconds = timeParts.length > 2 ? parseInt(timeParts[2]) : 0;
if (!isNaN(hours) && !isNaN(minutes)) {
targetDate.setHours(hours, minutes, seconds, 0);
// If the time is in the past, assume it's tomorrow
if (targetDate.getTime() < now.getTime() && !dateStr) {
targetDate.setDate(targetDate.getDate() + 1);
}
return targetDate.getTime();
}
}
return null;
}
/**
* Show full-size image in modal
*/
function showImage(url) {
let modal = document.getElementById('sstvImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvImageModal';
modal.className = 'sstv-image-modal';
modal.innerHTML = `
<button class="sstv-modal-close" onclick="SSTV.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
document.body.appendChild(modal);
}
modal.querySelector('img').src = url;
modal.classList.add('show');
}
/**
* Close image modal
*/
function closeImage() {
const modal = document.getElementById('sstvImageModal');
if (modal) modal.classList.remove('show');
}
/**
* Format timestamp for display
*/
function formatTimestamp(isoString) {
if (!isoString) return '--';
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show status message
*/
function showStatusMessage(message, type) {
if (typeof showNotification === 'function') {
showNotification('SSTV', message);
} else {
console.log(`[SSTV ${type}] ${message}`);
}
}
// Public API
return {
init,
start,
stop,
loadImages,
loadIssSchedule,
showImage,
closeImage,
useGPS,
updateTLE,
stopIssTracking,
stopCountdown
};
})();
// Initialize when DOM is ready (will be called by selectMode)
document.addEventListener('DOMContentLoaded', function() {
// Initialization happens via selectMode when SSTV mode is activated
});
+236 -5
View File
@@ -77,6 +77,7 @@ const WiFiMode = (function() {
let scanMode = 'quick'; // 'quick' or 'deep' let scanMode = 'quick'; // 'quick' or 'deep'
let eventSource = null; let eventSource = null;
let pollTimer = null; let pollTimer = null;
let agentPollTimer = null;
// Data stores // Data stores
let networks = new Map(); // bssid -> network let networks = new Map(); // bssid -> network
@@ -505,8 +506,13 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent deep scan started:', scanResult); console.log('[WiFiMode] Agent deep scan started:', scanResult);
} }
// Start SSE stream for real-time updates // Start SSE stream for real-time updates (works with push-enabled agents)
startEventStream(); startEventStream();
// Also start polling for agent data (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} catch (error) { } catch (error) {
console.error('[WiFiMode] Deep scan error:', error); console.error('[WiFiMode] Deep scan error:', error);
showError(error.message); showError(error.message);
@@ -523,6 +529,9 @@ const WiFiMode = (function() {
pollTimer = null; pollTimer = null;
} }
// Stop agent polling
stopAgentDeepScanPolling();
// Close event stream // Close event stream
if (eventSource) { if (eventSource) {
eventSource.close(); eventSource.close();
@@ -584,9 +593,18 @@ const WiFiMode = (function() {
const status = isAgentMode && data.result ? data.result : data; const status = isAgentMode && data.result ? data.result : data;
if (status.is_scanning || status.running) { if (status.is_scanning || status.running) {
setScanning(true, status.scan_mode); // Agent returns scan_type in params, local returns scan_mode
if (status.scan_mode === 'deep') { // Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick'
let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep';
if (detectedMode === 'deepscan') detectedMode = 'deep';
setScanning(true, detectedMode);
if (detectedMode === 'deep') {
startEventStream(); startEventStream();
// Also start polling for agent mode (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} else { } else {
startQuickScanPolling(); startQuickScanPolling();
} }
@@ -655,6 +673,76 @@ const WiFiMode = (function() {
}); });
} }
// ==========================================================================
// Agent Deep Scan Polling (fallback when push is not enabled)
// ==========================================================================
function startAgentDeepScanPolling() {
if (agentPollTimer) return;
console.log('[WiFiMode] Starting agent deep scan polling...');
agentPollTimer = setInterval(async () => {
if (!isScanning || scanMode !== 'deep') {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (!isAgentMode) {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`);
if (!response.ok) return;
const result = await response.json();
if (result.status !== 'success' || !result.data) return;
const data = result.data.data || result.data;
const agentName = result.agent_name || 'Remote';
// Process networks
if (data.networks && Array.isArray(data.networks)) {
data.networks.forEach(net => {
net._agent = agentName;
handleStreamEvent({
type: 'network_update',
network: net
});
});
}
// Process clients
if (data.clients && Array.isArray(data.clients)) {
data.clients.forEach(client => {
client._agent = agentName;
handleStreamEvent({
type: 'client_update',
client: client
});
});
}
console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`);
} catch (error) {
console.debug('[WiFiMode] Agent poll error:', error);
}
}, 2000); // Poll every 2 seconds
}
function stopAgentDeepScanPolling() {
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
// ========================================================================== // ==========================================================================
// SSE Event Stream // SSE Event Stream
// ========================================================================== // ==========================================================================
@@ -799,6 +887,9 @@ const WiFiMode = (function() {
clients.set(client.mac, client); clients.set(client.mac, client);
updateStats(); updateStats();
// Update client display if this client belongs to the selected network
updateClientInList(client);
if (onClientUpdate) onClientUpdate(client); if (onClientUpdate) onClientUpdate(client);
} }
@@ -1047,6 +1138,9 @@ const WiFiMode = (function() {
// Show the drawer // Show the drawer
elements.detailDrawer.classList.add('open'); elements.detailDrawer.classList.add('open');
// Fetch and display clients for this network
fetchClientsForNetwork(network.bssid);
} }
function closeDetail() { function closeDetail() {
@@ -1059,6 +1153,130 @@ const WiFiMode = (function() {
}); });
} }
// ==========================================================================
// Client Display
// ==========================================================================
async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return;
try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
// Route through agent proxy
response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
} else {
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
}
if (!response.ok) {
// Hide client list on error
elements.detailClientList.style.display = 'none';
return;
}
const data = await response.json();
// Handle agent response format (may be nested in 'result')
const result = isAgentMode && data.result ? data.result : data;
const clientList = result.clients || [];
if (clientList.length > 0) {
renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
} catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error);
elements.detailClientList.style.display = 'none';
}
}
function renderClientList(clientList, bssid) {
const container = elements.detailClientList?.querySelector('.wifi-client-list');
const countBadge = document.getElementById('wifiClientCountBadge');
if (!container) return;
// Update count badge
if (countBadge) {
countBadge.textContent = clientList.length;
}
// Render client cards
container.innerHTML = clientList.map(client => {
const rssi = client.rssi_current;
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
// Format last seen time
const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--';
// Build probed SSIDs badges
let probesHtml = '';
if (client.probed_ssids && client.probed_ssids.length > 0) {
const probes = client.probed_ssids.slice(0, 5); // Show max 5
probesHtml = `
<div class="wifi-client-probes">
${probes.map(ssid => `<span class="wifi-client-probe-badge">${escapeHtml(ssid)}</span>`).join('')}
${client.probed_ssids.length > 5 ? `<span class="wifi-client-probe-badge">+${client.probed_ssids.length - 5}</span>` : ''}
</div>
`;
}
return `
<div class="wifi-client-card" data-mac="${escapeHtml(client.mac)}">
<div class="wifi-client-identity">
<span class="wifi-client-mac">${escapeHtml(client.mac)}</span>
<span class="wifi-client-vendor">${escapeHtml(client.vendor || 'Unknown vendor')}</span>
${probesHtml}
</div>
<div class="wifi-client-signal">
<span class="wifi-client-rssi ${signalClass}">${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'}</span>
<span class="wifi-client-lastseen">${lastSeen}</span>
</div>
</div>
`;
}).join('');
}
function updateClientInList(client) {
// Check if this client belongs to the currently selected network
if (!selectedNetwork || client.associated_bssid !== selectedNetwork) {
return;
}
const container = elements.detailClientList?.querySelector('.wifi-client-list');
if (!container) return;
const existingCard = container.querySelector(`[data-mac="${client.mac}"]`);
if (existingCard) {
// Update existing card's RSSI and last seen
const rssiEl = existingCard.querySelector('.wifi-client-rssi');
const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen');
if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) {
const rssi = client.rssi_current;
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
rssiEl.textContent = rssi + ' dBm';
rssiEl.className = 'wifi-client-rssi ' + signalClass;
}
if (lastSeenEl && client.last_seen) {
lastSeenEl.textContent = formatTime(client.last_seen);
}
} else {
// New client for this network - re-fetch the full list
fetchClientsForNetwork(selectedNetwork);
}
}
// ========================================================================== // ==========================================================================
// Statistics // Statistics
// ========================================================================== // ==========================================================================
@@ -1292,9 +1510,19 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId); console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
// Stop any running scan // Stop UI polling only - don't stop the actual scan on the agent
// The agent should continue running independently
if (isScanning) { if (isScanning) {
stopScan(); stopAgentDeepScanPolling();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
setScanning(false);
} }
// Clear existing data when switching agents (unless "Show All" is enabled) // Clear existing data when switching agents (unless "Show All" is enabled)
@@ -1306,6 +1534,9 @@ const WiFiMode = (function() {
// Refresh capabilities for new agent // Refresh capabilities for new agent
checkCapabilities(); checkCapabilities();
// Check if new agent already has a scan running
checkScanStatus();
lastAgentId = currentAgentId; lastAgentId = currentAgentId;
} }
+4916 -4821
View File
File diff suppressed because it is too large Load Diff
+30 -8
View File
@@ -4,8 +4,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-B History // INTERCEPT</title> <title>ADS-B History // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> {% 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=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <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/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
</head> </head>
<body> <body>
@@ -22,6 +29,9 @@
</div> </div>
</header> </header>
{% set active_mode = 'adsb' %}
{% include 'partials/nav.html' with context %}
<main class="history-shell"> <main class="history-shell">
<section class="summary-strip"> <section class="summary-strip">
<div class="summary-card"> <div class="summary-card">
@@ -240,6 +250,11 @@
</div> </div>
<script> <script>
// Bias-T helper (reads from main dashboard localStorage)
function getBiasTEnabled() {
return localStorage.getItem('biasTEnabled') === 'true';
}
const historyEnabled = {{ 'true' if history_enabled else 'false' }}; const historyEnabled = {{ 'true' if history_enabled else 'false' }};
const summaryMessages = document.getElementById('summaryMessages'); const summaryMessages = document.getElementById('summaryMessages');
@@ -457,7 +472,7 @@
if (!points.length) { if (!points.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)'; ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "JetBrains Mono", monospace'; ctx.font = '12px "Space Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2); ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return; return;
} }
@@ -465,7 +480,7 @@
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined); const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
if (!series.length) { if (!series.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)'; ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "JetBrains Mono", monospace'; ctx.font = '12px "Space Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2); ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return; return;
} }
@@ -506,7 +521,7 @@
} }
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)'; ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
ctx.font = '11px "JetBrains Mono", monospace'; ctx.font = '11px "Space Mono", monospace';
ctx.fillText(`${maxVal} ${unit}`, 12, padding); ctx.fillText(`${maxVal} ${unit}`, 12, padding);
ctx.fillText(`${minVal} ${unit}`, 12, height - padding); ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
} }
@@ -553,11 +568,9 @@
} }
devices.forEach((dev, idx) => { devices.forEach((dev, idx) => {
const index = dev.index !== undefined ? dev.index : idx; const index = dev.index !== undefined ? dev.index : idx;
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = index; opt.value = index;
opt.textContent = `${type} #${index}${serial}`; opt.textContent = `SDR ${index}: ${dev.name}`;
sessionDeviceSelect.appendChild(opt); sessionDeviceSelect.appendChild(opt);
}); });
sessionDeviceSelect.disabled = false; sessionDeviceSelect.disabled = false;
@@ -632,7 +645,7 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ device, source: 'adsb_history' }) body: JSON.stringify({ device, source: 'adsb_history', bias_t: getBiasTEnabled() })
}); });
if (!resp.ok) { if (!resp.ok) {
sessionNotice.textContent = 'Start failed'; sessionNotice.textContent = 'Start failed';
@@ -758,5 +771,14 @@
} }
}); });
</script> </script>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body> </body>
</html> </html>
+568 -571
View File
File diff suppressed because it is too large Load Diff
+178 -28
View File
@@ -8,7 +8,7 @@
{% if offline_settings.fonts_source == 'local' %} {% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %} {% else %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
{% endif %} {% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading --> <!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %} {% if offline_settings.assets_source == 'local' %}
@@ -18,8 +18,17 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %} {% endif %}
<!-- Core CSS variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <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/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body>
<!-- Radar background effects --> <!-- Radar background effects -->
@@ -42,11 +51,12 @@
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All <input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
</label> </label>
</div> </div>
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
<a href="/" class="back-link">Main Dashboard</a>
</div> </div>
</header> </header>
{% set active_mode = 'ais' %}
{% include 'partials/nav.html' with context %}
<div class="stats-strip"> <div class="stats-strip">
<div class="stats-strip-inner"> <div class="stats-strip-inner">
<div class="strip-stat"> <div class="strip-stat">
@@ -84,6 +94,7 @@
<span id="trackingStatus">STANDBY</span> <span id="trackingStatus">STANDBY</span>
</div> </div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div> <div class="strip-time" id="utcTime">--:--:-- UTC</div>
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
</div> </div>
</div> </div>
@@ -189,6 +200,11 @@
</main> </main>
<script> <script>
// Bias-T helper (reads from main dashboard localStorage)
function getBiasTEnabled() {
return localStorage.getItem('biasTEnabled') === 'true';
}
// State // State
let vesselMap = null; let vesselMap = null;
let vessels = {}; let vessels = {};
@@ -213,10 +229,20 @@
const MAX_TRAIL_POINTS = 50; const MAX_TRAIL_POINTS = 50;
// Observer location // Observer location
let observerLocation = { lat: 51.5074, lon: -0.1278 }; let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('ais_observerLocation');
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarker = null; let observerMarker = null;
// GPS state
let gpsConnected = false;
let gpsEventSource = null;
let gpsReconnectTimeout = null;
// Statistics // Statistics
let stats = { let stats = {
totalVesselsSeen: new Set(), totalVesselsSeen: new Set(),
@@ -323,10 +349,12 @@
const size = 24; const size = 24;
const glowColor = isSelected ? 'rgba(255,255,255,0.8)' : color; const glowColor = isSelected ? 'rgba(255,255,255,0.8)' : color;
const glowSize = isSelected ? '8px' : '4px'; const glowSize = isSelected ? '8px' : '4px';
const trackingRing = isSelected ?
'<div class="tracking-ring"></div><div class="tracking-ring-inner"></div>' : '';
return L.divIcon({ return L.divIcon({
className: 'vessel-marker' + (isSelected ? ' selected' : ''), className: 'vessel-marker' + (isSelected ? ' selected' : ''),
html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 ${glowSize} ${glowColor});"> html: `${trackingRing}<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
<path fill="${color}" d="${path}"/> <path fill="${color}" d="${path}"/>
</svg>`, </svg>`,
iconSize: [size, size], iconSize: [size, size],
@@ -362,18 +390,10 @@
}; };
// Initialize map // Initialize map
function initMap() { async function initMap() {
// Load saved observer location if (observerLocation) {
const saved = localStorage.getItem('ais_observerLocation'); document.getElementById('obsLat').value = observerLocation.lat;
if (saved) { document.getElementById('obsLon').value = observerLocation.lon;
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) {
observerLocation = parsed;
document.getElementById('obsLat').value = parsed.lat;
document.getElementById('obsLon').value = parsed.lon;
}
} catch (e) {}
} }
vesselMap = L.map('vesselMap', { vesselMap = L.map('vesselMap', {
@@ -382,11 +402,20 @@
zoomControl: true zoomControl: true
}); });
// OpenStreetMap tile layer // Use settings manager for tile layer (allows runtime changes)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { window.vesselMap = vesselMap;
attribution: '&copy; OpenStreetMap contributors', if (typeof Settings !== 'undefined') {
maxZoom: 19 // Wait for settings to load from server before applying tiles
}).addTo(vesselMap); await Settings.init();
Settings.createTileLayer().addTo(vesselMap);
Settings.registerMap(vesselMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(vesselMap);
}
// Add observer marker // Add observer marker
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], { observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
@@ -450,7 +479,11 @@
const lon = parseFloat(document.getElementById('obsLon').value); const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation = { lat, lon }; observerLocation = { lat, lon };
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation)); if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
if (observerMarker) { if (observerMarker) {
observerMarker.setLatLng([lat, lon]); observerMarker.setLatLng([lat, lon]);
} }
@@ -527,7 +560,7 @@
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, { fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
}) })
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
@@ -552,7 +585,7 @@
fetch('/ais/start', { fetch('/ais/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -948,6 +981,110 @@
document.getElementById('utcTime').textContent = utc + ' UTC'; document.getElementById('utcTime').textContent = utc + ' UTC';
} }
// ============================================
// GPS FUNCTIONS (gpsd auto-connect)
// ============================================
async function autoConnectGps() {
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);
}
}
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' && data.latitude && data.longitude) {
updateLocationFromGps(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) {
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4);
// Update observer marker position
if (observerMarker) {
observerMarker.setLatLng([position.latitude, position.longitude]);
}
// Center map on GPS location (on first fix)
if (vesselMap && !vesselMap._gpsInitialized) {
vesselMap.setView([position.latitude, position.longitude], vesselMap.getZoom());
vesselMap._gpsInitialized = true;
}
// Redraw range rings at new position
drawRangeRings();
// Save to localStorage
if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
}
function showGpsIndicator(show) {
const indicator = document.getElementById('gpsIndicator');
if (indicator) {
indicator.style.display = show ? 'inline-flex' : 'none';
}
}
// Session timer functions // Session timer functions
function startSessionTimer() { function startSessionTimer() {
if (!stats.sessionStart) { if (!stats.sessionStart) {
@@ -1344,7 +1481,11 @@
} }
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', initMap); document.addEventListener('DOMContentLoaded', function() {
initMap();
// Auto-connect to gpsd if available
autoConnectGps();
});
</script> </script>
<!-- Agent styles --> <!-- Agent styles -->
@@ -1362,7 +1503,7 @@
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
cursor: pointer; cursor: pointer;
} }
.agent-select-sm:focus { .agent-select-sm:focus {
@@ -1414,8 +1555,17 @@
} }
</style> </style>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<!-- Agent Manager --> <!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script> <script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script> <script>
// AIS-specific agent integration // AIS-specific agent integration
let aisCurrentAgent = 'local'; let aisCurrentAgent = 'local';
@@ -1500,7 +1650,7 @@
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, { fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
+24
View File
@@ -0,0 +1,24 @@
{#
Card/Panel Component
Reusable container with optional header and footer
Variables:
- title: Optional card header title
- indicator: If true, shows status indicator dot in header
- indicator_active: If true, indicator is active/green
- no_padding: If true, removes body padding
#}
<div class="panel">
{% if title %}
<div class="panel-header">
<span>{{ title }}</span>
{% if indicator %}
<div class="panel-indicator {% if indicator_active %}active{% endif %}"></div>
{% endif %}
</div>
{% endif %}
<div class="panel-content{% if no_padding %}" style="padding: 0;{% else %}{% endif %}">
{{ caller() }}
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
{#
Empty State Component
Display when no data is available
Variables:
- icon: Optional SVG icon (default: generic empty icon)
- title: Main message (default: "No data")
- description: Optional helper text
- action_text: Optional button text
- action_onclick: Optional button onclick handler
- action_href: Optional button link
#}
<div class="empty-state">
<div class="empty-state-icon">
{% if icon %}
{{ icon|safe }}
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M8 12h8"/>
</svg>
{% endif %}
</div>
<div class="empty-state-title">{{ title|default('No data') }}</div>
{% if description %}
<div class="empty-state-description">{{ description }}</div>
{% endif %}
{% if action_text %}
<div class="empty-state-action">
{% if action_href %}
<a href="{{ action_href }}" class="btn btn-primary btn-sm">{{ action_text }}</a>
{% elif action_onclick %}
<button class="btn btn-primary btn-sm" onclick="{{ action_onclick }}">{{ action_text }}</button>
{% endif %}
</div>
{% endif %}
</div>
+27
View File
@@ -0,0 +1,27 @@
{#
Loading State Component
Display while data is being fetched
Variables:
- text: Optional loading text (default: "Loading...")
- size: 'sm', 'md', or 'lg' (default: 'md')
- overlay: If true, renders as full overlay
#}
{% if overlay %}
<div class="loading-overlay">
<div class="loading-content">
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
{% if text %}
<div class="loading-text mt-3 text-secondary text-sm">{{ text }}</div>
{% endif %}
</div>
</div>
{% else %}
<div class="loading-inline flex items-center gap-3">
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
{% if text %}
<span class="text-secondary text-sm">{{ text }}</span>
{% endif %}
</div>
{% endif %}
+47
View File
@@ -0,0 +1,47 @@
{#
Stats Strip Component
Horizontal bar displaying key metrics
Variables:
- stats: List of stat objects with 'id', 'value', 'label', and optional 'title'
- show_divider: Show divider after stats (default: true)
- status_dot_id: Optional ID for status indicator dot
- status_text_id: Optional ID for status text
- time_id: Optional ID for time display
#}
<div class="stats-strip">
<div class="stats-strip-inner">
{% for stat in stats %}
<div class="strip-stat" {% if stat.title %}title="{{ stat.title }}"{% endif %}>
<span class="strip-value" id="{{ stat.id }}">{{ stat.value|default('0') }}</span>
<span class="strip-label">{{ stat.label }}</span>
</div>
{% endfor %}
{% if show_divider|default(true) %}
<div class="strip-divider"></div>
{% endif %}
{# Additional content from caller #}
{% if caller is defined %}
{{ caller() }}
{% endif %}
{% if status_dot_id or status_text_id %}
<div class="strip-divider"></div>
<div class="strip-status">
{% if status_dot_id %}
<div class="status-dot inactive" id="{{ status_dot_id }}"></div>
{% endif %}
{% if status_text_id %}
<span id="{{ status_text_id }}">STANDBY</span>
{% endif %}
</div>
{% endif %}
{% if time_id %}
<div class="strip-time" id="{{ time_id }}">--:--:-- UTC</div>
{% endif %}
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
{#
Status Badge Component
Compact status indicator with dot and text
Variables:
- status: 'online', 'offline', 'warning', 'error' (default: 'offline')
- text: Status text to display
- id: Optional ID for the text element (for JS updates)
- dot_id: Optional ID for the dot element (for JS updates)
- pulse: If true, adds pulse animation to dot
#}
{% set status_class = {
'online': 'online',
'active': 'online',
'offline': 'offline',
'warning': 'warning',
'error': 'error',
'inactive': 'inactive'
}.get(status|default('offline'), 'inactive') %}
<div class="status-badge flex items-center gap-2">
<div class="status-dot {{ status_class }}{% if pulse %} pulse{% endif %}"
{% if dot_id %}id="{{ dot_id }}"{% endif %}></div>
<span class="text-sm"
{% if id %}id="{{ id }}"{% endif %}>{{ text|default('Unknown') }}</span>
</div>
+715 -359
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en" data-theme="{{ theme|default('dark') }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}iNTERCEPT{% endblock %} // iNTERCEPT</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
{# Fonts - Conditional CDN/Local loading #}
{% if offline_settings and 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=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
{% endif %}
{# Core CSS (Design System) #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
{# Responsive styles #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
{# Page-specific CSS #}
{% block styles %}{% endblock %}
{# Page-specific head content #}
{% block head %}{% endblock %}
</head>
<body>
<div class="app-shell">
{# Global Header #}
{% block header %}
<header class="app-header">
<div class="app-header-left">
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
<span></span>
<span></span>
<span></span>
</button>
<a href="/" class="app-logo">
<svg class="app-logo-icon" width="40" height="40" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 30 Q5 50, 15 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="var(--accent-green)"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="var(--accent-cyan)"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
</svg>
<span class="app-logo-text">
<span class="app-logo-title">iNTERCEPT</span>
<span class="app-logo-tagline">// See the Invisible</span>
</span>
</a>
{% if version %}
<span class="badge badge-primary">v{{ version }}</span>
{% endif %}
</div>
<div class="app-header-right">
{% block header_right %}
<div class="header-clock">
<span class="header-clock-label">UTC</span>
<span id="headerUtcTime">--:--:--</span>
</div>
{% endblock %}
</div>
</header>
{% endblock %}
{# Global Navigation - opt-in for pages that need it #}
{# Override this block and include 'partials/nav.html' in child templates #}
{% block navigation %}{% endblock %}
{# Main Content Area #}
<main class="app-main">
{% block main %}
<div class="content-wrapper">
{# Optional Sidebar #}
{% block sidebar %}{% endblock %}
{# Page Content #}
<div class="app-content">
{% block content %}{% endblock %}
</div>
</div>
{% endblock %}
</main>
{# Toast/Notification Container #}
<div id="toastContainer" class="toast-container"></div>
</div>
{# Core JavaScript #}
<script>
// UTC Clock
function updateUtcClock() {
const now = new Date();
const utc = now.toISOString().slice(11, 19);
const clockEl = document.getElementById('headerUtcTime');
if (clockEl) clockEl.textContent = utc;
}
setInterval(updateUtcClock, 1000);
updateUtcClock();
// Mobile menu toggle
const hamburgerBtn = document.getElementById('hamburgerBtn');
const drawerOverlay = document.getElementById('drawerOverlay');
if (hamburgerBtn) {
hamburgerBtn.addEventListener('click', function() {
this.classList.toggle('open');
document.querySelector('.app-sidebar')?.classList.toggle('open');
drawerOverlay?.classList.toggle('visible');
});
}
if (drawerOverlay) {
drawerOverlay.addEventListener('click', function() {
hamburgerBtn?.classList.remove('open');
document.querySelector('.app-sidebar')?.classList.remove('open');
this.classList.remove('visible');
});
}
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// Apply saved theme
const savedTheme = localStorage.getItem('intercept-theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
// Nav dropdown handling
function toggleNavDropdown(groupName) {
const group = document.querySelector(`.nav-group[data-group="${groupName}"]`);
if (!group) return;
// Close other dropdowns
document.querySelectorAll('.nav-group.open').forEach(g => {
if (g !== group) g.classList.remove('open');
});
group.classList.toggle('open');
}
// Close dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.nav-group')) {
document.querySelectorAll('.nav-group.open').forEach(g => g.classList.remove('open'));
}
});
</script>
{# Page-specific JavaScript #}
{% block scripts %}{% endblock %}
</body>
</html>
+226
View File
@@ -0,0 +1,226 @@
{% extends 'layout/base.html' %}
{#
Dashboard Base Template
Extended layout for full-screen dashboard pages (ADSB, AIS, Satellite, etc.)
Features: Full-height layout, stats strip, sidebar overlay on mobile
Variables:
- active_mode: The current mode for nav highlighting (e.g., 'adsb', 'ais', 'satellite')
#}
{% block styles %}
{{ super() }}
<style>
/* Dashboard-specific overrides */
html, body {
height: 100%;
overflow: hidden;
}
.app-shell {
height: 100vh;
overflow: hidden;
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Radar/Grid background effect */
.dashboard-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
.radar-bg {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at center, transparent 0%, var(--bg-primary) 70%),
repeating-linear-gradient(0deg, transparent, transparent 50px, var(--border-color) 50px, var(--border-color) 51px),
repeating-linear-gradient(90deg, transparent, transparent 50px, var(--border-color) 50px, var(--border-color) 51px);
opacity: 0.3;
}
.grid-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border-color) 1px, transparent 1px),
linear-gradient(90deg, var(--border-color) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.15;
}
.scanline {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.5;
animation: scanline 8s linear infinite;
}
@keyframes scanline {
0% { top: 0; }
100% { top: 100%; }
}
/* Animations toggle */
[data-animations="off"] .scanline,
[data-animations="off"] .radar-bg,
[data-animations="off"] .grid-bg {
display: none;
}
/* Dashboard main content */
.dashboard-content {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.dashboard-map-container {
flex: 1;
position: relative;
}
.dashboard-sidebar {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
}
@media (max-width: 1024px) {
.dashboard-sidebar {
width: 280px;
}
}
@media (max-width: 768px) {
.dashboard-sidebar {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: var(--z-fixed);
transform: translateX(100%);
transition: transform var(--transition-base);
}
.dashboard-sidebar.open {
transform: translateX(0);
}
}
</style>
{% endblock %}
{% block header %}
<header class="app-header" style="padding: 0 var(--space-3); height: 48px;">
<div class="app-header-left" style="gap: var(--space-3);">
<a href="/" class="app-logo" style="gap: var(--space-2);">
<svg class="app-logo-icon" width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 30 Q5 50, 15 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="var(--accent-cyan)" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="var(--accent-cyan)" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="var(--accent-cyan)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="var(--accent-green)"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="var(--accent-cyan)"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
</svg>
</a>
<div class="dashboard-header-title">
<span style="font-size: var(--text-lg); font-weight: var(--font-bold); color: var(--text-primary);">
{% block dashboard_title %}DASHBOARD{% endblock %}
</span>
<span style="font-size: var(--text-sm); color: var(--text-dim); margin-left: var(--space-2);">
// iNTERCEPT
</span>
</div>
</div>
<div class="app-header-right">
{% block dashboard_header_center %}{% endblock %}
<div class="header-utilities" style="gap: var(--space-2);">
{% block agent_selector %}{% endblock %}
</div>
</div>
</header>
{% endblock %}
{% block navigation %}
{# Include the unified nav partial with active_mode set #}
{% include 'partials/nav.html' with context %}
{% endblock %}
{% block main %}
{# Background effects #}
<div class="dashboard-bg">
{% block dashboard_bg %}
<div class="radar-bg"></div>
{% endblock %}
<div class="scanline"></div>
</div>
{# Stats strip #}
{% block stats_strip %}{% endblock %}
{# Dashboard content #}
<div class="dashboard-content">
{% block dashboard_content %}{% endblock %}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
// Dashboard-specific scripts
(function() {
// Mobile sidebar toggle
const sidebarToggle = document.getElementById('sidebarToggle');
const sidebar = document.querySelector('.dashboard-sidebar');
const overlay = document.getElementById('drawerOverlay');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', function() {
sidebar.classList.toggle('open');
if (overlay) overlay.classList.toggle('visible');
});
}
if (overlay) {
overlay.addEventListener('click', function() {
sidebar?.classList.remove('open');
this.classList.remove('visible');
});
}
// UTC Clock update
function updateUtcClock() {
const now = new Date();
const utc = now.toISOString().slice(11, 19) + ' UTC';
document.querySelectorAll('[id$="utcTime"], [id$="UtcTime"]').forEach(el => {
el.textContent = utc;
});
}
setInterval(updateUtcClock, 1000);
updateUtcClock();
})();
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
{#
Help Modal Partial
Provides consistent help modal across all pages
#}
<!-- Help Modal -->
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
<div class="help-content">
<button class="help-close" onclick="hideHelp()">&times;</button>
<h2>iNTERCEPT Help</h2>
<div class="help-tabs">
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">&#128223;</span><span class="desc">POCSAG messages decoded</span></div>
<div class="icon-item"><span class="icon">&#128224;</span><span class="desc">FLEX messages decoded</span></div>
<div class="icon-item"><span class="icon">&#128232;</span><span class="desc">Total messages received</span></div>
<div class="icon-item"><span class="icon">&#127777;&#65039;</span><span class="desc">Unique sensors detected</span></div>
<div class="icon-item"><span class="icon">&#128202;</span><span class="desc">Device types found</span></div>
<div class="icon-item"><span class="icon">&#128752;&#65039;</span><span class="desc">Satellites monitored</span></div>
<div class="icon-item"><span class="icon">&#128225;</span><span class="desc">WiFi Access Points</span></div>
<div class="icon-item"><span class="icon">&#128100;</span><span class="desc">Connected WiFi clients</span></div>
<div class="icon-item"><span class="icon">&#129309;</span><span class="desc">Captured handshakes</span></div>
<div class="icon-item"><span class="icon">&#128641;</span><span class="desc">Detected drones (click for details)</span></div>
<div class="icon-item"><span class="icon">&#9888;&#65039;</span><span class="desc">Rogue APs (click for details)</span></div>
<div class="icon-item"><span class="icon">&#128309;</span><span class="desc">Bluetooth devices</span></div>
<div class="icon-item"><span class="icon">&#128205;</span><span class="desc">BLE beacons / APRS stations</span></div>
</div>
<h3>Mode Tab Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">&#128223;</span><span class="desc">Pager - POCSAG/FLEX decoder</span></div>
<div class="icon-item"><span class="icon">&#128225;</span><span class="desc">433MHz - Sensor decoder</span></div>
<div class="icon-item"><span class="icon">&#9889;</span><span class="desc">Meters - Utility meter decoder</span></div>
<div class="icon-item"><span class="icon">&#9992;&#65039;</span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon">&#128674;</span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon">&#128251;</span><span class="desc">Spy Stations - Number stations database</span></div>
<div class="icon-item"><span class="icon">&#128205;</span><span class="desc">APRS - Amateur radio tracking</span></div>
<div class="icon-item"><span class="icon">&#128752;&#65039;</span><span class="desc">Satellite - Pass prediction</span></div>
<div class="icon-item"><span class="icon">&#128246;</span><span class="desc">WiFi - Network scanner</span></div>
<div class="icon-item"><span class="icon">&#128309;</span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
<div class="icon-item"><span class="icon">&#128251;</span><span class="desc">Listening Post - SDR scanner</span></div>
<div class="icon-item"><span class="icon">&#128269;</span><span class="desc">TSCM - Counter-surveillance</span></div>
</div>
</div>
<!-- Modes Section -->
<div id="help-modes" class="help-section">
<h3>Pager Mode</h3>
<ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
<li>Set frequency to local pager frequencies (common: 152-158 MHz)</li>
<li>Messages are displayed in real-time as they're decoded</li>
<li>Use presets for common pager frequencies</li>
</ul>
<h3>433MHz Sensor Mode</h3>
<ul class="tip-list">
<li>Decodes wireless sensors on 433.92 MHz ISM band</li>
<li>Detects temperature, humidity, weather stations, tire pressure monitors</li>
<li>Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)</li>
<li>Device intelligence builds profiles of recurring devices</li>
</ul>
<h3>Utility Meter Mode</h3>
<ul class="tip-list">
<li>Decodes utility meter transmissions (water, gas, electric) using rtlamr</li>
<li>Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)</li>
<li>Displays meter IDs and consumption data in real-time</li>
<li>Supports SCM, SCM+, IDM, NetIDM, and R900 message types</li>
</ul>
<h3>Aircraft (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
<li>Features radar scope, map view, airband audio, and ACARS decoding</li>
<li>Optional history mode persists data to Postgres for long-term analysis</li>
<li>Access history dashboard at <code>/adsb/history</code></li>
</ul>
<h3>Vessels (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the AIS Dashboard for maritime vessel tracking</li>
<li>Displays vessel name, MMSI, callsign, destination, and navigation data</li>
<li><strong>VHF DSC Channel 70:</strong> Monitors maritime distress frequency (156.525 MHz)</li>
<li>Decodes DSC messages: Distress, Urgency, Safety, and Routine calls</li>
<li>MMSI country identification via Maritime Identification Digits (MID)</li>
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
</ul>
<h3>Spy Stations</h3>
<ul class="tip-list">
<li>Database of number stations and diplomatic HF networks</li>
<li>Browse stations from priyom.org with frequencies and schedules</li>
<li>Filter by type (number/diplomatic), country, and mode</li>
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
<li>Click "Tune" to listen via Listening Post mode</li>
</ul>
<h3>APRS Mode</h3>
<ul class="tip-list">
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
<li>Tracks amateur radio operators transmitting position data</li>
<li>Regional frequencies: 144.390 MHz (N. America), 144.800 MHz (Europe)</li>
<li>Uses Direwolf or multimon-ng for packet decoding</li>
<li>Interactive map shows station positions in real-time</li>
</ul>
<h3>Satellite Mode</h3>
<ul class="tip-list">
<li>Track satellites using TLE (Two-Line Element) data</li>
<li>Add satellites manually or fetch from Celestrak by category</li>
<li>Categories: Amateur, Weather, ISS, Starlink, GPS, and more</li>
<li>View next pass predictions with elevation and duration</li>
</ul>
<h3>WiFi Mode</h3>
<ul class="tip-list">
<li>Requires a WiFi adapter capable of monitor mode</li>
<li>Click "Enable Monitor" to put adapter in monitor mode</li>
<li>Scans all channels or lock to a specific channel</li>
<li>Detects drones by SSID patterns and manufacturer OUI</li>
<li>Rogue AP detection flags same SSID on multiple BSSIDs</li>
<li>Click network rows to target for deauth or handshake capture</li>
</ul>
<h3>Bluetooth Mode</h3>
<ul class="tip-list">
<li>Scans for classic Bluetooth and BLE devices</li>
<li>Shows device names, addresses, and signal strength</li>
<li>Manufacturer lookup from MAC address OUI</li>
<li>Radar visualization shows device proximity</li>
</ul>
<h3>Listening Post Mode</h3>
<ul class="tip-list">
<li>Wideband SDR scanner with spectrum visualization</li>
<li>Tune to any frequency supported by your SDR hardware</li>
<li>AM/FM/USB/LSB demodulation modes</li>
<li>Bookmark frequencies for quick recall</li>
<li>Quick tune presets for emergency and marine channels</li>
</ul>
<h3>TSCM Mode</h3>
<ul class="tip-list">
<li>Technical Surveillance Countermeasures sweep</li>
<li>Scans for unknown RF transmitters, WiFi devices, Bluetooth</li>
<li>Baseline comparison to detect new/anomalous devices</li>
<li>Threat classification: Critical, High, Medium, Low</li>
<li>Useful for security audits and bug sweeps</li>
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
</ul>
<h3>Meshtastic Mode</h3>
<ul class="tip-list">
<li>Integrates with Meshtastic LoRa mesh network devices</li>
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
<li>Real-time message streaming with RSSI and SNR metrics</li>
<li>Configure channels with encryption keys</li>
<li>View connected nodes and message history</li>
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
</ul>
<h3>Network Monitor</h3>
<ul class="tip-list">
<li>Aggregates data from multiple remote INTERCEPT agents</li>
<li>View all WiFi, Bluetooth, ADS-B, AIS data in one unified view</li>
<li>Real-time streaming via Server-Sent Events (SSE)</li>
<li>Location estimation using multi-agent trilateration</li>
<li>Manage agents at <code>/controller/manage</code></li>
</ul>
</div>
<!-- WiFi Section -->
<div id="help-wifi" class="help-section">
<h3>Monitor Mode</h3>
<ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
<li><strong>Kill Processes:</strong> Optional - stops NetworkManager/wpa_supplicant (may drop other connections)</li>
<li>Some adapters rename when entering monitor mode (e.g., wlan0 &rarr; wlan0mon)</li>
</ul>
<h3>Handshake Capture</h3>
<ul class="tip-list">
<li>Click "Capture" on a network to start targeted handshake capture</li>
<li>Status panel shows capture progress and file location</li>
<li>Use deauth to force clients to reconnect (only on authorized networks!)</li>
<li>Handshake files saved to /tmp/intercept_handshake_*.cap</li>
</ul>
<h3>Drone Detection</h3>
<ul class="tip-list">
<li>Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)</li>
<li>Also detected by manufacturer OUI in MAC address</li>
<li>Distance estimated from signal strength (approximate)</li>
<li>Click drone count in stats bar to see all detected drones</li>
</ul>
<h3>Rogue AP Detection</h3>
<ul class="tip-list">
<li>Flags networks where same SSID appears on multiple BSSIDs</li>
<li>Could indicate evil twin attack or legitimate multi-AP setup</li>
<li>Click rogue count to see which SSIDs are flagged</li>
</ul>
<h3>Proximity Alerts</h3>
<ul class="tip-list">
<li>Add MAC addresses to watch list for alerts when detected</li>
<li>Watch list persists in browser localStorage</li>
<li>Useful for tracking specific devices</li>
</ul>
<h3>Client Probe Analysis</h3>
<ul class="tip-list">
<li>Shows what networks client devices are looking for</li>
<li>Orange highlights indicate sensitive/private network names</li>
<li>Reveals user location history (home, work, hotels, airports)</li>
<li>Useful for security awareness and pen test reports</li>
</ul>
</div>
<!-- Tips Section -->
<div id="help-tips" class="help-section">
<h3>General Tips</h3>
<ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li>
<li><strong>Sound alerts:</strong> Toggle sound on/off in settings for each mode</li>
<li><strong>Export data:</strong> Use export buttons to save captured data as JSON</li>
<li><strong>Device Intelligence:</strong> Tracks device patterns over time</li>
<li><strong>Theme toggle:</strong> Click the theme button in header to switch dark/light mode</li>
<li><strong>Settings:</strong> Click the gear icon in the header to access settings</li>
<li><strong>Offline mode:</strong> Enable in Settings to use local assets without internet</li>
</ul>
<h3>Keyboard Shortcuts</h3>
<ul class="tip-list">
<li><strong>F1</strong> - Open this help page</li>
<li><strong>?</strong> - Open help (when not typing in a field)</li>
<li><strong>Escape</strong> - Close help and modal dialogs</li>
</ul>
<h3>Requirements</h3>
<ul class="tip-list">
<li><strong>Pager:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
<li>Run as root/sudo for full hardware access</li>
</ul>
<h3>Legal Notice</h3>
<ul class="tip-list">
<li>Only use on networks and devices you own or have authorization to test</li>
<li>Passive monitoring may be legal; active attacks require authorization</li>
<li>Check local laws regarding radio frequency monitoring</li>
</ul>
</div>
</div>
</div>
<script>
// Help modal functions - defined here so all pages have them
(function() {
// Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') {
window.showHelp = function() {
document.getElementById('helpModal').classList.add('active');
document.body.style.overflow = 'hidden';
};
}
if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() {
document.getElementById('helpModal').classList.remove('active');
document.body.style.overflow = '';
};
}
if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(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 (only add once)
if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideHelp();
// Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
}
})();
</script>
+1 -1
View File
@@ -45,7 +45,7 @@
fetch('/ais/start', { fetch('/ais/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain }) body: JSON.stringify({ device, gain, bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
+1 -1
View File
@@ -19,7 +19,7 @@
</div> </div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span> <span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
<span id="lpQuickFreq" style="font-size: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">---.--- MHz</span> <span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
</div> </div>
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span> <span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
+19
View File
@@ -100,3 +100,22 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Traceroute Modal -->
<div id="meshTracerouteModal" class="signal-details-modal">
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeTracerouteModal()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>Traceroute to <span id="meshTracerouteDest">--</span></h3>
<button class="signal-details-modal-close" onclick="Meshtastic.closeTracerouteModal()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div id="meshTracerouteContent" class="mesh-traceroute-content">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="signal-details-modal-footer">
<button class="preset-btn" onclick="Meshtastic.closeTracerouteModal()" style="width: 100%;">Close</button>
</div>
</div>
</div>
+42
View File
@@ -0,0 +1,42 @@
<!-- SSTV MODE -->
<div id="sstvMode" class="mode-content">
<div class="section">
<h3>ISS SSTV Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode Slow-Scan Television images from the International Space Station.
ISS SSTV transmits on 145.800 MHz FM during special events.
</p>
</div>
<div class="section">
<h3>Decoder Settings</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="sstvFrequency" value="145.800" step="0.001" min="100" max="500">
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://ariss.org/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
ARISS.org (Event Schedule)
</a>
<a href="https://www.amsat.org/sstv-from-iss/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
AMSAT SSTV Guide
</a>
</div>
</div>
<div class="section">
<h3>About SSTV</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
SSTV (Slow-Scan Television) is a method for transmitting images via radio.
The ISS periodically transmits commemorative images during special events
which can be received with an RTL-SDR and appropriate software.
</p>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
Common modes: PD120, PD180, Martin1, Scottie1
</p>
</div>
</div>
+304
View File
@@ -0,0 +1,304 @@
{#
Global Navigation Partial
Single source of truth for app navigation
Compatible with:
- index.html (uses switchMode() for mode panels)
- Dashboard pages (uses navigation links)
Variables:
- active_mode: Current active mode (e.g., 'pager', 'adsb', 'wifi')
- is_index_page: If true, Satellite/SSTV use switchMode (panel mode)
If false (default), Satellite links to dashboard
#}
{% set is_index_page = is_index_page|default(false) %}
{% macro mode_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %}
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</a>
{%- elif is_index_page %}
<button class="mode-nav-btn {{ is_active }}" onclick="switchMode('{{ mode }}')">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</button>
{%- else %}
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</a>
{%- endif %}
{%- endmacro %}
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %}
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a>
{%- elif is_index_page %}
<button class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" onclick="switchMode('{{ mode }}')">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</button>
{%- else %}
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a>
{%- endif %}
{%- endmacro %}
{# Desktop Navigation - uses existing CSS class names for compatibility #}
<nav class="mode-nav" id="mainNav">
{# SDR / RF Group #}
<div class="mode-nav-dropdown" data-group="sdr">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('sdr')"{% endif %}>
<span class="nav-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.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
<span class="nav-label">SDR / RF</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('pager', 'Pager', '<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>') }}
{{ mode_item('sensor', '433MHz', '<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>') }}
{{ mode_item('rtlamr', 'Meters', '<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>') }}
{{ mode_item('adsb', 'Aircraft', '<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>', '/adsb/dashboard') }}
{{ mode_item('ais', 'Vessels', '<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"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('aprs', 'APRS', '<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>') }}
{{ mode_item('listening', 'Listening Post', '<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"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<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"/><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>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<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>') }}
</div>
</div>
{# Wireless Group #}
<div class="mode-nav-dropdown" data-group="wireless">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
<span class="nav-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="nav-label">Wireless</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('wifi', 'WiFi', '<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>') }}
{{ mode_item('bluetooth', 'Bluetooth', '<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>') }}
</div>
</div>
{# Security Group #}
<div class="mode-nav-dropdown" data-group="security">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('security')"{% endif %}>
<span class="nav-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="nav-label">Security</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('tscm', 'TSCM', '<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>') }}
</div>
</div>
{# Space Group #}
<div class="mode-nav-dropdown" data-group="space">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
<span class="nav-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>
<span class="nav-label">Space</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{% if is_index_page %}
{{ mode_item('satellite', 'Satellite', '<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>') }}
{% else %}
{{ mode_item('satellite', 'Satellite', '<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>', '/satellite/dashboard') }}
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<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>') }}
</div>
</div>
{# Dynamic dashboard button (shown when in satellite mode) #}
<div class="mode-nav-actions">
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
<span class="nav-label">Full Dashboard</span>
</a>
</div>
{# Nav Utilities (clock, theme, tools) #}
<div class="nav-utilities">
<div class="nav-clock">
<span class="utc-label">UTC</span>
<span class="utc-time" id="headerUtcTime">--:--:--</span>
</div>
<div class="nav-divider"></div>
<a href="/" class="nav-dashboard-btn" title="Return to Main Dashboard" style="text-decoration: none;">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
<span class="nav-label">Main Dashboard</span>
</a>
<div class="nav-divider"></div>
<div class="nav-tools">
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
</button>
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
<span class="icon-sun 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>
</button>
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;">
<span class="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>
</a>
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
</a>
<button class="nav-tool-btn" onclick="showSettings()" title="Settings">
<span class="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="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></span>
</button>
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
</button>
</div>
</div>
</nav>
{# Mobile Navigation Bar #}
<nav class="mobile-nav-bar" id="mobileNavBar">
{{ mobile_item('pager', 'Pager', '<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>') }}
{{ mobile_item('sensor', '433MHz', '<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>') }}
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
{{ mobile_item('wifi', 'WiFi', '<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>') }}
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{% if is_index_page %}
{{ mobile_item('satellite', 'Sat', '<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>') }}
{% else %}
{{ mobile_item('satellite', 'Sat', '<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>', '/satellite/dashboard') }}
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<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="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
{{ mobile_item('meshtastic', 'Mesh', '<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>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}
<script>
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
if (typeof switchMode === 'undefined') {
window.switchMode = function(mode) {
// On dashboard pages, navigate to main page with mode param
window.location.href = '/?mode=' + mode;
};
}
if (typeof toggleNavDropdown === 'undefined') {
window.toggleNavDropdown = function(groupName) {
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${groupName}"]`);
if (!dropdown) return;
// Close other dropdowns
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => {
if (d !== dropdown) d.classList.remove('open');
});
dropdown.classList.toggle('open');
};
// Close dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.mode-nav-dropdown')) {
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => d.classList.remove('open'));
}
});
}
if (typeof toggleAnimations === 'undefined') {
window.toggleAnimations = function() {
const html = document.documentElement;
const current = html.getAttribute('data-animations') || 'on';
const next = current === 'on' ? 'off' : 'on';
html.setAttribute('data-animations', next);
localStorage.setItem('animations', next);
};
}
if (typeof toggleTheme === 'undefined') {
window.toggleTheme = function() {
const html = document.documentElement;
const current = html.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('intercept-theme', next);
};
}
if (typeof showSettings === 'undefined') {
window.showSettings = function() {
// Try to open settings modal if it exists on this page
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.add('active');
if (typeof Settings !== 'undefined' && Settings.init) {
Settings.init().then(() => {
if (Settings.checkAssets) Settings.checkAssets();
});
}
} else {
// Fall back to navigating to main page settings
window.location.href = '/?settings=1';
}
};
}
if (typeof hideSettings === 'undefined') {
window.hideSettings = function() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.remove('active');
}
};
}
// showHelp is defined by the help-modal.html partial
if (typeof logout === 'undefined') {
window.logout = function(e) {
if (e) e.preventDefault();
if (confirm('Are you sure you want to logout?')) {
window.location.href = '/logout';
}
};
}
// Apply saved preferences and start clock
(function() {
const savedTheme = localStorage.getItem('intercept-theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
const savedAnimations = localStorage.getItem('intercept-animations');
if (savedAnimations) {
document.documentElement.setAttribute('data-animations', savedAnimations);
}
// UTC Clock update (if not already defined by parent page)
if (typeof window._navClockStarted === 'undefined') {
window._navClockStarted = true;
function updateNavUtcClock() {
const now = new Date();
const utc = now.toISOString().slice(11, 19);
const el = document.getElementById('headerUtcTime');
if (el) el.textContent = utc;
}
setInterval(updateNavUtcClock, 1000);
updateNavUtcClock();
}
})();
</script>
+37
View File
@@ -0,0 +1,37 @@
{#
Page Header Partial
Consistent page title with optional description and actions
Variables:
- title: Page title (required)
- description: Optional description text
- back_url: Optional back link URL
- back_text: Optional back link text (default: "Back")
#}
<div class="page-header">
{% if back_url %}
<a href="{{ back_url }}" class="back-link mb-4">
<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>
{{ back_text|default('Back') }}
</a>
{% endif %}
<div class="flex items-center justify-between">
<div>
<h1 class="page-title">{{ title }}</h1>
{% if description %}
<p class="page-description">{{ description }}</p>
{% endif %}
</div>
{% if caller is defined %}
<div class="page-actions">
{{ caller() }}
</div>
{% endif %}
</div>
</div>
+317 -167
View File
@@ -1,167 +1,317 @@
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()"> <div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
<div class="settings-content"> <div class="settings-content">
<div class="settings-header"> <div class="settings-header">
<h2> <h2>
<span class="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="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></span> <span class="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="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></span>
Settings Settings
</h2> </h2>
<button class="settings-close" onclick="hideSettings()">&times;</button> <button class="settings-close" onclick="hideSettings()">&times;</button>
</div> </div>
<div class="settings-tabs"> <div class="settings-tabs">
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button> <button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button> <button class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')">Location</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button> <button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
</div> <button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
<!-- Offline Section --> <button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
<div id="settings-offline" class="settings-section active"> </div>
<div class="settings-group">
<div class="settings-group-title">Offline Mode</div> <!-- Offline Section -->
<div id="settings-offline" class="settings-section active">
<div class="settings-row"> <div class="settings-group">
<div class="settings-label"> <div class="settings-group-title">Offline Mode</div>
<span class="settings-label-text">Enable Offline Mode</span>
<span class="settings-label-desc">Use local assets instead of CDN</span> <div class="settings-row">
</div> <div class="settings-label">
<label class="toggle-switch"> <span class="settings-label-text">Enable Offline Mode</span>
<input type="checkbox" id="offlineEnabled" onchange="Settings.toggleOfflineMode(this.checked)"> <span class="settings-label-desc">Use local assets instead of CDN</span>
<span class="toggle-slider"></span> </div>
</label> <label class="toggle-switch">
</div> <input type="checkbox" id="offlineEnabled" onchange="Settings.toggleOfflineMode(this.checked)">
</div> <span class="toggle-slider"></span>
</label>
<div class="settings-group"> </div>
<div class="settings-group-title">Asset Sources</div> </div>
<div class="settings-row"> <div class="settings-group">
<div class="settings-label"> <div class="settings-group-title">Asset Sources</div>
<span class="settings-label-text">JavaScript/CSS Libraries</span>
<span class="settings-label-desc">Leaflet, Chart.js</span> <div class="settings-row">
</div> <div class="settings-label">
<select id="assetsSource" class="settings-select" onchange="Settings.setAssetSource(this.value)"> <span class="settings-label-text">JavaScript/CSS Libraries</span>
<option value="cdn">CDN (Online)</option> <span class="settings-label-desc">Leaflet, Chart.js</span>
<option value="local">Local</option> </div>
</select> <select id="assetsSource" class="settings-select" onchange="Settings.setAssetSource(this.value)">
</div> <option value="cdn">CDN (Online)</option>
<option value="local">Local</option>
<div class="settings-row"> </select>
<div class="settings-label"> </div>
<span class="settings-label-text">Web Fonts</span>
<span class="settings-label-desc">Inter, JetBrains Mono</span> <div class="settings-row">
</div> <div class="settings-label">
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)"> <span class="settings-label-text">Web Fonts</span>
<option value="cdn">Google Fonts (Online)</option> <span class="settings-label-desc">Space Mono</span>
<option value="local">Local</option> </div>
</select> <select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
</div> <option value="cdn">Google Fonts (Online)</option>
</div> <option value="local">Local</option>
</select>
<div class="settings-group"> </div>
<div class="settings-group-title">Map Tiles</div> </div>
<div class="settings-row"> <div class="settings-group">
<div class="settings-label"> <div class="settings-group-title">Map Tiles</div>
<span class="settings-label-text">Tile Provider</span>
<span class="settings-label-desc">Map background imagery</span> <div class="settings-row">
</div> <div class="settings-label">
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)"> <span class="settings-label-text">Tile Provider</span>
<option value="openstreetmap">OpenStreetMap</option> <span class="settings-label-desc">Map background imagery</span>
<option value="cartodb_dark">CartoDB Dark</option> </div>
<option value="cartodb_light">CartoDB Positron</option> <select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
<option value="esri_world">ESRI World Imagery</option> <option value="openstreetmap">OpenStreetMap</option>
<option value="custom">Custom URL</option> <option value="cartodb_dark">CartoDB Dark</option>
</select> <option value="cartodb_light">CartoDB Positron</option>
</div> <option value="esri_world">ESRI World Imagery</option>
<option value="custom">Custom URL</option>
<div class="settings-row custom-url-row" id="customTileUrlRow" style="display: none;"> </select>
<div class="settings-label" style="width: 100%;"> </div>
<span class="settings-label-text">Custom Tile URL</span>
<span class="settings-label-desc">e.g., http://localhost:8080/{z}/{x}/{y}.png</span> <div class="settings-row custom-url-row" id="customTileUrlRow" style="display: none;">
<input type="text" id="customTileUrl" class="settings-input" <div class="settings-label" style="width: 100%;">
placeholder="http://tile-server/{z}/{x}/{y}.png" <span class="settings-label-text">Custom Tile URL</span>
onchange="Settings.setCustomTileUrl(this.value)"> <span class="settings-label-desc">e.g., http://localhost:8080/{z}/{x}/{y}.png</span>
</div> <input type="text" id="customTileUrl" class="settings-input"
</div> placeholder="http://tile-server/{z}/{x}/{y}.png"
</div> onchange="Settings.setCustomTileUrl(this.value)">
</div>
<div class="settings-group"> </div>
<div class="settings-group-title">Local Asset Status</div> </div>
<div class="asset-status" id="assetStatus">
<div class="asset-status-row"> <div class="settings-group">
<span class="asset-name">Leaflet JS/CSS</span> <div class="settings-group-title">Local Asset Status</div>
<span class="asset-badge checking" id="statusLeaflet">Checking...</span> <div class="asset-status" id="assetStatus">
</div> <div class="asset-status-row">
<div class="asset-status-row"> <span class="asset-name">Leaflet JS/CSS</span>
<span class="asset-name">Chart.js</span> <span class="asset-badge checking" id="statusLeaflet">Checking...</span>
<span class="asset-badge checking" id="statusChartjs">Checking...</span> </div>
</div> <div class="asset-status-row">
<div class="asset-status-row"> <span class="asset-name">Chart.js</span>
<span class="asset-name">Inter Font</span> <span class="asset-badge checking" id="statusChartjs">Checking...</span>
<span class="asset-badge checking" id="statusInter">Checking...</span> </div>
</div> <div class="asset-status-row">
<div class="asset-status-row"> <span class="asset-name">Inter Font</span>
<span class="asset-name">JetBrains Mono</span> <span class="asset-badge checking" id="statusInter">Checking...</span>
<span class="asset-badge checking" id="statusJetBrains">Checking...</span> </div>
</div> <div class="asset-status-row">
</div> <span class="asset-name">Space Mono</span>
<button class="check-assets-btn" onclick="Settings.checkAssets()"> <span class="asset-badge checking" id="statusJetbrains">Checking...</span>
Check Assets </div>
</button> </div>
</div> <button class="check-assets-btn" onclick="Settings.checkAssets()">
Check Assets
<div class="settings-info"> </button>
<strong>Note:</strong> Changes to asset sources require a page reload to take effect. </div>
Local assets must be available in <code>/static/vendor/</code>.
</div> <div class="settings-info">
</div> <strong>Note:</strong> Changes to asset sources require a page reload to take effect.
Local assets must be available in <code>/static/vendor/</code>.
<!-- Display Section --> </div>
<div id="settings-display" class="settings-section"> </div>
<div class="settings-group">
<div class="settings-group-title">Visual Preferences</div> <!-- Location Section -->
<div id="settings-location" class="settings-section">
<div class="settings-row"> <div class="settings-group">
<div class="settings-label"> <div class="settings-group-title">Observer Location</div>
<span class="settings-label-text">Theme</span> <p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
<span class="settings-label-desc">Color scheme preference</span> Set your geographic coordinates for satellite pass predictions and ISS tracking.
</div> </p>
<select id="themeSelect" class="settings-select" onchange="setThemePreference(this.value)">
<option value="dark">Dark</option> <div class="settings-row">
<option value="light">Light</option> <div class="settings-label">
</select> <span class="settings-label-text">Latitude</span>
</div> <span class="settings-label-desc">Decimal degrees (-90 to 90)</span>
</div>
<div class="settings-row"> <input type="number" id="observerLatInput" class="settings-input"
<div class="settings-label"> step="0.0001" min="-90" max="90" placeholder="51.5074"
<span class="settings-label-text">Animations</span> style="width: 120px; text-align: right;">
<span class="settings-label-desc">Enable visual effects and animations</span> </div>
</div>
<label class="toggle-switch"> <div class="settings-row">
<input type="checkbox" id="animationsEnabled" checked onchange="setAnimationsEnabled(this.checked)"> <div class="settings-label">
<span class="toggle-slider"></span> <span class="settings-label-text">Longitude</span>
</label> <span class="settings-label-desc">Decimal degrees (-180 to 180)</span>
</div> </div>
</div> <input type="number" id="observerLonInput" class="settings-input"
</div> step="0.0001" min="-180" max="180" placeholder="-0.1278"
style="width: 120px; text-align: right;">
<!-- About Section --> </div>
<div id="settings-about" class="settings-section">
<div class="settings-group"> <div style="display: flex; gap: 10px; margin-top: 15px;">
<div class="about-info"> <button class="check-assets-btn" onclick="detectLocationGPS(this)" style="flex: 1;">
<p><strong>iNTERCEPT</strong> - Signal Intelligence Platform</p> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; vertical-align: -2px; margin-right: 5px;">
<p>Version: <span class="about-version">{{ version }}</span></p> <circle cx="12" cy="12" r="10"/>
<p> <circle cx="12" cy="12" r="3"/>
A unified web interface for software-defined radio (SDR) tools, <line x1="12" y1="2" x2="12" y2="6"/>
supporting pager decoding, sensor monitoring, aircraft tracking, <line x1="12" y1="18" x2="12" y2="22"/>
WiFi/Bluetooth scanning, and more. <line x1="2" y1="12" x2="6" y2="12"/>
</p> <line x1="18" y1="12" x2="22" y2="12"/>
<p> </svg>
<a href="https://github.com/intercept" target="_blank">GitHub Repository</a> Use GPS
</p> </button>
</div> <button class="check-assets-btn" onclick="saveObserverLocation()" style="flex: 1; background: var(--accent-cyan); color: #000;">
</div> Save Location
</div> </button>
</div> </div>
</div> </div>
<div class="settings-group">
<div class="settings-group-title">Current Location</div>
<div id="currentLocationDisplay" style="padding: 12px; background: var(--bg-tertiary); border-radius: 6px; font-family: var(--font-mono); font-size: 12px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
<span style="color: var(--text-dim);">Latitude</span>
<span id="currentLatDisplay" style="color: var(--accent-cyan);">Not set</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-dim);">Longitude</span>
<span id="currentLonDisplay" style="color: var(--accent-cyan);">Not set</span>
</div>
</div>
</div>
<div class="settings-info">
<strong>Note:</strong> Location is used for ISS pass predictions in SSTV mode and satellite tracking.
Your location is stored locally and never sent to external servers.
</div>
</div>
<!-- Display Section -->
<div id="settings-display" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Visual Preferences</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Theme</span>
<span class="settings-label-desc">Color scheme preference</span>
</div>
<select id="themeSelect" class="settings-select" onchange="setThemePreference(this.value)">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Animations</span>
<span class="settings-label-desc">Enable visual effects and animations</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="animationsEnabled" checked onchange="setAnimationsEnabled(this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- Updates Section -->
<div id="settings-updates" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Update Status</div>
<div id="updateStatusContent" style="padding: 10px 0;">
<div style="text-align: center; padding: 20px; color: var(--text-dim);">
Loading update status...
</div>
</div>
<button class="check-assets-btn" onclick="checkForUpdatesManual()" style="margin-top: 10px;">
Check Now
</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Update Settings</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Auto-Check for Updates</span>
<span class="settings-label-desc">Periodically check GitHub for new releases</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="updateCheckEnabled" checked onchange="toggleUpdateCheck(this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="settings-info">
<strong>Note:</strong> Updates are fetched from GitHub and applied via git pull.
Make sure you have git installed and the application is in a git repository.
</div>
</div>
<!-- Tools Section -->
<div id="settings-tools" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Tool Dependencies</div>
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
Check which external tools are installed for each mode.
<span style="color: var(--accent-green);"></span> = Installed,
<span style="color: var(--accent-red);"></span> = Missing
</p>
<div id="settingsToolsContent" style="max-height: 45vh; overflow-y: auto;">
<div style="text-align: center; padding: 30px; color: var(--text-dim);">
Loading dependencies...
</div>
</div>
</div>
<div class="settings-group" style="margin-top: 15px;">
<div class="settings-group-title">Quick Install (Debian/Ubuntu)</div>
<div style="background: var(--bg-tertiary); padding: 10px; border-radius: 4px; font-family: var(--font-mono); font-size: 10px; overflow-x: auto;">
<div>sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxdumptool hcxtools</div>
<div style="margin-top: 5px;">pip install skyfield flask</div>
</div>
<div style="margin-top: 10px; font-size: 11px; color: var(--text-dim);">
<strong>Note:</strong> ACARS decoding requires <code>acarsdec</code> which must be built from source.
See <a href="https://github.com/TLeconte/acarsdec" target="_blank" style="color: var(--accent-cyan);">github.com/TLeconte/acarsdec</a> or run <code>./setup.sh</code> for automated installation.
</div>
</div>
</div>
<!-- About Section -->
<div id="settings-about" class="settings-section">
<div class="settings-group">
<div class="about-info">
<p><strong>iNTERCEPT</strong> - Signal Intelligence Platform</p>
<p>Version: <span class="about-version">{{ version }}</span></p>
<p>
A unified web interface for software-defined radio (SDR) tools,
supporting pager decoding, sensor monitoring, aircraft tracking,
WiFi/Bluetooth scanning, and more.
</p>
<p>
<a href="https://github.com/smittix/intercept" target="_blank">GitHub Repository</a>
</p>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Support the Project</div>
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
If you find iNTERCEPT useful, consider supporting its development.
</p>
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="donate-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 18px; height: 18px; vertical-align: -3px; margin-right: 8px;">
<path d="M17 8h1a4 4 0 1 1 0 8h-1"/>
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/>
<line x1="6" y1="2" x2="6" y2="4"/>
<line x1="10" y1="2" x2="10" y2="4"/>
<line x1="14" y1="2" x2="14" y2="4"/>
</svg>
Buy Me a Coffee
</a>
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+587
View File
@@ -0,0 +1,587 @@
"""
Unit tests for deauthentication attack detector.
"""
import os
import sys
import time
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from utils.wifi.deauth_detector import (
DeauthDetector,
DeauthPacketInfo,
DeauthTracker,
DeauthAlert,
DEAUTH_REASON_CODES,
)
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
)
class TestDeauthPacketInfo:
"""Tests for DeauthPacketInfo dataclass."""
def test_creation(self):
"""Test basic creation of packet info."""
pkt = DeauthPacketInfo(
timestamp=1234567890.0,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
signal_dbm=-45,
)
assert pkt.frame_type == 'deauth'
assert pkt.src_mac == 'AA:BB:CC:DD:EE:FF'
assert pkt.reason_code == 7
assert pkt.signal_dbm == -45
class TestDeauthTracker:
"""Tests for DeauthTracker."""
def test_add_packet(self):
"""Test adding packets to tracker."""
tracker = DeauthTracker()
pkt1 = DeauthPacketInfo(
timestamp=100.0,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
)
tracker.add_packet(pkt1)
assert len(tracker.packets) == 1
assert tracker.first_seen == 100.0
assert tracker.last_seen == 100.0
def test_multiple_packets(self):
"""Test adding multiple packets."""
tracker = DeauthTracker()
for i in range(5):
pkt = DeauthPacketInfo(
timestamp=100.0 + i,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
)
tracker.add_packet(pkt)
assert len(tracker.packets) == 5
assert tracker.first_seen == 100.0
assert tracker.last_seen == 104.0
def test_get_packets_in_window(self):
"""Test filtering packets by time window."""
tracker = DeauthTracker()
now = time.time()
# Add old packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 10,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# Add recent packets
for i in range(3):
tracker.add_packet(DeauthPacketInfo(
timestamp=now - i,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# 5-second window should only include the 3 recent packets
in_window = tracker.get_packets_in_window(5.0)
assert len(in_window) == 3
def test_cleanup_old_packets(self):
"""Test removing old packets."""
tracker = DeauthTracker()
now = time.time()
# Add old packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 20,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# Add recent packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
tracker.alert_sent = True
# Cleanup with 10-second window
tracker.cleanup_old_packets(10.0)
assert len(tracker.packets) == 1
assert tracker.packets[0].timestamp == now
def test_cleanup_resets_alert_sent(self):
"""Test that cleanup resets alert_sent when all packets removed."""
tracker = DeauthTracker()
now = time.time()
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 100, # Very old
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
tracker.alert_sent = True
# Cleanup should remove all packets
tracker.cleanup_old_packets(10.0)
assert len(tracker.packets) == 0
assert tracker.alert_sent is False
class TestDeauthAlert:
"""Tests for DeauthAlert."""
def test_to_dict(self):
"""Test conversion to dictionary."""
alert = DeauthAlert(
id='deauth-123-1',
timestamp=1234567890.0,
severity='high',
attacker_mac='AA:BB:CC:DD:EE:FF',
attacker_vendor='Unknown',
attacker_signal_dbm=-45,
is_spoofed_ap=True,
target_mac='11:22:33:44:55:66',
target_vendor='Apple',
target_type='client',
target_known_from_scan=True,
ap_bssid='AA:BB:CC:DD:EE:FF',
ap_essid='TestNetwork',
ap_channel=6,
frame_type='deauth',
reason_code=7,
reason_text='Class 3 frame received from nonassociated STA',
packet_count=50,
window_seconds=5.0,
packets_per_second=10.0,
attack_type='targeted',
description='Targeted deauth flood against known client',
)
d = alert.to_dict()
assert d['id'] == 'deauth-123-1'
assert d['type'] == 'deauth_alert'
assert d['severity'] == 'high'
assert d['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
assert d['attacker']['is_spoofed_ap'] is True
assert d['target']['type'] == 'client'
assert d['access_point']['essid'] == 'TestNetwork'
assert d['attack_info']['packet_count'] == 50
assert d['analysis']['attack_type'] == 'targeted'
class TestDeauthDetector:
"""Tests for DeauthDetector."""
def test_init(self):
"""Test detector initialization."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
assert detector.interface == 'wlan0mon'
assert detector.event_callback == callback
assert not detector.is_running
def test_stats(self):
"""Test stats property."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
stats = detector.stats
assert stats['is_running'] is False
assert stats['interface'] == 'wlan0mon'
assert stats['packets_captured'] == 0
assert stats['alerts_generated'] == 0
def test_get_alerts_empty(self):
"""Test getting alerts when none exist."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
alerts = detector.get_alerts()
assert alerts == []
def test_clear_alerts(self):
"""Test clearing alerts."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Add a mock alert
detector._alerts.append(MagicMock())
detector._trackers[('A', 'B', 'C')] = DeauthTracker()
detector._alert_counter = 5
detector.clear_alerts()
assert len(detector._alerts) == 0
assert len(detector._trackers) == 0
assert detector._alert_counter == 0
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_severity_low(self, mock_time):
"""Test alert generation with low severity."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create packets just at threshold
packets = []
for i in range(DEAUTH_ALERT_THRESHOLD):
packets.append(DeauthPacketInfo(
timestamp=1000.0 - (DEAUTH_ALERT_THRESHOLD - 1 - i) * 0.1,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
signal_dbm=-50,
))
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
packets=packets,
packet_count=DEAUTH_ALERT_THRESHOLD,
)
assert alert.severity == 'low'
assert alert.packet_count == DEAUTH_ALERT_THRESHOLD
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_severity_high(self, mock_time):
"""Test alert generation with high severity."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create packets above critical threshold
packets = []
for i in range(DEAUTH_CRITICAL_THRESHOLD):
packets.append(DeauthPacketInfo(
timestamp=1000.0 - (DEAUTH_CRITICAL_THRESHOLD - 1 - i) * 0.1,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
))
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
packets=packets,
packet_count=DEAUTH_CRITICAL_THRESHOLD,
)
assert alert.severity == 'high'
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_broadcast_attack(self, mock_time):
"""Test alert classification for broadcast attack."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
packets = [DeauthPacketInfo(
timestamp=999.9,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='FF:FF:FF:FF:FF:FF', # Broadcast
bssid='99:88:77:66:55:44',
reason_code=7,
)]
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', 'FF:FF:FF:FF:FF:FF', '99:88:77:66:55:44'),
packets=packets,
packet_count=10,
)
assert alert.attack_type == 'broadcast'
assert alert.target_type == 'broadcast'
assert 'all clients' in alert.description.lower()
def test_lookup_ap_no_callback(self):
"""Test AP lookup when no callback is provided."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=None,
)
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
assert result['essid'] is None
assert result['channel'] is None
def test_lookup_ap_with_callback(self):
"""Test AP lookup with callback."""
callback = MagicMock()
get_networks = MagicMock(return_value={
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet', 'channel': 6}
})
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=get_networks,
)
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
assert result['essid'] == 'TestNet'
assert result['channel'] == 6
def test_check_spoofed_source(self):
"""Test detection of spoofed AP source."""
callback = MagicMock()
get_networks = MagicMock(return_value={
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet'}
})
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=get_networks,
)
# Source matches known AP - spoofed
assert detector._check_spoofed_source('AA:BB:CC:DD:EE:FF') is True
# Source does not match any AP - not spoofed
assert detector._check_spoofed_source('11:22:33:44:55:66') is False
def test_cleanup_old_trackers(self):
"""Test cleanup of old trackers."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
now = time.time()
# Add an old tracker
old_tracker = DeauthTracker()
old_tracker.add_packet(DeauthPacketInfo(
timestamp=now - 100, # Very old
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
))
detector._trackers[('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')] = old_tracker
# Add a recent tracker
recent_tracker = DeauthTracker()
recent_tracker.add_packet(DeauthPacketInfo(
timestamp=now,
frame_type='deauth',
src_mac='BB:CC:DD:EE:FF:AA',
dst_mac='22:33:44:55:66:77',
bssid='88:77:66:55:44:33',
reason_code=7,
))
detector._trackers[('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33')] = recent_tracker
detector._cleanup_old_trackers()
# Old tracker should be removed
assert ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44') not in detector._trackers
# Recent tracker should remain
assert ('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33') in detector._trackers
class TestReasonCodes:
"""Tests for reason code dictionary."""
def test_common_reason_codes(self):
"""Test that common reason codes are defined."""
assert 1 in DEAUTH_REASON_CODES # Unspecified
assert 7 in DEAUTH_REASON_CODES # Class 3 frame
assert 14 in DEAUTH_REASON_CODES # MIC failure
def test_reason_code_descriptions(self):
"""Test reason code descriptions are strings."""
for code, desc in DEAUTH_REASON_CODES.items():
assert isinstance(code, int)
assert isinstance(desc, str)
assert len(desc) > 0
class TestDeauthDetectorIntegration:
"""Integration tests for DeauthDetector with mocked scapy."""
@patch('utils.wifi.deauth_detector.time.time')
def test_process_deauth_packet_generates_alert(self, mock_time):
"""Test that processing packets generates alert when threshold exceeded."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create a mock scapy packet
mock_pkt = MagicMock()
# Mock Dot11Deauth layer
mock_deauth = MagicMock()
mock_deauth.reason = 7
# Mock Dot11 layer
mock_dot11 = MagicMock()
mock_dot11.addr1 = '11:22:33:44:55:66' # dst
mock_dot11.addr2 = 'AA:BB:CC:DD:EE:FF' # src
mock_dot11.addr3 = '99:88:77:66:55:44' # bssid
# Mock RadioTap layer
mock_radiotap = MagicMock()
mock_radiotap.dBm_AntSignal = -50
# Set up haslayer behavior
def haslayer_side_effect(layer):
if 'Dot11Deauth' in str(layer):
return True
if 'Dot11Disas' in str(layer):
return False
if 'RadioTap' in str(layer):
return True
return False
mock_pkt.haslayer = haslayer_side_effect
# Set up __getitem__ behavior
def getitem_side_effect(layer):
if 'Dot11Deauth' in str(layer):
return mock_deauth
if 'Dot11' in str(layer) and 'Deauth' not in str(layer):
return mock_dot11
if 'RadioTap' in str(layer):
return mock_radiotap
return MagicMock()
mock_pkt.__getitem__ = getitem_side_effect
# Patch the scapy imports inside _process_deauth_packet
with patch('utils.wifi.deauth_detector.DeauthDetector._process_deauth_packet.__globals__', {
'Dot11': MagicMock,
'Dot11Deauth': MagicMock,
'Dot11Disas': MagicMock,
'RadioTap': MagicMock,
}):
# Process enough packets to trigger alert
for i in range(DEAUTH_ALERT_THRESHOLD + 5):
mock_time.return_value = 1000.0 + i * 0.1
# Manually simulate what _process_deauth_packet does
pkt_info = DeauthPacketInfo(
timestamp=mock_time.return_value,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
signal_dbm=-50,
)
detector._packets_captured += 1
tracker_key = ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')
tracker = detector._trackers[tracker_key]
tracker.add_packet(pkt_info)
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
packet_count = len(packets_in_window)
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
alert = detector._generate_alert(
tracker_key=tracker_key,
packets=packets_in_window,
packet_count=packet_count,
)
detector._alerts.append(alert)
detector._alerts_generated += 1
tracker.alert_sent = True
detector.event_callback(alert.to_dict())
# Verify alert was generated
assert detector._alerts_generated == 1
assert len(detector._alerts) == 1
assert callback.called
# Verify callback was called with alert data
call_args = callback.call_args[0][0]
assert call_args['type'] == 'deauth_alert'
assert call_args['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
assert call_args['target']['mac'] == '11:22:33:44:55:66'

Some files were not shown because too many files have changed in this diff Show More