mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 12:25:40 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301f04e3de | ||
|
|
0b14da8f4f | ||
|
|
5ff9ba7a53 | ||
|
|
8770d2b3e0 | ||
|
|
ae40726c71 | ||
|
|
d3c4fdc6d6 | ||
|
|
cb3ce5e764 | ||
|
|
044ffd34e2 | ||
|
|
28b2756f40 | ||
|
|
4fc12383fa | ||
|
|
f2c7c48eed | ||
|
|
e027beeb38 | ||
|
|
748862db9c | ||
|
|
036078e1ce | ||
|
|
60a20d4190 | ||
|
|
9aa185ef09 | ||
|
|
89b4ee817e | ||
|
|
48de8f99b3 | ||
|
|
c13de6f7d7 | ||
|
|
e04324a4c9 | ||
|
|
871d6953ed | ||
|
|
e5f808b078 | ||
|
|
3650007f06 | ||
|
|
eca41c466f | ||
|
|
074dd736d9 | ||
|
|
bfc1acbbe6 | ||
|
|
f16fce8b7f | ||
|
|
a892582821 | ||
|
|
3e2f7a9afe | ||
|
|
56a09d180d | ||
|
|
f1bcb95ee5 | ||
|
|
011294c0fa | ||
|
|
b97212087d | ||
|
|
68f36d9ecf | ||
|
|
5d20269d05 | ||
|
|
918589fc8c | ||
|
|
f2c6186d8c | ||
|
|
6a0c0770b4 | ||
|
|
c4c06e7fb8 | ||
|
|
502244fc38 | ||
|
|
0073504657 | ||
|
|
b4ce4ede42 | ||
|
|
6362c4338a | ||
|
|
fb57670f74 | ||
|
|
1666f7c5d7 | ||
|
|
feceadf432 | ||
|
|
5e81ad6c87 | ||
|
|
f1cf759ebd | ||
|
|
da19ddef51 | ||
|
|
b461a05b6d | ||
|
|
9916a9d59f | ||
|
|
f979743727 | ||
|
|
056410a850 | ||
|
|
142bbabcc3 | ||
|
|
da315aac94 | ||
|
|
db9219319d | ||
|
|
db7f394a6a | ||
|
|
e267a99274 | ||
|
|
e36c6cca49 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ data/
|
||||
config.json
|
||||
data-lincomatic/
|
||||
config-lincomatic.json
|
||||
theme.json
|
||||
|
||||
152
CUSTOMIZATION-PLAN.md
Normal file
152
CUSTOMIZATION-PLAN.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# CUSTOMIZATION-PLAN.md — White-Label / Multi-Instance Theming
|
||||
|
||||
## Status: Phase 1 Complete (v2.6.0+)
|
||||
|
||||
### What's Built
|
||||
- Floating draggable customizer panel (🎨 in nav)
|
||||
- Basic (7 colors) + Advanced (12 colors + fonts) with light/dark mode
|
||||
- Node role colors + packet type colors
|
||||
- Branding (site name, logo, favicon)
|
||||
- Home page content editor with markdown support
|
||||
- Auto-save to localStorage + admin JSON export
|
||||
- Colors restore on page load before any rendering
|
||||
|
||||
### Known Bugs to Fix
|
||||
- Nav background sometimes doesn't repaint (gradient caching)
|
||||
- Some pages may flash default colors before customization applies
|
||||
- Color picker dragging can still feel sluggish on complex pages
|
||||
- Reset preview may not fully restore all derived variables
|
||||
|
||||
### Next Round: Phase 2
|
||||
- **Click-to-identify**: Click any UI element → customizer scrolls to the setting that controls it (like DevTools inspect but for theme colors)
|
||||
- **Theme presets**: Built-in themes (Default, Cascadia Navy, Forest Green, Midnight) — one-click switch
|
||||
- **Import config**: Paste JSON to load a theme (reverse of export)
|
||||
- **Preview home page changes live** without navigating away
|
||||
- Fix remaining 8 hardcoded colors from audit (nav stats, trace labels, rec-dot)
|
||||
- Hex viewer color customization (Advanced section)
|
||||
|
||||
### Architecture Notes
|
||||
- `customize.js` MUST load right after `roles.js`, before `app.js` — color restore timing is critical
|
||||
- `syncBadgeColors()` in roles.js is the single source for badge CSS
|
||||
- `ROLE_STYLE[role].color` must be updated alongside `ROLE_COLORS[role]`
|
||||
- Auto-save debounced 500ms, theme-refresh debounced 300ms
|
||||
|
||||
## Problem
|
||||
|
||||
Regional mesh admins (e.g. CascadiaMesh) fork the analyzer and manually edit CSS/HTML to customize branding, colors, and content. This is fragile — every upstream update requires re-applying customizations.
|
||||
|
||||
## Goal
|
||||
|
||||
A `config.json`-driven customization system where admins configure branding, colors, labels, and home page content without touching source code. Accessible via a **Tools → Customization** UI that outputs the config.
|
||||
|
||||
## Direct Feedback (CascadiaMesh Admin)
|
||||
|
||||
Customizations they made manually:
|
||||
- **Branding**: Custom logo, favicon, site title ("CascadiaMesh Analyzer")
|
||||
- **Colors**: Node type colors (repeaters blue instead of red, companions red)
|
||||
- **UI styling**: Custom color scheme (deep navy theme — "Cascadia" theme)
|
||||
- **Home page**: Intro section emojis, steps, checklist content
|
||||
|
||||
Requested config options:
|
||||
- Configurable branding assets (logo, favicon, site name)
|
||||
- Configurable UI colors/text labels
|
||||
- Configurable node type colors
|
||||
- Everything in the intro/home section should be configurable
|
||||
|
||||
## Config Schema (proposed)
|
||||
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "CascadiaMesh Analyzer",
|
||||
"logoUrl": "/assets/logo.png",
|
||||
"faviconUrl": "/assets/favicon.ico",
|
||||
"tagline": "Pacific Northwest Mesh Network Monitor"
|
||||
},
|
||||
"theme": {
|
||||
"accent": "#20468b",
|
||||
"accentHover": "#2d5bb0",
|
||||
"navBg": "#111c36",
|
||||
"navBg2": "#060a13",
|
||||
"statusGreen": "#45644c",
|
||||
"statusYellow": "#b08b2d",
|
||||
"statusRed": "#b54a4a"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "#3b82f6",
|
||||
"companion": "#ef4444",
|
||||
"room": "#8b5cf6",
|
||||
"sensor": "#10b981",
|
||||
"observer": "#f59e0b"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "CascadiaMesh Network Monitor",
|
||||
"heroSubtitle": "Real-time packet analysis for the Pacific Northwest mesh",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" },
|
||||
{ "emoji": "📊", "title": "Analyze", "description": "Understand your network's health" }
|
||||
],
|
||||
"checklist": [
|
||||
{ "question": "How do I add my node?", "answer": "..." },
|
||||
{ "question": "What regions are covered?", "answer": "..." }
|
||||
],
|
||||
"footerLinks": [
|
||||
{ "label": "Discord", "url": "https://discord.gg/..." },
|
||||
{ "label": "GitHub", "url": "https://github.com/..." }
|
||||
]
|
||||
},
|
||||
"labels": {
|
||||
"latestPackets": "Latest Packets",
|
||||
"liveMap": "Live Map"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Config Loading + CSS Variables (Server)
|
||||
- Server reads `config.json` theme section
|
||||
- New endpoint: `GET /api/config/theme` returns merged theme config
|
||||
- Client injects CSS variables from theme config on page load
|
||||
- Node type colors configurable via `window.TYPE_COLORS` override
|
||||
|
||||
### Phase 2: Branding
|
||||
- Config drives nav bar title, logo, favicon
|
||||
- `index.html` rendered server-side with branding placeholders OR
|
||||
- Client JS replaces branding elements on load from `/api/config/theme`
|
||||
|
||||
### Phase 3: Home Page Content
|
||||
- Home page sections (hero, steps, checklist, footer) driven by config
|
||||
- Default content baked in; config overrides specific sections
|
||||
- Emoji + text for each step configurable
|
||||
|
||||
### Phase 4: Tools → Customization UI
|
||||
- New page `#/customize` (admin only?)
|
||||
- Color pickers for theme variables
|
||||
- Live preview
|
||||
- Branding upload (logo, favicon)
|
||||
- Export as JSON config
|
||||
- Home page content editor (WYSIWYG-lite)
|
||||
|
||||
### Phase 5: CSS Theme Presets
|
||||
- Built-in themes: Default (blue), Cascadia (navy), Forest (green), Midnight (dark)
|
||||
- One-click theme switching
|
||||
- Custom theme = override any variable
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- Theme CSS variables are already in `:root {}` — just need to override from config
|
||||
- Node type colors used in `roles.js` via `TYPE_COLORS` — make configurable
|
||||
- Home page content is in `home.js` — extract to template driven by config
|
||||
- Logo/favicon: serve from config-specified path, default to built-in
|
||||
- No build step — pure runtime configuration
|
||||
- Config changes take effect on page reload (no server restart needed for theme)
|
||||
|
||||
## Priority
|
||||
|
||||
1. Theme colors (CSS variables from config) — highest impact, lowest effort
|
||||
2. Branding (site name, logo) — visible, requested
|
||||
3. Node type colors — requested specifically
|
||||
4. Home page content — requested
|
||||
5. Customization UI — nice to have, lower priority
|
||||
19
README.md
19
README.md
@@ -120,11 +120,26 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v $(pwd)/config.json:/app/config.json \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
**Persist your database** across container rebuilds by using a named volume (`meshcore-data`) or bind mount (`-v ./data:/app/data`).
|
||||
Config lives in the data volume at `/app/data/config.json` — a default is created on first run. To edit it:
|
||||
```bash
|
||||
docker exec -it meshcore-analyzer vi /app/data/config.json
|
||||
```
|
||||
|
||||
Or use a bind mount for the data directory:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 3000:3000 \
|
||||
-p 1883:1883 \
|
||||
-v ./data:/app/data \
|
||||
meshcore-analyzer
|
||||
# Now edit ./data/config.json directly on the host
|
||||
```
|
||||
|
||||
**Theme customization:** Put `theme.json` next to `config.json` — wherever your config lives, that's where the theme goes. Use the built-in customizer (Tools → Customize) to design your theme, download the file, and drop it in. Changes are picked up on page refresh — no restart needed. The server logs where it's looking on startup.
|
||||
|
||||
### Manual Install
|
||||
|
||||
|
||||
@@ -5,6 +5,48 @@
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem"
|
||||
},
|
||||
"branding": {
|
||||
"siteName": "MeshCore Analyzer",
|
||||
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
|
||||
"logoUrl": null,
|
||||
"faviconUrl": null
|
||||
},
|
||||
"theme": {
|
||||
"accent": "#4a9eff",
|
||||
"accentHover": "#6db3ff",
|
||||
"navBg": "#0f0f23",
|
||||
"navBg2": "#1a1a2e",
|
||||
"statusGreen": "#45644c",
|
||||
"statusYellow": "#b08b2d",
|
||||
"statusRed": "#b54a4a"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "#dc2626",
|
||||
"companion": "#2563eb",
|
||||
"room": "#16a34a",
|
||||
"sensor": "#d97706",
|
||||
"observer": "#8b5cf6"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "MeshCore Analyzer",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" },
|
||||
{ "emoji": "📊", "title": "Analyze", "description": "Understand your network's health" }
|
||||
],
|
||||
"checklist": [
|
||||
{ "question": "How do I add my node?", "answer": "Search for your node name or paste your public key." },
|
||||
{ "question": "What regions are covered?", "answer": "Check the map page to see active observers and nodes." }
|
||||
],
|
||||
"footerLinks": [
|
||||
{ "label": "📦 Packets", "url": "#/packets" },
|
||||
{ "label": "🗺️ Network Map", "url": "#/map" },
|
||||
{ "label": "🔴 Live", "url": "#/live" },
|
||||
{ "label": "📡 All Nodes", "url": "#/nodes" },
|
||||
{ "label": "💬 Channels", "url": "#/channels" }
|
||||
]
|
||||
},
|
||||
"mqtt": {
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copy example config if no config.json exists
|
||||
# Copy example config if no config.json exists at app root (not bind-mounted)
|
||||
if [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found, copying from config.example.json"
|
||||
cp /app/config.example.json /app/config.json
|
||||
fi
|
||||
|
||||
# theme.json: check data/ volume (admin-editable on host)
|
||||
if [ -f /app/data/theme.json ]; then
|
||||
ln -sf /app/data/theme.json /app/theme.json
|
||||
fi
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
215
docs/CUSTOMIZATION.md
Normal file
215
docs/CUSTOMIZATION.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Customizing Your Instance
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Open your analyzer in a browser
|
||||
2. Go to **Tools → Customize**
|
||||
3. Change colors, branding, home page content
|
||||
4. Click **💾 Download theme.json**
|
||||
5. Put the file next to your `config.json` on the server
|
||||
6. Refresh the page — done
|
||||
|
||||
No restart needed. The server picks up changes to `theme.json` on every page load.
|
||||
|
||||
## Where Does theme.json Go?
|
||||
|
||||
**Next to config.json.** However you deployed, put them side by side.
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
# Add to your docker run command:
|
||||
-v /path/to/theme.json:/app/theme.json:ro
|
||||
|
||||
# Or if you bind-mount the data directory:
|
||||
# Just put theme.json in that directory
|
||||
```
|
||||
|
||||
**Bare metal / PM2 / systemd:**
|
||||
```bash
|
||||
# Same directory as server.js and config.json
|
||||
cp theme.json /path/to/meshcore-analyzer/
|
||||
```
|
||||
|
||||
Check the server logs on startup — it tells you where it's looking:
|
||||
```
|
||||
[theme] Loaded from /app/theme.json
|
||||
```
|
||||
or:
|
||||
```
|
||||
[theme] No theme.json found. Place it next to config.json or in data/ to customize.
|
||||
```
|
||||
|
||||
## What Can You Customize?
|
||||
|
||||
### Branding
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "Bay Area Mesh",
|
||||
"tagline": "Community LoRa mesh network",
|
||||
"logoUrl": "/my-logo.svg",
|
||||
"faviconUrl": "/my-favicon.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logo replaces the 🍄 emoji in the nav bar (renders at 24px height). Favicon replaces the browser tab icon. Use a URL path for files in the `public/` folder, or a full URL for external images.
|
||||
|
||||
### Theme Colors (Light Mode)
|
||||
```json
|
||||
{
|
||||
"theme": {
|
||||
"accent": "#ff6b6b",
|
||||
"navBg": "#1a1a2e",
|
||||
"navText": "#ffffff",
|
||||
"background": "#f4f5f7",
|
||||
"text": "#1a1a2e",
|
||||
"statusGreen": "#22c55e",
|
||||
"statusYellow": "#eab308",
|
||||
"statusRed": "#ef4444"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Colors (Dark Mode)
|
||||
```json
|
||||
{
|
||||
"themeDark": {
|
||||
"accent": "#57f2a5",
|
||||
"navBg": "#0a0a1a",
|
||||
"background": "#0f0f23",
|
||||
"text": "#e2e8f0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only include colors you want to change — everything else stays default.
|
||||
|
||||
### All Available Theme Keys
|
||||
|
||||
| Key | What It Controls |
|
||||
|-----|-----------------|
|
||||
| `accent` | Buttons, links, active tabs, badges, charts |
|
||||
| `accentHover` | Hover state for accent elements |
|
||||
| `navBg` | Nav bar background (gradient start) |
|
||||
| `navBg2` | Nav bar gradient end |
|
||||
| `navText` | Nav bar text and links |
|
||||
| `navTextMuted` | Inactive nav links, stats |
|
||||
| `background` | Main page background |
|
||||
| `text` | Primary text color |
|
||||
| `textMuted` | Labels, timestamps, secondary text |
|
||||
| `statusGreen` | Healthy/online indicators |
|
||||
| `statusYellow` | Warning/degraded indicators |
|
||||
| `statusRed` | Error/offline indicators |
|
||||
| `border` | Dividers, table borders |
|
||||
| `surface1` | Card backgrounds |
|
||||
| `surface2` | Nested panels |
|
||||
| `cardBg` | Detail panels, modals |
|
||||
| `contentBg` | Content area behind cards |
|
||||
| `detailBg` | Side panels, packet detail |
|
||||
| `inputBg` | Text inputs, dropdowns |
|
||||
| `rowStripe` | Alternating table rows |
|
||||
| `rowHover` | Table row hover |
|
||||
| `selectedBg` | Selected/active rows |
|
||||
| `font` | Body font stack |
|
||||
| `mono` | Monospace font (hex, hashes, code) |
|
||||
|
||||
### Node Role Colors
|
||||
```json
|
||||
{
|
||||
"nodeColors": {
|
||||
"repeater": "#dc2626",
|
||||
"companion": "#2563eb",
|
||||
"room": "#16a34a",
|
||||
"sensor": "#d97706",
|
||||
"observer": "#8b5cf6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Affects map markers, packet path badges, node lists, and legends.
|
||||
|
||||
### Packet Type Colors
|
||||
```json
|
||||
{
|
||||
"typeColors": {
|
||||
"ADVERT": "#22c55e",
|
||||
"GRP_TXT": "#3b82f6",
|
||||
"TXT_MSG": "#f59e0b",
|
||||
"ACK": "#6b7280",
|
||||
"REQUEST": "#a855f7",
|
||||
"RESPONSE": "#06b6d4",
|
||||
"TRACE": "#ec4899",
|
||||
"PATH": "#14b8a6",
|
||||
"ANON_REQ": "#f43f5e"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Affects packet badges, feed dots, map markers, and chart colors.
|
||||
|
||||
### Home Page Content
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"heroTitle": "Welcome to Bay Area Mesh",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" }
|
||||
],
|
||||
"checklist": [
|
||||
{ "question": "How do I add my node?", "answer": "Search by name or paste your public key." }
|
||||
],
|
||||
"footerLinks": [
|
||||
{ "label": "📦 Packets", "url": "#/packets" },
|
||||
{ "label": "🗺️ Map", "url": "#/map" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Step descriptions and checklist answers support Markdown (`**bold**`, `*italic*`, `` `code` ``, `[links](url)`).
|
||||
|
||||
## User vs Admin Themes
|
||||
|
||||
- **Admin theme** (`theme.json`): Default for all users. Edit the file, refresh.
|
||||
- **User theme** (browser): Each user can override the admin theme via Tools → Customize → "Save as my theme". Stored in localStorage, only affects that browser.
|
||||
|
||||
User themes take priority over admin themes. Users can reset their personal theme to go back to the admin default.
|
||||
|
||||
## Full Example
|
||||
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "Bay Area MeshCore",
|
||||
"tagline": "Community mesh monitoring for the Bay Area",
|
||||
"logoUrl": "https://example.com/logo.svg"
|
||||
},
|
||||
"theme": {
|
||||
"accent": "#2563eb",
|
||||
"statusGreen": "#16a34a",
|
||||
"statusYellow": "#ca8a04",
|
||||
"statusRed": "#dc2626"
|
||||
},
|
||||
"themeDark": {
|
||||
"accent": "#60a5fa",
|
||||
"navBg": "#0a0a1a",
|
||||
"background": "#111827"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "#ef4444",
|
||||
"observer": "#a855f7"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "Bay Area MeshCore",
|
||||
"heroSubtitle": "Real-time monitoring for our community mesh network.",
|
||||
"steps": [
|
||||
{ "emoji": "💬", "title": "Join our Discord", "description": "Get help and connect with local operators." },
|
||||
{ "emoji": "📡", "title": "Advertise your node", "description": "Send an ADVERT so the network can see you." },
|
||||
{ "emoji": "🗺️", "title": "Check the map", "description": "Find repeaters near you." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -6,6 +6,14 @@
|
||||
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// --- Status color helpers (read from CSS variables for theme support) ---
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||||
function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
|
||||
function statusRed() { return cssVar('--status-red') || '#ef4444'; }
|
||||
function accentColor() { return cssVar('--accent') || '#4a9eff'; }
|
||||
function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }
|
||||
|
||||
// --- SVG helpers ---
|
||||
function sparkSvg(data, color, w = 120, h = 32) {
|
||||
if (!data.length) return '';
|
||||
@@ -247,8 +255,8 @@
|
||||
|
||||
// ===================== RF / SIGNAL =====================
|
||||
function renderRF(el, rf) {
|
||||
const snrHist = histogram(rf.snrValues, 20, '#22c55e');
|
||||
const rssiHist = histogram(rf.rssiValues, 20, '#3b82f6');
|
||||
const snrHist = histogram(rf.snrValues, 20, statusGreen());
|
||||
const rssiHist = histogram(rf.rssiValues, 20, accentColor());
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="analytics-row">
|
||||
@@ -329,20 +337,21 @@
|
||||
svg += `<text x="${pad-4}" y="${y+3}" text-anchor="end" font-size="9" fill="var(--text-muted)">${rssi}</text>`;
|
||||
}
|
||||
// Quality zones
|
||||
const _sg = statusGreen(), _sy = statusYellow(), _sr = statusRed();
|
||||
const zones = [
|
||||
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: '#22c55e20' },
|
||||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: '#f59e0b15' },
|
||||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: '#ef444410' },
|
||||
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: _sg + '20' },
|
||||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: _sy + '15' },
|
||||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: _sr + '10' },
|
||||
];
|
||||
// Define patterns for color-blind accessibility
|
||||
svg += `<defs>`;
|
||||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="#22c55e" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="#f59e0b" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="${_sg}" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="${_sy}" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `</defs>`;
|
||||
const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' };
|
||||
const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' };
|
||||
const zoneBorder = { 'Excellent': '#22c55e', 'Good': '#f59e0b', 'Weak': '#ef4444' };
|
||||
const zoneBorder = { 'Excellent': _sg, 'Good': _sy, 'Weak': _sr };
|
||||
zones.forEach(z => {
|
||||
const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||||
const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||||
@@ -369,7 +378,7 @@
|
||||
let html = '<table class="analytics-table"><thead><tr><th>Type</th><th>Packets</th><th>Avg SNR</th><th>Min</th><th>Max</th><th>Distribution</th></tr></thead><tbody>';
|
||||
snrByType.forEach(t => {
|
||||
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
|
||||
const color = t.avg > 6 ? '#22c55e' : t.avg > 0 ? '#f59e0b' : '#ef4444';
|
||||
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
|
||||
html += `<tr>
|
||||
<td><strong>${t.name}</strong></td>
|
||||
<td>${t.count}</td>
|
||||
@@ -392,7 +401,7 @@
|
||||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
svg += `<polyline points="${snrPts}" fill="none" stroke="#22c55e" stroke-width="2"/>`;
|
||||
svg += `<polyline points="${snrPts}" fill="none" stroke="${statusGreen()}" stroke-width="2"/>`;
|
||||
// Packet count as area
|
||||
const areaPts = data.map((d, i) => {
|
||||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||||
@@ -411,7 +420,7 @@
|
||||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${data[i].hour.slice(11)}h</text>`;
|
||||
}
|
||||
svg += '</svg>';
|
||||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:#22c55e"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
|
||||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:${statusGreen()}"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -526,7 +535,7 @@
|
||||
const x = pad + (d.hops / maxHop) * (w - pad * 2);
|
||||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||||
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
|
||||
const color = d.avgSnr > 6 ? '#22c55e' : d.avgSnr > 0 ? '#f59e0b' : '#ef4444';
|
||||
const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed();
|
||||
svg += `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6"/>`;
|
||||
svg += `<text x="${x}" y="${y-r-3}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${d.hops}h</text>`;
|
||||
});
|
||||
@@ -927,13 +936,13 @@
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
badge = '<span class="badge" style="background:#22c55e;color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||||
} else if (c.classification === 'regional') {
|
||||
badge = '<span class="badge" style="background:#f59e0b;color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||||
} else if (c.classification === 'distant') {
|
||||
badge = '<span class="badge" style="background:#ef4444;color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||||
} else {
|
||||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||||
@@ -993,7 +1002,7 @@
|
||||
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
|
||||
<td>${s.count.toLocaleString()}</td>
|
||||
<td>${s.pct}%</td>
|
||||
<td><div style="background:${hasSelfLoop ? '#f59e0b' : 'var(--accent,#3b82f6)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||||
<td><div style="background:${hasSelfLoop ? 'var(--status-yellow)' : 'var(--accent)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody></table>`;
|
||||
@@ -1093,7 +1102,7 @@
|
||||
const dLon = (a.lon - b.lon) * 85;
|
||||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:#ef4444;font-weight:bold' : km > 50 ? 'color:#f59e0b' : 'color:#22c55e';
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
} else {
|
||||
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)} → ${esc(b.name)} (no coords)</span></div>`);
|
||||
@@ -1155,13 +1164,13 @@
|
||||
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
|
||||
L.circleMarker(ll, {
|
||||
radius: isEnd ? 8 : 5,
|
||||
color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||||
fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||||
color: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
|
||||
fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
|
||||
fillOpacity: 0.9, weight: 2
|
||||
}).bindTooltip(n.name, { permanent: false }).addTo(map);
|
||||
});
|
||||
|
||||
L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||||
L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||||
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
|
||||
}
|
||||
}
|
||||
@@ -1207,15 +1216,15 @@
|
||||
<h3>🔍 Network Status</h3>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#22c55e">${active}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--status-green)">${active}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟢 Active</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#eab308">${degraded}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--status-yellow)">${degraded}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟡 Degraded</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#ef4444">${silent}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--status-red)">${silent}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🔴 Silent</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
@@ -1340,7 +1349,7 @@
|
||||
if (data.distHistogram && data.distHistogram.bins) {
|
||||
const buckets = data.distHistogram.bins.map(b => b.count);
|
||||
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
|
||||
html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, '#22c55e')}</div>`;
|
||||
html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, statusGreen())}</div>`;
|
||||
}
|
||||
|
||||
// Distance over time
|
||||
|
||||
128
public/app.js
128
public/app.js
@@ -315,6 +315,14 @@ function navigate() {
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', navigate);
|
||||
let _themeRefreshTimer = null;
|
||||
window.addEventListener('theme-changed', () => {
|
||||
if (_themeRefreshTimer) clearTimeout(_themeRefreshTimer);
|
||||
_themeRefreshTimer = setTimeout(() => {
|
||||
_themeRefreshTimer = null;
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
}, 300);
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
|
||||
@@ -325,6 +333,43 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
|
||||
localStorage.setItem('meshcore-theme', theme);
|
||||
// Re-apply user theme CSS vars for the correct mode (light/dark)
|
||||
reapplyUserThemeVars(theme === 'dark');
|
||||
}
|
||||
function reapplyUserThemeVars(dark) {
|
||||
try {
|
||||
var userTheme = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
// Fall back to server config
|
||||
var cfg = window.SITE_CONFIG || {};
|
||||
if (!cfg.theme && !cfg.themeDark) return;
|
||||
userTheme = cfg;
|
||||
}
|
||||
var themeData = dark ? Object.assign({}, userTheme.theme || {}, userTheme.themeDark || {}) : (userTheme.theme || {});
|
||||
if (!Object.keys(themeData).length) return;
|
||||
var varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
var root = document.documentElement.style;
|
||||
for (var key in varMap) {
|
||||
if (themeData[key]) root.setProperty(varMap[key], themeData[key]);
|
||||
}
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
var nav = document.querySelector('.top-nav');
|
||||
if (nav) { nav.style.background = ''; void nav.offsetHeight; }
|
||||
}
|
||||
} catch (e) { console.error('[theme] reapply error:', e); }
|
||||
}
|
||||
// On load: respect saved pref, else OS pref, else light
|
||||
if (savedTheme) {
|
||||
@@ -497,8 +542,87 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
setInterval(updateNavStats, 15000);
|
||||
debouncedOnWS(function () { updateNavStats(); });
|
||||
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
window.SITE_CONFIG = cfg;
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
|
||||
const root = document.documentElement.style;
|
||||
const varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(varMap)) {
|
||||
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
|
||||
}
|
||||
// Derived vars
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply node color overrides (skip if user has local preferences)
|
||||
if (cfg.nodeColors && !userTheme.nodeColors) {
|
||||
for (const [role, color] of Object.entries(cfg.nodeColors)) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type color overrides (skip if user has local preferences)
|
||||
if (cfg.typeColors && !userTheme.typeColors) {
|
||||
for (const [type, color] of Object.entries(cfg.typeColors)) {
|
||||
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
|
||||
}
|
||||
if (window.syncBadgeColors) window.syncBadgeColors();
|
||||
}
|
||||
|
||||
// Apply branding (skip if user has local preferences)
|
||||
if (cfg.branding && !userTheme.branding) {
|
||||
if (cfg.branding.siteName) {
|
||||
document.title = cfg.branding.siteName;
|
||||
const brandText = document.querySelector('.brand-text');
|
||||
if (brandText) brandText.textContent = cfg.branding.siteName;
|
||||
}
|
||||
if (cfg.branding.logoUrl) {
|
||||
const brandIcon = document.querySelector('.brand-icon');
|
||||
if (brandIcon) {
|
||||
const img = document.createElement('img');
|
||||
img.src = cfg.branding.logoUrl;
|
||||
img.alt = cfg.branding.siteName || 'Logo';
|
||||
img.style.height = '24px';
|
||||
img.style.width = 'auto';
|
||||
brandIcon.replaceWith(img);
|
||||
}
|
||||
}
|
||||
if (cfg.branding.faviconUrl) {
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let speedMult = 1;
|
||||
let highlightTimers = [];
|
||||
|
||||
const TYPE_COLORS = {
|
||||
const TYPE_COLORS = window.TYPE_COLORS || {
|
||||
ADVERT: '#f59e0b', GRP_TXT: '#10b981', TXT_MSG: '#6366f1',
|
||||
TRACE: '#8b5cf6', REQ: '#ef4444', RESPONSE: '#3b82f6',
|
||||
ACK: '#6b7280', PATH: '#ec4899', ANON_REQ: '#f97316', UNKNOWN: '#6b7280'
|
||||
|
||||
1270
public/customize.js
Normal file
1270
public/customize.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,12 +25,6 @@
|
||||
.chooser-btn span:last-child { font-size: .8rem; color: var(--text-muted); }
|
||||
.home-level-toggle { margin-top: 16px; }
|
||||
|
||||
:root {
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.home-hero {
|
||||
text-align: center;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
function showChooser(container) {
|
||||
container.innerHTML = `
|
||||
<section class="home-chooser">
|
||||
<h1>Welcome to Bay Area MeshCore Analyzer</h1>
|
||||
<h1>Welcome to ${escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer')}</h1>
|
||||
<p>How familiar are you with MeshCore?</p>
|
||||
<div class="chooser-options">
|
||||
<button class="chooser-btn new" id="chooseNew">
|
||||
@@ -62,11 +62,13 @@
|
||||
const exp = isExperienced();
|
||||
const myNodes = getMyNodes();
|
||||
const hasNodes = myNodes.length > 0;
|
||||
const homeCfg = window.SITE_CONFIG?.home || null;
|
||||
const siteName = window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer';
|
||||
|
||||
container.innerHTML = `
|
||||
<section class="home-hero">
|
||||
<h1>${hasNodes ? 'My Mesh' : 'MeshCore Analyzer'}</h1>
|
||||
<p>${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : 'Find your nodes to start monitoring them.'}</p>
|
||||
<h1>${hasNodes ? 'My Mesh' : escapeHtml(homeCfg?.heroTitle || siteName)}</h1>
|
||||
<p>${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : escapeHtml(homeCfg?.heroSubtitle || 'Find your nodes to start monitoring them.')}</p>
|
||||
<div class="home-search-wrap">
|
||||
<input type="text" id="homeSearch" placeholder="Search by node name or public key…" autocomplete="off" aria-label="Search nodes" role="combobox" aria-expanded="false" aria-owns="homeSuggest" aria-autocomplete="list" aria-activedescendant="">
|
||||
<div class="home-suggest" id="homeSuggest" role="listbox"></div>
|
||||
@@ -92,17 +94,18 @@
|
||||
|
||||
${exp ? '' : `
|
||||
<section class="home-checklist">
|
||||
<h2>🚀 Getting on the mesh — SF Bay Area</h2>
|
||||
${checklist()}
|
||||
<h2>🚀 Getting on the mesh${homeCfg?.steps ? '' : ' — SF Bay Area'}</h2>
|
||||
${checklist(homeCfg)}
|
||||
</section>`}
|
||||
|
||||
<section class="home-footer">
|
||||
<div class="home-footer-links">
|
||||
${homeCfg?.footerLinks ? homeCfg.footerLinks.map(l => `<a href="${escapeAttr(l.url)}" class="home-footer-link" target="_blank" rel="noopener">${escapeHtml(l.label)}</a>`).join('') : `
|
||||
<a href="#/packets" class="home-footer-link">📦 Packets</a>
|
||||
<a href="#/map" class="home-footer-link">🗺️ Network Map</a>
|
||||
<a href="#/live" class="home-footer-link">🔴 Live</a>
|
||||
<a href="#/nodes" class="home-footer-link">📡 All Nodes</a>
|
||||
<a href="#/channels" class="home-footer-link">💬 Channels</a>
|
||||
<a href="#/channels" class="home-footer-link">💬 Channels</a>`}
|
||||
</div>
|
||||
<div class="home-level-toggle">
|
||||
<small>${exp ? 'Want setup guides? ' : 'Already know MeshCore? '}
|
||||
@@ -261,7 +264,7 @@
|
||||
// SNR quality label
|
||||
const snrVal = stats.avgSnr;
|
||||
const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null;
|
||||
const snrColor = snrVal != null ? (snrVal > 10 ? '#22c55e' : snrVal > 0 ? '#3b82f6' : snrVal > -5 ? '#f59e0b' : '#ef4444') : '#6b7280';
|
||||
const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280';
|
||||
|
||||
// Build sparkline from recent packets (packet timestamps → hourly buckets)
|
||||
const sparkHtml = buildSparkline(h.recentPackets || []);
|
||||
@@ -507,7 +510,13 @@
|
||||
function escapeAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
||||
function timeSinceMs(d) { return Date.now() - d.getTime(); }
|
||||
|
||||
function checklist() {
|
||||
function checklist(homeCfg) {
|
||||
if (homeCfg?.checklist) {
|
||||
return homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
|
||||
}
|
||||
if (homeCfg?.steps) {
|
||||
return homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
|
||||
}
|
||||
const items = [
|
||||
{ q: '💬 First: Join the Bay Area MeshCore Discord',
|
||||
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774221932">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1774058575">
|
||||
<link rel="stylesheet" href="style.css?v=1774237752">
|
||||
<link rel="stylesheet" href="home.css?v=1774236560">
|
||||
<link rel="stylesheet" href="live.css?v=1774236560">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -64,6 +64,7 @@
|
||||
<div class="nav-fav-dropdown" id="favDropdown"></div>
|
||||
</div>
|
||||
<button class="nav-btn" id="searchToggle" title="Search (Ctrl+K)">🔍</button>
|
||||
<button class="nav-btn" id="customizeToggle" title="Customize theme & branding">🎨</button>
|
||||
<button class="nav-btn" id="darkModeToggle" title="Toggle dark mode">☀️</button>
|
||||
<button class="nav-btn hamburger" id="hamburger" title="Menu" aria-label="Toggle navigation menu">☰</button>
|
||||
</div>
|
||||
@@ -80,25 +81,26 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774325000"></script>
|
||||
<script src="roles.js?v=1774236560"></script>
|
||||
<script src="customize.js?v=1774238281" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774325000"></script>
|
||||
<script src="hop-resolver.js?v=1774223973"></script>
|
||||
<script src="hop-display.js?v=1774221932"></script>
|
||||
<script src="app.js?v=1774126708"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774225004"></script>
|
||||
<script src="map.js?v=1774220756" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="app.js?v=1774238281"></script>
|
||||
<script src="home.js?v=1774236560"></script>
|
||||
<script src="packets.js?v=1774236560"></script>
|
||||
<script src="map.js?v=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774331200" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774221131" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774135052" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774208460" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774207165" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774208460" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774218049" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774229396" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774219440" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
.live-beacon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
background: var(--status-red);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: beaconPulse 1.5s ease-in-out infinite;
|
||||
@@ -80,11 +80,11 @@
|
||||
.live-stat-pill span {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.live-stat-pill.anim-pill span { color: #f59e0b; }
|
||||
.live-stat-pill.rate-pill span { color: #22c55e; }
|
||||
.live-stat-pill.anim-pill span { color: var(--status-yellow); }
|
||||
.live-stat-pill.rate-pill span { color: var(--status-green); }
|
||||
|
||||
.live-sound-btn {
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
@@ -375,7 +375,7 @@
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(59,130,246,0.15);
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
@@ -486,7 +486,7 @@
|
||||
|
||||
.vcr-live-btn {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
color: var(--status-red);
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -501,15 +501,15 @@
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.vcr-mode-live { color: #22c55e; }
|
||||
.vcr-mode-paused { color: #fbbf24; background: rgba(251,191,36,0.1); }
|
||||
.vcr-mode-replay { color: #60a5fa; background: rgba(96,165,250,0.1); }
|
||||
.vcr-mode-live { color: var(--status-green); }
|
||||
.vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); }
|
||||
.vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); }
|
||||
|
||||
.vcr-live-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #22c55e;
|
||||
background: var(--status-green);
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
animation: vcr-pulse 1.5s ease-in-out infinite;
|
||||
@@ -541,7 +541,7 @@
|
||||
}
|
||||
.vcr-lcd-mode {
|
||||
font-size: 0.65rem;
|
||||
color: #4ade80;
|
||||
color: var(--status-green);
|
||||
text-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -551,7 +551,7 @@
|
||||
}
|
||||
.vcr-lcd-pkts {
|
||||
font-size: 0.6rem;
|
||||
color: #fbbf24;
|
||||
color: var(--status-yellow);
|
||||
text-shadow: 0 0 4px rgba(251, 191, 36, 0.5);
|
||||
font-weight: 700;
|
||||
min-height: 0.7rem;
|
||||
@@ -559,7 +559,7 @@
|
||||
.vcr-missed {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #fbbf24;
|
||||
color: var(--status-yellow);
|
||||
background: rgba(251,191,36,0.15);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
@@ -587,7 +587,7 @@
|
||||
}
|
||||
.vcr-scope-btn.active {
|
||||
background: rgba(59,130,246,0.2);
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
border-color: rgba(59,130,246,0.3);
|
||||
}
|
||||
|
||||
@@ -613,7 +613,7 @@
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #f87171;
|
||||
background: var(--status-red);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 4px rgba(248,113,113,0.5);
|
||||
@@ -631,7 +631,7 @@
|
||||
.vcr-prompt-btn {
|
||||
background: rgba(59,130,246,0.15);
|
||||
border: 1px solid rgba(59,130,246,0.25);
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Status color helpers (read from CSS variables for theme support)
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||||
|
||||
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer;
|
||||
let nodeMarkers = {};
|
||||
let nodeData = {};
|
||||
@@ -36,7 +40,7 @@
|
||||
|
||||
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
|
||||
|
||||
const TYPE_COLORS = {
|
||||
const TYPE_COLORS = window.TYPE_COLORS || {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6'
|
||||
};
|
||||
@@ -348,7 +352,7 @@
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
drawLcdText(`${hh}:${mm}:${ss}`, '#4ade80');
|
||||
drawLcdText(`${hh}:${mm}:${ss}`, statusGreen());
|
||||
}
|
||||
|
||||
function updateVCRLcd() {
|
||||
@@ -644,11 +648,11 @@
|
||||
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:#22c55e" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:#ec4899" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.GRP_TXT}" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.TXT_MSG}" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.REQUEST}" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.TRACE}" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list" id="roleLegendList"></ul>
|
||||
@@ -1210,7 +1214,7 @@
|
||||
const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase()));
|
||||
const name = escapeHtml(h.name || h.prefix);
|
||||
if (isThis) return `<strong style="color:var(--accent)">${name}</strong>`;
|
||||
return h.pubkey ? `<a href="#/nodes/${h.pubkey}" style="color:var(--text-primary);text-decoration:none">${name}</a>` : name;
|
||||
return h.pubkey ? `<a href="#/nodes/${h.pubkey}" style="color:var(--text);text-decoration:none">${name}</a>` : name;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:3px 0;font-size:11px;line-height:1.4">${chain} <span style="color:var(--text-muted)">(${p.count}×)</span></div>`;
|
||||
}).join('');
|
||||
@@ -2237,5 +2241,17 @@
|
||||
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
|
||||
}
|
||||
|
||||
registerPage('live', { init, destroy });
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
registerPage('live', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { /* live map rebuilds on next packet */ };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
const closeBtn = L.control({ position: 'topright' });
|
||||
closeBtn.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'leaflet-bar');
|
||||
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--bg-secondary,#1e293b);color:var(--text-primary,#e2e8f0);border-radius:4px">✕</a>';
|
||||
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);border-radius:4px">✕</a>';
|
||||
L.DomEvent.on(div, 'click', function (e) {
|
||||
L.DomEvent.preventDefault(e);
|
||||
routeLayer.clearLayers();
|
||||
@@ -317,7 +317,7 @@
|
||||
positions.forEach((p, i) => {
|
||||
const isOrigin = i === 0 && p.isOrigin;
|
||||
const isLast = i === positions.length - 1 && positions.length > 1;
|
||||
const color = isOrigin ? '#06b6d4' : isLast ? '#ef4444' : i === 0 ? '#22c55e' : '#f59e0b';
|
||||
const color = isOrigin ? '#06b6d4' : isLast ? (getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444') : i === 0 ? (getComputedStyle(document.documentElement).getPropertyValue('--status-green').trim() || '#22c55e') : '#f59e0b';
|
||||
const radius = isOrigin ? 14 : 10;
|
||||
const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
|
||||
|
||||
@@ -575,12 +575,12 @@
|
||||
|
||||
if (m.offset > 10) {
|
||||
const line = L.polyline([m.latLng, pos], {
|
||||
color: '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
|
||||
});
|
||||
markerLayer.addLayer(line);
|
||||
// Small dot at true GPS position
|
||||
const dot = L.circleMarker(m.latLng, {
|
||||
radius: 3, fillColor: '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||||
radius: 3, fillColor: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||||
});
|
||||
markerLayer.addLayer(dot);
|
||||
}
|
||||
@@ -680,5 +680,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
registerPage('map', { init, destroy });
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
registerPage('map', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -327,10 +327,10 @@
|
||||
const el = document.getElementById('nodeCounts');
|
||||
if (!el) return;
|
||||
el.innerHTML = [
|
||||
{ k: 'repeaters', l: 'Repeaters', c: '#3b82f6' },
|
||||
{ k: 'rooms', l: 'Rooms', c: '#6b7280' },
|
||||
{ k: 'companions', l: 'Companions', c: '#22c55e' },
|
||||
{ k: 'sensors', l: 'Sensors', c: '#f59e0b' },
|
||||
{ k: 'repeaters', l: 'Repeaters', c: ROLE_COLORS.repeater },
|
||||
{ k: 'rooms', l: 'Rooms', c: ROLE_COLORS.room || '#6b7280' },
|
||||
{ k: 'companions', l: 'Companions', c: ROLE_COLORS.companion },
|
||||
{ k: 'sensors', l: 'Sensors', c: ROLE_COLORS.sensor },
|
||||
].map(r => `<span class="node-count-pill" style="background:${r.c}">${counts[r.k] || 0} ${r.l}</span>`).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -1184,7 +1184,7 @@
|
||||
const hopLabel = decoded.path_len != null ? `${decoded.path_len} hops` : '';
|
||||
const snrLabel = snr != null ? `SNR ${snr} dB` : '';
|
||||
const meta = [chLabel, hopLabel, snrLabel].filter(Boolean).join(' · ');
|
||||
messageHtml = `<div class="detail-message" style="padding:12px;margin:8px 0;background:var(--card-bg);border-radius:8px;border-left:3px solid var(--primary)">
|
||||
messageHtml = `<div class="detail-message" style="padding:12px;margin:8px 0;background:var(--card-bg);border-radius:8px;border-left:3px solid var(--accent)">
|
||||
<div style="font-size:1.1em">${escapeHtml(decoded.text)}</div>
|
||||
${meta ? `<div style="font-size:0.85em;color:var(--muted);margin-top:4px">${meta}</div>` : ''}
|
||||
</div>`;
|
||||
@@ -1695,7 +1695,19 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
registerPage('packets', { init, destroy });
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
registerPage('packets', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { if (typeof renderTableRows === 'function') renderTableRows(); };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Standalone packet detail page: #/packet/123 or #/packet/HASH
|
||||
registerPage('packet-detail', {
|
||||
@@ -1712,7 +1724,7 @@
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'max-width:800px;margin:0 auto;padding:20px';
|
||||
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--primary);text-decoration:none">← Back to packets</a></div>`;
|
||||
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--accent);text-decoration:none">← Back to packets</a></div>`;
|
||||
const detail = document.createElement('div');
|
||||
container.appendChild(detail);
|
||||
await renderDetail(detail, data);
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
// System health (memory, event loop, WS)
|
||||
if (health) {
|
||||
const m = health.memory, el = health.eventLoop;
|
||||
const elColor = el.p95Ms > 500 ? '#ef4444' : el.p95Ms > 100 ? '#f59e0b' : '#22c55e';
|
||||
const memColor = m.heapUsed > m.heapTotal * 0.85 ? '#ef4444' : m.heapUsed > m.heapTotal * 0.7 ? '#f59e0b' : '#22c55e';
|
||||
const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)';
|
||||
const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)';
|
||||
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? '#22c55e' : c.hitRate > 20 ? '#f59e0b' : '#ef4444'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? 'var(--status-green)' : c.hitRate > 20 ? 'var(--status-yellow)' : 'var(--status-red)'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
|
||||
@@ -63,7 +63,7 @@
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? '#22c55e' : '#f59e0b'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? 'var(--status-green)' : 'var(--status-yellow)'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,40 @@
|
||||
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
|
||||
};
|
||||
|
||||
window.TYPE_COLORS = {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
|
||||
ANON_REQ: '#f43f5e', UNKNOWN: '#6b7280'
|
||||
};
|
||||
|
||||
// Badge CSS class name mapping
|
||||
const TYPE_BADGE_MAP = {
|
||||
ADVERT: 'advert', GRP_TXT: 'grp-txt', TXT_MSG: 'txt-msg', ACK: 'ack',
|
||||
REQUEST: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path',
|
||||
ANON_REQ: 'anon-req', UNKNOWN: 'unknown'
|
||||
};
|
||||
|
||||
// Generate badge CSS from TYPE_COLORS — single source of truth
|
||||
window.syncBadgeColors = function() {
|
||||
var el = document.getElementById('type-color-badges');
|
||||
if (!el) { el = document.createElement('style'); el.id = 'type-color-badges'; document.head.appendChild(el); }
|
||||
var css = '';
|
||||
for (var type in TYPE_BADGE_MAP) {
|
||||
var color = window.TYPE_COLORS[type];
|
||||
if (!color) continue;
|
||||
var cls = TYPE_BADGE_MAP[type];
|
||||
css += '.badge-' + cls + ' { background: ' + color + '20; color: ' + color + '; }\n';
|
||||
}
|
||||
el.textContent = css;
|
||||
};
|
||||
|
||||
// Auto-sync on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', window.syncBadgeColors);
|
||||
} else {
|
||||
window.syncBadgeColors();
|
||||
}
|
||||
|
||||
window.ROLE_LABELS = {
|
||||
repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers',
|
||||
sensor: 'Sensors', observer: 'Observers'
|
||||
@@ -303,4 +337,22 @@
|
||||
'CMN': 'Casablanca, MA',
|
||||
'LOS': 'Lagos, NG'
|
||||
};
|
||||
|
||||
// Simple markdown → HTML (bold, italic, links, code, lists, line breaks)
|
||||
window.miniMarkdown = function(text) {
|
||||
if (!text) return '';
|
||||
var html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--accent)">$1</a>')
|
||||
.replace(/^- (.+)/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br>');
|
||||
// Wrap consecutive <li> in <ul>
|
||||
html = html.replace(/((?:<li>.*?<\/li><br>?)+)/g, function(m) {
|
||||
return '<ul>' + m.replace(/<br>/g, '') + '</ul>';
|
||||
});
|
||||
return html;
|
||||
};
|
||||
})();
|
||||
|
||||
126
public/style.css
126
public/style.css
@@ -3,7 +3,12 @@
|
||||
:root {
|
||||
--nav-bg: #0f0f23;
|
||||
--nav-bg2: #1a1a2e;
|
||||
--nav-text: #ffffff;
|
||||
--nav-text-muted: #cbd5e1;
|
||||
--accent: #4a9eff;
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
--accent-hover: #6db3ff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #5b6370;
|
||||
@@ -30,6 +35,9 @@
|
||||
When changing dark theme variables, update BOTH blocks below. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
--surface-0: #0f0f23;
|
||||
--surface-1: #1a1a2e;
|
||||
--surface-2: #232340;
|
||||
@@ -50,6 +58,9 @@
|
||||
}
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
|
||||
[data-theme="dark"] {
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
--surface-0: #0f0f23;
|
||||
--surface-1: #1a1a2e;
|
||||
--surface-2: #232340;
|
||||
@@ -87,15 +98,15 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
/* === Nav === */
|
||||
.top-nav {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: linear-gradient(135deg, #0f0f23 0%, #151532 50%, #1a1035 100%); color: #fff; padding: 0 20px; height: 52px;
|
||||
background: linear-gradient(135deg, var(--nav-bg) 0%, var(--nav-bg2) 100%); color: var(--nav-text); padding: 0 20px; height: 52px;
|
||||
position: sticky; top: 0; z-index: 1100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
}
|
||||
.nav-left { display: flex; align-items: center; gap: 24px; }
|
||||
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: #fff; font-weight: 700; font-size: 16px; }
|
||||
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: 16px; }
|
||||
.brand-icon { font-size: 20px; }
|
||||
.live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: #555;
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted);
|
||||
display: inline-block; margin-left: 4px; transition: background .3s;
|
||||
}
|
||||
@keyframes pulse-ring {
|
||||
@@ -103,18 +114,18 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
.live-dot.connected { background: #22c55e; animation: pulse-ring 2s ease-out infinite; }
|
||||
.live-dot.connected { background: var(--status-green); animation: pulse-ring 2s ease-out infinite; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: 4px; }
|
||||
.nav-link {
|
||||
color: #cbd5e1; text-decoration: none; padding: 14px 12px; font-size: 14px;
|
||||
color: var(--nav-text-muted); text-decoration: none; padding: 14px 12px; font-size: 14px;
|
||||
border-bottom: 2px solid transparent; transition: all .15s;
|
||||
background: none; border-top: none; border-left: none; border-right: none;
|
||||
cursor: pointer; font-family: var(--font);
|
||||
}
|
||||
.nav-link:hover { color: #fff; }
|
||||
.nav-link:hover { color: var(--nav-text); }
|
||||
.nav-link.active {
|
||||
color: #fff;
|
||||
color: var(--nav-text);
|
||||
border-bottom-color: transparent;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
@@ -125,28 +136,28 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
.nav-dropdown { position: relative; }
|
||||
.dropdown-menu {
|
||||
display: none; position: absolute; top: 100%; left: 0;
|
||||
background: var(--nav-bg2); border: 1px solid #333; border-radius: 6px;
|
||||
background: var(--nav-bg2); border: 1px solid var(--border); border-radius: 6px;
|
||||
min-width: 140px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
.nav-dropdown:hover .dropdown-menu { display: block; }
|
||||
.dropdown-item {
|
||||
display: block; padding: 8px 16px; color: #cbd5e1; text-decoration: none; font-size: 13px;
|
||||
display: block; padding: 8px 16px; color: var(--text-muted); text-decoration: none; font-size: 13px;
|
||||
}
|
||||
.dropdown-item:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
.nav-right { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-btn {
|
||||
background: none; border: 1px solid #444; color: #cbd5e1; padding: 6px 12px;
|
||||
background: none; border: 1px solid var(--border); color: var(--nav-text-muted); padding: 6px 12px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.nav-btn:hover { background: #333; color: #fff; }
|
||||
.nav-btn:hover { background: var(--nav-bg2); color: var(--nav-text); }
|
||||
/* === Nav Stats === */
|
||||
.nav-stats {
|
||||
display: flex; gap: 12px; align-items: center; font-size: 12px; color: #94a3b8;
|
||||
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
|
||||
font-family: var(--mono); margin-right: 4px;
|
||||
}
|
||||
.nav-stats .stat-val { color: #e2e8f0; font-weight: 600; transition: color 0.3s ease; }
|
||||
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
|
||||
.nav-stats .stat-val.updated { color: var(--accent); }
|
||||
|
||||
/* === Layout === */
|
||||
@@ -248,32 +259,11 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
display: inline-block; padding: 2px 8px; border-radius: var(--badge-radius);
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px;
|
||||
}
|
||||
.badge-advert { background: #dcfce7; color: #166534; }
|
||||
.badge-grp-txt { background: #dbeafe; color: #1e40af; }
|
||||
.badge-ack { background: #f3f4f6; color: var(--text-muted); }
|
||||
.badge-req { background: #ffedd5; color: #9a3412; }
|
||||
.badge-txt-msg { background: #f3e8ff; color: #7e22ce; }
|
||||
.badge-trace { background: #cffafe; color: #0e7490; }
|
||||
.badge-path { background: #fef9c3; color: #a16207; }
|
||||
.badge-response { background: #e0e7ff; color: #3730a3; }
|
||||
.badge-anon-req { background: #fce7f3; color: #9d174d; }
|
||||
.badge-unknown { background: #f3f4f6; color: var(--text-muted); }
|
||||
|
||||
[data-theme="dark"] .badge-advert { background: #166534; color: #86efac; }
|
||||
[data-theme="dark"] .badge-grp-txt { background: #1e3a5f; color: #93c5fd; }
|
||||
[data-theme="dark"] .badge-ack { background: #374151; color: #d1d5db; }
|
||||
[data-theme="dark"] .badge-req { background: #7c2d12; color: #fdba74; }
|
||||
[data-theme="dark"] .badge-txt-msg { background: #581c87; color: #d8b4fe; }
|
||||
[data-theme="dark"] .badge-trace { background: #164e63; color: #67e8f9; }
|
||||
[data-theme="dark"] .badge-path { background: #713f12; color: #fde68a; }
|
||||
[data-theme="dark"] .badge-response { background: #312e81; color: #a5b4fc; }
|
||||
[data-theme="dark"] .badge-anon-req { background: #831843; color: #f9a8d4; }
|
||||
[data-theme="dark"] .badge-unknown { background: #374151; color: #d1d5db; }
|
||||
|
||||
.badge-region {
|
||||
display: inline-block; padding: 2px 6px; border-radius: 4px;
|
||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--nav-bg); color: #fff; letter-spacing: .5px;
|
||||
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
||||
}
|
||||
.badge-obs {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 10px;
|
||||
@@ -334,7 +324,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 12px;
|
||||
}
|
||||
.field-table th {
|
||||
text-align: left; padding: 6px 8px; background: var(--nav-bg); color: #fff;
|
||||
text-align: left; padding: 6px 8px; background: var(--nav-bg); color: var(--nav-text);
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .3px;
|
||||
}
|
||||
.field-table td {
|
||||
@@ -661,7 +651,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
border-radius: 4px; font-family: var(--mono); font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.trace-path-arrow { color: var(--text-muted); font-size: 16px; }
|
||||
.trace-path-label { color: #94a3b8; font-size: 12px; font-style: italic; }
|
||||
.trace-path-label { color: var(--text-muted); font-size: 12px; font-style: italic; }
|
||||
.trace-path-info { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* Timeline */
|
||||
@@ -687,31 +677,31 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
}
|
||||
.tl-delta { font-size: 11px; color: var(--text-muted); text-align: right; }
|
||||
.tl-snr { font-size: 12px; font-weight: 600; text-align: right; }
|
||||
.tl-snr.good { color: #16a34a; }
|
||||
.tl-snr.ok { color: #ca8a04; }
|
||||
.tl-snr.bad { color: #dc2626; }
|
||||
.tl-snr.good { color: var(--status-green); }
|
||||
.tl-snr.ok { color: var(--status-yellow); }
|
||||
.tl-snr.bad { color: var(--status-red); }
|
||||
.tl-rssi { font-size: 12px; color: var(--text-muted); text-align: right; }
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* === Observers Page === */
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.health-dot.health-green { background: #22c55e; box-shadow: 0 0 6px #22c55e80; }
|
||||
.health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; }
|
||||
.health-dot.health-green { background: var(--status-green); box-shadow: 0 0 6px #22c55e80; }
|
||||
.health-dot.health-yellow { background: var(--status-yellow); box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: var(--status-red); box-shadow: 0 0 6px #ef444480; }
|
||||
.obs-table td:first-child { white-space: nowrap; }
|
||||
.obs-table td:nth-child(6) { max-width: none; overflow: visible; }
|
||||
.col-observer { min-width: 70px; max-width: none; }
|
||||
.spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
@media (max-width: 640px) { .spark-bar { max-width: 60px; } }
|
||||
.spark-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 4px; transition: width 0.3s; }
|
||||
.spark-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-hover, #60a5fa)); border-radius: 4px; transition: width 0.3s; }
|
||||
.spark-label { position: absolute; right: 4px; top: 0; line-height: 18px; font-size: 11px; color: var(--text); font-weight: 500; }
|
||||
|
||||
/* === Dark mode input overrides === */
|
||||
@@ -921,7 +911,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
transition: color .15s, transform .15s;
|
||||
}
|
||||
.fav-star:hover { transform: scale(1.2); }
|
||||
.fav-star.on { color: #f5a623; }
|
||||
.fav-star.on { color: var(--status-yellow); }
|
||||
|
||||
/* BYOP Decode Modal */
|
||||
.byop-modal { max-width: 560px; }
|
||||
@@ -935,7 +925,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
color: var(--text);
|
||||
}
|
||||
.byop-input:focus { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.byop-err { color: #ef4444; font-size: .85rem; }
|
||||
.byop-err { color: var(--status-red); font-size: .85rem; }
|
||||
.byop-decoded { margin-top: 8px; }
|
||||
.byop-section { margin-bottom: 14px; }
|
||||
.byop-section-title {
|
||||
@@ -1069,9 +1059,9 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
|
||||
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
|
||||
.badge-hash-1 { background: #ef444420; color: #ef4444; }
|
||||
.badge-hash-2 { background: #22c55e20; color: #22c55e; }
|
||||
.badge-hash-3 { background: #3b82f620; color: #3b82f6; }
|
||||
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
|
||||
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
|
||||
.badge-hash-3 { background: #3b82f620; color: var(--accent); }
|
||||
.timeline-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; }
|
||||
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||
.timeline-chart svg { display: block; }
|
||||
@@ -1178,12 +1168,12 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
|
||||
/* Clickable hop links */
|
||||
.hop-link {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--accent, #3b82f6);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.hop-link:hover { color: var(--accent, #60a5fa); text-decoration: underline; }
|
||||
.hop-link:hover { color: var(--accent-hover, #60a5fa); text-decoration: underline; }
|
||||
|
||||
/* Detail map link */
|
||||
.detail-map-link {
|
||||
@@ -1204,7 +1194,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
padding: 5px 12px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--accent, #3b82f6);
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
@@ -1223,11 +1213,11 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
}
|
||||
|
||||
/* Ambiguous hop indicator */
|
||||
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
|
||||
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
|
||||
.hop-conflict-btn { background: #f59e0b; color: #000; border: none; border-radius: 4px; font-size: 11px;
|
||||
.hop-ambiguous { border-bottom: 1px dashed var(--status-yellow, #f59e0b); }
|
||||
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: var(--status-yellow, #f59e0b); }
|
||||
.hop-conflict-btn { background: var(--status-yellow, #f59e0b); color: #000; border: none; border-radius: 4px; font-size: 11px;
|
||||
font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; }
|
||||
.hop-conflict-btn:hover { background: #d97706; }
|
||||
.hop-conflict-btn:hover { background: var(--status-yellow, #d97706); filter: brightness(0.85); }
|
||||
.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; }
|
||||
.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border);
|
||||
@@ -1241,7 +1231,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
|
||||
.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
|
||||
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
|
||||
.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
|
||||
.hop-global-fallback { border-bottom: 1px dashed var(--status-red); }
|
||||
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
|
||||
|
||||
/* Self-loop subpath rows */
|
||||
@@ -1249,7 +1239,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.subpath-selfloop td:first-child::after { content: ''; }
|
||||
|
||||
/* Hop prefix in subpath routes */
|
||||
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
|
||||
.hop-prefix { color: var(--text-muted); font-size: 0.8em; }
|
||||
|
||||
/* Subpath split layout */
|
||||
.subpath-layout { display: flex; gap: 0; flex: 1; min-height: 0; overflow: auto; position: relative; }
|
||||
@@ -1257,7 +1247,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.subpath-detail { width: 420px; min-width: 360px; max-width: 50vw; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; transition: width 0.2s; }
|
||||
.subpath-detail.collapsed { width: 0; min-width: 0; padding: 0; overflow: hidden; border: none; }
|
||||
.subpath-detail-inner h4 { margin: 0 0 4px; word-break: break-word; }
|
||||
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: #9ca3af; font-size: 0.9em; }
|
||||
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: var(--text-muted); font-size: 0.9em; }
|
||||
.subpath-section { margin: 16px 0; }
|
||||
.subpath-section h5 { margin: 0 0 6px; font-size: 0.9em; }
|
||||
.subpath-selected { background: var(--accent, #3b82f6) !important; color: #fff; }
|
||||
@@ -1267,7 +1257,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
/* Hour distribution chart */
|
||||
.hour-chart { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
|
||||
.hour-bar { flex: 1; background: var(--accent, #3b82f6); border-radius: 2px 2px 0 0; min-width: 4px; }
|
||||
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: #9ca3af; }
|
||||
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: var(--text-muted); }
|
||||
|
||||
/* Parent paths */
|
||||
.parent-path { padding: 3px 0; border-bottom: 1px solid var(--border, #e5e7eb); }
|
||||
@@ -1287,7 +1277,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* Subpath jump nav */
|
||||
.subpath-jump-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 0.9em; flex-wrap: wrap; }
|
||||
.subpath-jump-nav span { color: #9ca3af; }
|
||||
.subpath-jump-nav span { color: var(--text-muted); }
|
||||
.subpath-jump-nav a { padding: 4px 12px; border-radius: 4px; background: var(--accent, #3b82f6); color: #fff; text-decoration: none; font-size: 0.85em; }
|
||||
.subpath-jump-nav a:hover { opacity: 0.8; }
|
||||
|
||||
@@ -1380,7 +1370,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--bg-card, #1e1e1e);
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
color: var(--text, #fff);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
@@ -1478,9 +1468,9 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||||
.perf-table code { font-size: 12px; color: var(--text); }
|
||||
.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); }
|
||||
.perf-table .perf-slow td { color: #ef4444; }
|
||||
.perf-table .perf-slow td { color: var(--status-red); }
|
||||
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
|
||||
.perf-table .perf-warn td { color: #f59e0b; }
|
||||
.perf-table .perf-warn td { color: var(--status-yellow); }
|
||||
|
||||
/* ─── Region filter bar ─── */
|
||||
.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
|
||||
@@ -1625,7 +1615,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1646,8 +1636,8 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* Audio voice selector */
|
||||
.audio-voice-select {
|
||||
background: var(--bg-secondary, #1f2937);
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
background: var(--input-bg, #1f2937);
|
||||
color: var(--text, #e5e7eb);
|
||||
border: 1px solid var(--border, #374151);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
|
||||
@@ -239,8 +239,8 @@
|
||||
for (const [node, pos] of nodePos) {
|
||||
const isEndpoint = node === 'Origin' || node === 'Dest';
|
||||
const r = isEndpoint ? 18 : 14;
|
||||
const fill = isEndpoint ? 'var(--primary, #3b82f6)' : 'var(--surface-2, #374151)';
|
||||
const stroke = isEndpoint ? 'var(--primary, #3b82f6)' : 'var(--border, #4b5563)';
|
||||
const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--surface-2, #374151)';
|
||||
const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--border, #4b5563)';
|
||||
const label = isEndpoint ? node : node;
|
||||
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`;
|
||||
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="${isEndpoint ? 10 : 9}" font-weight="${isEndpoint ? 700 : 500}">${escapeHtml(label)}</text>`;
|
||||
|
||||
72
server.js
72
server.js
@@ -7,7 +7,18 @@ const { WebSocketServer } = require('ws');
|
||||
const mqtt = require('mqtt');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const config = require('./config.json');
|
||||
// Config: bind-mounted config.json first, then fall back to data/ dir
|
||||
const CONFIG_PATHS = [
|
||||
path.join(__dirname, 'config.json'),
|
||||
path.join(__dirname, 'data', 'config.json')
|
||||
];
|
||||
function loadConfigFile() {
|
||||
for (const p of CONFIG_PATHS) {
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const config = loadConfigFile();
|
||||
const decoder = require('./decoder');
|
||||
const PAYLOAD_TYPES = decoder.PAYLOAD_TYPES;
|
||||
const { nodeNearRegion, IATA_COORDS } = require('./iata-coords');
|
||||
@@ -383,6 +394,59 @@ function getObserverIdsForRegions(regionParam) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Theme: hot-load from theme.json (same dir as config.json, or data/ dir)
|
||||
const THEME_PATHS = [
|
||||
path.join(__dirname, 'theme.json'),
|
||||
path.join(__dirname, 'data', 'theme.json')
|
||||
];
|
||||
function loadThemeFile() {
|
||||
for (const p of THEME_PATHS) {
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
app.get('/api/config/theme', (req, res) => {
|
||||
const cfg = loadConfigFile();
|
||||
const theme = loadThemeFile();
|
||||
res.json({
|
||||
branding: {
|
||||
siteName: 'MeshCore Analyzer',
|
||||
tagline: 'Real-time MeshCore LoRa mesh network analyzer',
|
||||
...(cfg.branding || {}),
|
||||
...(theme.branding || {})
|
||||
},
|
||||
theme: {
|
||||
accent: '#4a9eff',
|
||||
accentHover: '#6db3ff',
|
||||
navBg: '#0f0f23',
|
||||
navBg2: '#1a1a2e',
|
||||
...(cfg.theme || {}),
|
||||
...(theme.theme || {})
|
||||
},
|
||||
themeDark: {
|
||||
...(cfg.themeDark || {}),
|
||||
...(theme.themeDark || {})
|
||||
},
|
||||
nodeColors: {
|
||||
repeater: '#dc2626',
|
||||
companion: '#2563eb',
|
||||
room: '#16a34a',
|
||||
sensor: '#d97706',
|
||||
observer: '#8b5cf6',
|
||||
...(cfg.nodeColors || {}),
|
||||
...(theme.nodeColors || {})
|
||||
},
|
||||
typeColors: {
|
||||
...(cfg.typeColors || {}),
|
||||
...(theme.typeColors || {})
|
||||
},
|
||||
home: theme.home || cfg.home || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
app.get('/api/config/map', (req, res) => {
|
||||
const defaults = config.mapDefaults || {};
|
||||
res.json({
|
||||
@@ -2975,6 +3039,12 @@ const listenPort = process.env.PORT || config.port;
|
||||
server.listen(listenPort, () => {
|
||||
const protocol = isHttps ? 'https' : 'http';
|
||||
console.log(`MeshCore Analyzer running on ${protocol}://localhost:${listenPort}`);
|
||||
// Log theme file location
|
||||
let themeFound = false;
|
||||
for (const p of THEME_PATHS) {
|
||||
try { fs.accessSync(p); console.log(`[theme] Loaded from ${p}`); themeFound = true; break; } catch {}
|
||||
}
|
||||
if (!themeFound) console.log(`[theme] No theme.json found. Place it next to config.json or in data/ to customize.`);
|
||||
// Pre-warm expensive caches via self-requests (yields event loop between each)
|
||||
setTimeout(() => {
|
||||
const port = listenPort;
|
||||
|
||||
Reference in New Issue
Block a user