mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-31 22:44:21 +00:00
777f77a451
# feat(#1420): dark-tile provider picker in customizer (4 variants) Closes #1420. ## What Operator pick: don't force a single dark-tile choice on everyone. Wire 4 candidates into the customizer + server config so users can choose which dark basemap they want, with per-browser persistence. ## Providers shipped | ID | Source | Filter | |---|---|---| | `carto-dark` (default) | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none | | `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked layers) | none | | `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in dark, cleared in light | | `positron-inverted` | Carto Positron + same CSS invert | applied in dark, cleared in light | No new dependencies — all providers are URL-only. ## Architecture - **`public/map-tile-providers.js`** — registry + 5 public helpers (`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`, `MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`, `MC_applyTileFilter`). Persists to `localStorage['mc-dark-tile-provider']`. Dispatches `mc-tile-provider-changed` on user pick. - **`public/map.js` / `public/live.js`** — resolve the active dark provider via the registry, manage the Esri labels overlay lifecycle (add when needed, remove cleanly so we don't leak layers on repeated theme toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen for both `data-theme` mutations AND `mc-tile-provider-changed`. - **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps re-render live without reload. - **`public/roles.js`** — hydrates the server default via `MC_setServerDefaultTileProvider` from `/api/config/client`. - **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on `Config` + surfaced in `ClientConfigResponse`. Default empty → client uses `carto-dark`. - **`config.example.json`** — documents the new field with all allowed values. ## Behavior guarantees (from the acceptance criteria) - ✅ Light mode is **completely unchanged** — `_resolveTileUrl(false)` short-circuits to `TILE_LIGHT` with no filter and no overlay logic. - ✅ Switching dark→light always clears the CSS filter, even if an inverted provider remains selected (`MC_applyTileFilter` is called on every theme change and early-returns to `style.filter = ''` when not dark). - ✅ Switching light→dark with an inverted provider re-applies the filter. - ✅ Attribution is updated per provider (Esri credit for Esri, CartoDB credit for the others); the Leaflet attribution control is refreshed. - ✅ Esri uses two stacked layers (base + reference labels). The reference layer is added/removed cleanly so repeat toggles do not leak. - ✅ Customizer change → immediate re-render, no reload. Uses the same "live setting + persist + dispatch event" pattern as cb-presets (#1361). ## TDD - Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile provider registry (red)` — 6/7 assertions fail (stub only returns nulls). - Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4 variants wired into customizer` — 7/7 pass. ## Tests `test-issue-1420-tile-providers.js` (wired into `test-all.sh` and `.github/workflows/deploy.yml` JS-unit step): ``` ── #1420 Dark-tile provider registry ── ✅ MC_TILE_PROVIDERS has all 4 IDs with url + attribution ✅ Inverted providers have non-null invertFilter; non-inverted have null ✅ MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed ✅ MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch) ✅ MC_getDarkTileProvider falls back to server default, then carto-dark ✅ Apply filter for inverted provider in dark mode; clear when switching to non-inverted ✅ Light mode always clears the CSS filter even if inverted provider is selected 7 passed, 0 failed ``` `cd cmd/server && go build ./... && go vet ./...` — clean. ## CDP verification Not run in this PR — the sandbox does not have a Chrome CDP endpoint reachable, and staging cannot exercise this code path until this branch is deployed. The issue body's "CDP-verified candidate set" table covers prior provider-URL validation; the new code path (registry lookup + filter swap + Esri overlay lifecycle) is covered by the unit tests above. **Recommend operator run a quick manual verification on staging post-deploy:** dark mode → open customizer → cycle through all 4 providers, confirm tiles render and the CSS filter is applied for `voyager-inverted` / `positron-inverted` (verify via `getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`). ## Files touched - `public/map-tile-providers.js` (new) - `public/map.js`, `public/live.js`, `public/customize-v2.js`, `public/roles.js`, `public/index.html` - `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go` - `config.example.json` - `test-issue-1420-tile-providers.js` (new), `test-all.sh`, `.github/workflows/deploy.yml` - `.eslintrc.json` (register new `MC_*` globals) --------- Co-authored-by: openclaw <bot@openclaw.local>
312 lines
17 KiB
JSON
312 lines
17 KiB
JSON
{
|
||
"port": 3000,
|
||
"apiKey": "your-secret-api-key-here",
|
||
"nodeBlacklist": [],
|
||
"_comment_nodeBlacklist": "Public keys of nodes to hide from all API responses. Use for trolls, offensive names, or nodes reporting false data that operators refuse to fix.",
|
||
"observerIATAWhitelist": [],
|
||
"_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources.",
|
||
"retention": {
|
||
"nodeDays": 7,
|
||
"observerDays": 14,
|
||
"packetDays": 30,
|
||
"_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). packetDays: transmissions older than N days are deleted (0 = disabled). NOTE (#1283): all four retention fields are consumed by the INGESTOR process. The server is read-only and never prunes."
|
||
},
|
||
"db": {
|
||
"vacuumOnStartup": false,
|
||
"incrementalVacuumPages": 1024,
|
||
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs. Executed by the INGESTOR at startup, BEFORE the MQTT subscriber starts (#1283), so there is no contention with concurrent writes. Blocks ingestor startup for minutes on large DBs; requires 2x DB file size in free disk space. incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919."
|
||
},
|
||
"_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.",
|
||
"https": {
|
||
"cert": "/path/to/cert.pem",
|
||
"key": "/path/to/key.pem",
|
||
"_comment": "TLS cert/key paths for direct HTTPS. Most deployments use Caddy (included in Docker) for auto-TLS instead."
|
||
},
|
||
"branding": {
|
||
"siteName": "CoreScope",
|
||
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
|
||
"logoUrl": null,
|
||
"faviconUrl": null,
|
||
"_comment": "Customize site name, tagline, logo, and favicon. logoUrl/faviconUrl can be absolute URLs or relative paths."
|
||
},
|
||
"theme": {
|
||
"accent": "#4a9eff",
|
||
"accentHover": "#6db3ff",
|
||
"navBg": "#0f0f23",
|
||
"navBg2": "#1a1a2e",
|
||
"statusGreen": "#45644c",
|
||
"statusYellow": "#b08b2d",
|
||
"statusRed": "#b54a4a",
|
||
"_comment": "CSS color overrides. Use the in-app Theme Customizer for live preview, then export values here."
|
||
},
|
||
"nodeColors": {
|
||
"repeater": "#dc2626",
|
||
"companion": "#2563eb",
|
||
"room": "#16a34a",
|
||
"sensor": "#d97706",
|
||
"observer": "#8b5cf6",
|
||
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
|
||
},
|
||
"mapDarkTileProvider": "carto-dark",
|
||
"_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all — default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.",
|
||
"home": {
|
||
"heroTitle": "CoreScope",
|
||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||
"steps": [
|
||
{
|
||
"emoji": "\ud83d\udce1",
|
||
"title": "Connect",
|
||
"description": "Link your node to the mesh"
|
||
},
|
||
{
|
||
"emoji": "\ud83d\udd0d",
|
||
"title": "Monitor",
|
||
"description": "Watch packets flow in real-time"
|
||
},
|
||
{
|
||
"emoji": "\ud83d\udcca",
|
||
"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": "\ud83d\udce6 Packets",
|
||
"url": "#/packets"
|
||
},
|
||
{
|
||
"label": "\ud83d\uddfa\ufe0f Network Map",
|
||
"url": "#/map"
|
||
},
|
||
{
|
||
"label": "\ud83d\udd34 Live",
|
||
"url": "#/live"
|
||
},
|
||
{
|
||
"label": "\ud83d\udce1 All Nodes",
|
||
"url": "#/nodes"
|
||
},
|
||
{
|
||
"label": "\ud83d\udcac Channels",
|
||
"url": "#/channels"
|
||
}
|
||
],
|
||
"_comment": "Customize the landing page hero, onboarding steps, FAQ, and footer links."
|
||
},
|
||
"mqtt": {
|
||
"broker": "mqtt://localhost:1883",
|
||
"topic": "meshcore/+/+/packets",
|
||
"_comment": "Legacy single-broker config. Prefer mqttSources[] for multiple brokers."
|
||
},
|
||
"mqttSources": [
|
||
{
|
||
"name": "local",
|
||
"broker": "mqtt://localhost:1883",
|
||
"topics": [
|
||
"meshcore/+/+/packets",
|
||
"meshcore/#"
|
||
]
|
||
},
|
||
{
|
||
"name": "lincomatic",
|
||
"broker": "mqtts://mqtt.lincomatic.com:8883",
|
||
"username": "your-username",
|
||
"password": "your-password",
|
||
"rejectUnauthorized": false,
|
||
"topics": [
|
||
"meshcore/SJC/#",
|
||
"meshcore/SFO/#",
|
||
"meshcore/OAK/#",
|
||
"meshcore/MRY/#"
|
||
],
|
||
"iataFilter": [
|
||
"SJC",
|
||
"SFO",
|
||
"OAK",
|
||
"MRY"
|
||
],
|
||
"region": "SJC",
|
||
"connectTimeoutSec": 45
|
||
}
|
||
],
|
||
"channelKeys": {
|
||
"Public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||
},
|
||
"hashChannels": [
|
||
"#LongFast",
|
||
"#test",
|
||
"#sf",
|
||
"#wardrive",
|
||
"#yo",
|
||
"#bot",
|
||
"#queer",
|
||
"#bookclub",
|
||
"#shtf"
|
||
],
|
||
"healthThresholds": {
|
||
"infraDegradedHours": 24,
|
||
"infraSilentHours": 72,
|
||
"nodeDegradedHours": 1,
|
||
"nodeSilentHours": 24,
|
||
"relayActiveHours": 24,
|
||
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others. relayActiveHours: a repeater is shown as 'actively relaying' if its pubkey appeared as a path hop in a non-advert packet within this window (issue #662)."
|
||
},
|
||
"defaultRegion": "SJC",
|
||
"mapDefaults": {
|
||
"center": [
|
||
37.45,
|
||
-122.0
|
||
],
|
||
"zoom": 9
|
||
},
|
||
"geo_filter": {
|
||
"polygon": [
|
||
[37.80, -122.52],
|
||
[37.80, -121.80],
|
||
[37.20, -121.80],
|
||
[37.20, -122.52]
|
||
],
|
||
"bufferKm": 20,
|
||
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter tab in the Customizer (requires apiKey) or the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon visually and export a config snippet. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
|
||
},
|
||
"foreignAdverts": {
|
||
"mode": "flag",
|
||
"_comment": "Controls how the ingestor handles ADVERTs whose GPS is OUTSIDE the geo_filter polygon (#730). 'flag' (default): store the advert/node and tag it foreign_advert=1 so operators can see bridged/leaked nodes via the API ('foreign': true on /api/nodes). 'drop': legacy behavior — silently discard the advert (no log, no node row). Only applies when geo_filter is configured; otherwise has no effect."
|
||
},
|
||
"areas": {
|
||
"_comment": "Optional. GPS-based display filter. Each entry defines a geographic area by polygon ([lat, lon] pairs) or bounding box (latMin/latMax/lonMin/lonMax). Packets and nodes are attributed to an area based on the transmitting node's own GPS coordinates — not the observer's location. Areas appear as a filter pill bar in the dashboard. Remove this section to disable the area filter UI.",
|
||
"BAY": {
|
||
"label": "Bay Area",
|
||
"polygon": [
|
||
[37.90, -122.55],
|
||
[37.90, -121.75],
|
||
[37.25, -121.75],
|
||
[37.25, -122.55]
|
||
]
|
||
},
|
||
"SJC": {
|
||
"label": "San Jose",
|
||
"latMin": 37.20,
|
||
"latMax": 37.45,
|
||
"lonMin": -122.05,
|
||
"lonMax": -121.75
|
||
}
|
||
},
|
||
"regions": {
|
||
"SJC": "San Jose, US",
|
||
"SFO": "San Francisco, US",
|
||
"OAK": "Oakland, US",
|
||
"MRY": "Monterey, US"
|
||
},
|
||
"cacheTTL": {
|
||
"stats": 10,
|
||
"nodeDetail": 300,
|
||
"nodeHealth": 300,
|
||
"nodeList": 90,
|
||
"bulkHealth": 600,
|
||
"networkStatus": 600,
|
||
"observers": 300,
|
||
"channels": 15,
|
||
"channelMessages": 10,
|
||
"analyticsRF": 1800,
|
||
"_comment_analyticsRF": "TTL (seconds) for the shared analytics result cache. Backs /api/analytics/rf, /api/analytics/topology, /api/analytics/distance, /api/analytics/hash-sizes, /api/analytics/channels, /api/analytics/subpaths. Default 60s if unset (#1239) — distance analytics is viewed live during active analysis, so the default smooths cold-miss churn without freezing data. Lower with care; sub-15s values can cause repeated multi-second cold computes during heavy ingest.",
|
||
"analyticsTopology": 1800,
|
||
"analyticsChannels": 1800,
|
||
"analyticsHashSizes": 3600,
|
||
"analyticsSubpaths": 3600,
|
||
"analyticsSubpathDetail": 3600,
|
||
"nodeAnalytics": 60,
|
||
"nodeSearch": 10,
|
||
"invalidationDebounce": 30,
|
||
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
|
||
},
|
||
"liveMap": {
|
||
"propagationBufferMs": 5000,
|
||
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
|
||
},
|
||
"timestamps": {
|
||
"defaultMode": "ago",
|
||
"timezone": "local",
|
||
"formatPreset": "iso",
|
||
"customFormat": "",
|
||
"allowCustomFormat": false,
|
||
"_comment": "defaultMode: ago|local|iso. timezone: local|utc. formatPreset: iso|us|eu. customFormat: strftime-style (requires allowCustomFormat: true)."
|
||
},
|
||
"packetStore": {
|
||
"maxMemoryMB": 1024,
|
||
"estimatedPacketBytes": 450,
|
||
"retentionHours": 168,
|
||
"hotStartupHours": 0,
|
||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are kept in memory (0 = unlimited). hotStartupHours: hours loaded synchronously at startup; background loader fills the remaining retentionHours window. 0 = disabled (loads full retentionHours synchronously, legacy behavior). Set to a positive value (e.g. 24) to reduce startup time on large DBs.",
|
||
"_comment_gomemlimit": "On startup the server reads GOMEMLIMIT from the environment if set; otherwise it derives a Go runtime soft memory limit of maxMemoryMB * 1.5 and applies it via debug.SetMemoryLimit. This forces aggressive GC under cgroup pressure so the process self-throttles before the kernel SIGKILLs it. To override, set GOMEMLIMIT explicitly (e.g. GOMEMLIMIT=850MiB). See issue #836."
|
||
},
|
||
"resolvedPath": {
|
||
"backfillHours": 24,
|
||
"_comment": "How far back (hours) the async backfill scans for observations with NULL resolved_path. Default: 24. Set higher to backfill older data, lower to speed up startup."
|
||
},
|
||
"neighborGraph": {
|
||
"maxAgeDays": 5,
|
||
"maxEdgeKm": 500,
|
||
"_comment": "maxAgeDays: neighbor edges older than this many days are pruned on startup and daily. Default 5. maxEdgeKm: geo-implausibility filter — when both endpoints have GPS, edges with haversine distance > maxEdgeKm are rejected at build time to prevent disambiguator self-reinforcement on wide-geo MQTT deployments. Default 500 km (well above any plausible terrestrial LoRa hop). 0 ⇒ use default; set negative to disable the filter. Rejected count surfaces in /api/analytics/neighbor-graph stats. Issue #1228."
|
||
},
|
||
"batteryThresholds": {
|
||
"lowMv": 3300,
|
||
"criticalMv": 3000,
|
||
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
|
||
},
|
||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||
"compression": {
|
||
"gzip": false,
|
||
"websocket": false,
|
||
"level": 6,
|
||
"minSizeBytes": 1024,
|
||
"contentTypes": [
|
||
"application/json",
|
||
"application/javascript",
|
||
"application/xml",
|
||
"text/html",
|
||
"text/css",
|
||
"text/plain",
|
||
"image/svg+xml"
|
||
]
|
||
},
|
||
"_comment_compression": "Opt-in HTTP gzip middleware + WebSocket permessage-deflate. Both default to false — enable ONLY when your upstream reverse proxy is NOT already compressing. gzip: enables the gzipMiddleware wrapper around the HTTP handler. websocket: sets gorilla websocket Upgrader.EnableCompression. level: gzip compression level 1..9 (1=BestSpeed, 9=BestCompression, default 6). minSizeBytes: advisory minimum response size below which compression would not pay off. contentTypes: MIME allow-list — only responses with these Content-Type values are compressed. Already-compressed types (image/*, video/*, audio/*, application/zip, application/x-gzip, application/pdf, application/octet-stream) are always skipped, as are responses whose handler already set Content-Encoding. Omit contentTypes to use the built-in default allow-list.",
|
||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional).",
|
||
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
|
||
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
|
||
"hashRegions": [
|
||
"#belgium",
|
||
"#eu"
|
||
],
|
||
"_comment_hashRegions": "Region names for scope matching on transport-route packets. Key = SHA256('#name')[:16]. Add any region names used by nodes in your network.",
|
||
"_comment_defaultRegion": "IATA code shown by default in region filters.",
|
||
"_comment_mapDefaults": "Initial map center [lat, lon] and zoom level.",
|
||
"_comment_regions": "IATA code → display name mapping for the region filter UI. Each key is a 3-letter IATA code that an observer is tagged with (resolved priority: MQTT payload `region` field > topic-derived region > mqttSources.region). Observers without an IATA tag will not appear under any region filter — only under 'All Regions'. The region filter dropdown shows one entry per code listed here PLUS any extra IATA codes the server discovers from observers at runtime (so you can omit codes here and they will still be selectable, just labelled with the bare IATA code instead of a friendly name). Selecting 'All Regions' (or no region) returns results from every observer including those with no IATA tag; selecting one or more codes restricts results to packets observed by observers tagged with those codes. The reserved value 'All' (case-insensitive) is treated as 'no filter' on the server, so the URL ?region=All behaves identically to omitting the param. Issue #770.",
|
||
|
||
"analytics": {
|
||
"defaultIntervalSeconds": 300,
|
||
"recomputeIntervalSeconds": {
|
||
"topology": 300,
|
||
"rf": 300,
|
||
"distance": 300,
|
||
"channels": 300,
|
||
"hashCollisions": 300,
|
||
"hashSizes": 300,
|
||
"roles": 300,
|
||
"observersClockSkew": 300,
|
||
"nodesClockSkew": 300
|
||
}
|
||
},
|
||
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache."
|
||
}
|