mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-31 05:15:39 +00:00
Compare commits
9 Commits
fix/byop-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c9211dac | ||
|
|
6922d63b1c | ||
|
|
f28a3146da | ||
|
|
3a1d7263b4 | ||
|
|
92188e8c12 | ||
|
|
380da0ee0b | ||
|
|
7155b5b017 | ||
|
|
30fe629bb4 | ||
|
|
65c95611f9 |
95
.env.example
95
.env.example
@@ -1,44 +1,51 @@
|
||||
# MeshCore Analyzer — Environment Configuration
|
||||
# Copy to .env and customize. All values have sensible defaults.
|
||||
#
|
||||
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
|
||||
# Each environment keeps config + data together in one directory:
|
||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||
|
||||
# --- Production ---
|
||||
# Data directory (database, theme, etc.)
|
||||
# Default: ~/meshcore-data
|
||||
# Used by: docker compose, manage.sh
|
||||
PROD_DATA_DIR=~/meshcore-data
|
||||
|
||||
# HTTP port for web UI
|
||||
# Default: 80
|
||||
# Used by: docker compose
|
||||
PROD_HTTP_PORT=80
|
||||
|
||||
# HTTPS port for web UI (TLS via Caddy)
|
||||
# Default: 443
|
||||
# Used by: docker compose
|
||||
PROD_HTTPS_PORT=443
|
||||
|
||||
# MQTT port for observer connections
|
||||
# Default: 1883
|
||||
# Used by: docker compose
|
||||
PROD_MQTT_PORT=1883
|
||||
|
||||
# --- Staging (HTTP only, no HTTPS) ---
|
||||
# Data directory
|
||||
# Default: ~/meshcore-staging-data
|
||||
# Used by: docker compose
|
||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
||||
|
||||
# HTTP port
|
||||
# Default: 81
|
||||
# Used by: docker compose
|
||||
STAGING_HTTP_PORT=81
|
||||
|
||||
# MQTT port
|
||||
# Default: 1884
|
||||
# Used by: docker compose
|
||||
STAGING_MQTT_PORT=1884
|
||||
# MeshCore Analyzer — Environment Configuration
|
||||
# Copy to .env and customize. All values have sensible defaults.
|
||||
#
|
||||
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
|
||||
# manage.sh setup negotiates and updates only these production managed keys:
|
||||
# PROD_DATA_DIR, PROD_HTTP_PORT, PROD_HTTPS_PORT, PROD_MQTT_PORT, DISABLE_MOSQUITTO
|
||||
# Each environment keeps config + data together in one directory:
|
||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||
|
||||
# --- Production ---
|
||||
# Data directory (database, theme, etc.)
|
||||
# Default: ~/meshcore-data
|
||||
# Used by: docker compose, manage.sh
|
||||
PROD_DATA_DIR=~/meshcore-data
|
||||
|
||||
# HTTP port for web UI
|
||||
# Default: 80
|
||||
# Used by: docker compose
|
||||
PROD_HTTP_PORT=80
|
||||
|
||||
# HTTPS port for web UI (TLS via Caddy)
|
||||
# Default: 443
|
||||
# Used by: docker compose
|
||||
PROD_HTTPS_PORT=443
|
||||
|
||||
# MQTT port for observer connections
|
||||
# Default: 1883
|
||||
# Used by: docker compose
|
||||
PROD_MQTT_PORT=1883
|
||||
|
||||
# Disable internal Mosquitto broker (set true to use external MQTT only)
|
||||
# Default: false
|
||||
# Used by: manage.sh + docker compose overrides
|
||||
DISABLE_MOSQUITTO=false
|
||||
|
||||
# --- Staging (HTTP only, no HTTPS) ---
|
||||
# Data directory
|
||||
# Default: ~/meshcore-staging-data
|
||||
# Used by: docker compose
|
||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
||||
|
||||
# HTTP port
|
||||
# Default: 82
|
||||
# Used by: docker compose
|
||||
STAGING_GO_HTTP_PORT=82
|
||||
|
||||
# MQTT port
|
||||
# Default: 1885
|
||||
# Used by: docker compose
|
||||
STAGING_GO_MQTT_PORT=1885
|
||||
|
||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
|
||||
@@ -94,6 +94,9 @@ The packets page loads 30K+ packets. Don't add per-packet API calls. Don't add O
|
||||
### 11. PR descriptions must be clean markdown
|
||||
When opening a pull request, the description must be **valid, readable markdown**. Use real newlines (not `\n` literals), proper code fences, and correct heading syntax. Write it using `--body-file -` (piped from a heredoc or file), never inline `--body` with escaped characters. If the description renders as garbage, fix it before requesting review. This is the first thing reviewers see.
|
||||
|
||||
### 12. Post a follow-up comment when review feedback is addressed
|
||||
When you push fixes for review comments, post a comment on the PR listing what was changed and the commit hash. Reviewers should not have to dig through commits to find what was fixed. Format: "Review feedback addressed (commit `abc1234`)" followed by a numbered list of what was done.
|
||||
|
||||
## MeshCore Firmware — Source of Truth
|
||||
|
||||
The MeshCore firmware source is cloned at `firmware/` (gitignored — not part of this repo). This is THE authoritative reference for anything related to the protocol, packet format, device behavior, advert structure, flags, hash sizes, route types, or how repeaters/companions/rooms/sensors behave.
|
||||
|
||||
@@ -40,6 +40,7 @@ RUN echo "unknown" > .git-commit
|
||||
|
||||
# Supervisor + Mosquitto + Caddy config
|
||||
COPY docker/supervisord-go.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/supervisord-go-no-mosquitto.conf /etc/supervisor/conf.d/supervisord-no-mosquitto.conf
|
||||
COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf
|
||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
|
||||
// DBStats tracks operational metrics for the ingestor database.
|
||||
type DBStats struct {
|
||||
TransmissionsInserted atomic.Int64
|
||||
ObservationsInserted atomic.Int64
|
||||
TransmissionsInserted atomic.Int64
|
||||
ObservationsInserted atomic.Int64
|
||||
DuplicateTransmissions atomic.Int64
|
||||
NodeUpserts atomic.Int64
|
||||
ObserverUpserts atomic.Int64
|
||||
WriteErrors atomic.Int64
|
||||
NodeUpserts atomic.Int64
|
||||
ObserverUpserts atomic.Int64
|
||||
WriteErrors atomic.Int64
|
||||
}
|
||||
|
||||
// Store wraps the SQLite database for packet ingestion.
|
||||
@@ -35,8 +35,8 @@ type Store struct {
|
||||
stmtUpsertNode *sql.Stmt
|
||||
stmtIncrementAdvertCount *sql.Stmt
|
||||
stmtUpsertObserver *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
}
|
||||
|
||||
// OpenStore opens or creates a SQLite DB at the given path, applying the
|
||||
@@ -333,13 +333,17 @@ func (s *Store) prepareStatements() error {
|
||||
}
|
||||
|
||||
s.stmtUpsertObserver, err = s.db.Prepare(`
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(?, name),
|
||||
iata = COALESCE(?, iata),
|
||||
last_seen = ?,
|
||||
packet_count = packet_count + 1,
|
||||
model = COALESCE(?, model),
|
||||
firmware = COALESCE(?, firmware),
|
||||
client_version = COALESCE(?, client_version),
|
||||
radio = COALESCE(?, radio),
|
||||
battery_mv = COALESCE(?, battery_mv),
|
||||
uptime_secs = COALESCE(?, uptime_secs),
|
||||
noise_floor = COALESCE(?, noise_floor)
|
||||
@@ -485,17 +489,34 @@ func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC
|
||||
|
||||
// ObserverMeta holds optional observer hardware metadata.
|
||||
type ObserverMeta struct {
|
||||
BatteryMv *int // millivolts, always integer
|
||||
UptimeSecs *int64 // seconds, always integer
|
||||
NoiseFloor *float64 // dBm, may have decimals
|
||||
Model *string // e.g., L1
|
||||
Firmware *string // firmware version string
|
||||
ClientVersion *string // client app version string
|
||||
Radio *string // radio chipset/platform string
|
||||
BatteryMv *int // millivolts, always integer
|
||||
UptimeSecs *int64 // seconds, always integer
|
||||
NoiseFloor *float64 // dBm, may have decimals
|
||||
}
|
||||
|
||||
// UpsertObserver inserts or updates an observer with optional hardware metadata.
|
||||
func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var model, firmware, clientVersion, radio interface{}
|
||||
var batteryMv, uptimeSecs, noiseFloor interface{}
|
||||
if meta != nil {
|
||||
if meta.Model != nil {
|
||||
model = *meta.Model
|
||||
}
|
||||
if meta.Firmware != nil {
|
||||
firmware = *meta.Firmware
|
||||
}
|
||||
if meta.ClientVersion != nil {
|
||||
clientVersion = *meta.ClientVersion
|
||||
}
|
||||
if meta.Radio != nil {
|
||||
radio = *meta.Radio
|
||||
}
|
||||
if meta.BatteryMv != nil {
|
||||
batteryMv = *meta.BatteryMv
|
||||
}
|
||||
@@ -508,8 +529,8 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
|
||||
}
|
||||
|
||||
_, err := s.stmtUpsertObserver.Exec(
|
||||
id, name, iata, now, now, batteryMv, uptimeSecs, noiseFloor,
|
||||
name, iata, now, batteryMv, uptimeSecs, noiseFloor,
|
||||
id, name, iata, now, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
name, iata, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -476,6 +476,31 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
||||
meta := &ObserverMeta{}
|
||||
hasData := false
|
||||
|
||||
if v, ok := msg["model"].(string); ok && v != "" {
|
||||
meta.Model = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["firmware"].(string); ok && v != "" {
|
||||
meta.Firmware = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["firmware_version"].(string); ok && v != "" {
|
||||
meta.Firmware = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["client_version"].(string); ok && v != "" {
|
||||
meta.ClientVersion = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["clientVersion"].(string); ok && v != "" {
|
||||
meta.ClientVersion = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["radio"].(string); ok && v != "" {
|
||||
meta.Radio = &v
|
||||
hasData = true
|
||||
}
|
||||
|
||||
if v, ok := msg["battery_mv"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int(math.Round(f))
|
||||
|
||||
@@ -177,13 +177,13 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
source := MQTTSource{Name: "test"}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"origin":"MyObserver"}`),
|
||||
payload: []byte(`{"origin":"MyObserver","model":"L1","firmware_version":"v1.2.3","client_version":"2.4.1","radio":"SX1262"}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
|
||||
var name, iata string
|
||||
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
|
||||
var name, iata, model, firmware, clientVersion, radio string
|
||||
err := store.db.QueryRow("SELECT name, iata, model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").Scan(&name, &iata, &model, &firmware, &clientVersion, &radio)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,6 +193,39 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
if iata != "SJC" {
|
||||
t.Errorf("iata=%s, want SJC", iata)
|
||||
}
|
||||
if model != "L1" {
|
||||
t.Errorf("model=%s, want L1", model)
|
||||
}
|
||||
if firmware != "v1.2.3" {
|
||||
t.Errorf("firmware=%s, want v1.2.3", firmware)
|
||||
}
|
||||
if clientVersion != "2.4.1" {
|
||||
t.Errorf("client_version=%s, want 2.4.1", clientVersion)
|
||||
}
|
||||
if radio != "SX1262" {
|
||||
t.Errorf("radio=%s, want SX1262", radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageStatusTopicMissingIdentityFields(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"origin":"MyObserver","battery_mv":3500}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
|
||||
var model, firmware, clientVersion, radio interface{}
|
||||
err := store.db.QueryRow("SELECT model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").
|
||||
Scan(&model, &firmware, &clientVersion, &radio)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model != nil || firmware != nil || clientVersion != nil || radio != nil {
|
||||
t.Errorf("identity fields should remain NULL when absent: model=%v firmware=%v client_version=%v radio=%v", model, firmware, clientVersion, radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
||||
|
||||
31
docker-compose.no-mosquitto.yml
Normal file
31
docker-compose.no-mosquitto.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
prod:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
image: corescope:latest
|
||||
container_name: corescope-prod
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${PROD_HTTP_PORT:-80}:80"
|
||||
- "${PROD_HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
|
||||
- caddy-data:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DISABLE_MOSQUITTO=true
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
38
docker-compose.staging.no-mosquitto.yml
Normal file
38
docker-compose.staging.no-mosquitto.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
staging-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
image: corescope-go:latest
|
||||
container_name: corescope-staging-go
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 3g
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
||||
- "6060:6060"
|
||||
- "6061:6061"
|
||||
volumes:
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
|
||||
- caddy-data-staging-go:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=staging
|
||||
- ENABLE_PPROF=true
|
||||
- DISABLE_MOSQUITTO=true
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
caddy-data-staging-go:
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=staging
|
||||
- ENABLE_PPROF=true
|
||||
- DISABLE_MOSQUITTO=${DISABLE_MOSQUITTO:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
- caddy-data:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DISABLE_MOSQUITTO=${DISABLE_MOSQUITTO:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
|
||||
@@ -14,4 +14,10 @@ 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
|
||||
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf"
|
||||
if [ "${DISABLE_MOSQUITTO:-false}" = "true" ]; then
|
||||
echo "[config] internal MQTT broker disabled (DISABLE_MOSQUITTO=true)"
|
||||
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord-no-mosquitto.conf"
|
||||
fi
|
||||
|
||||
exec /usr/bin/supervisord -c "$SUPERVISORD_CONF"
|
||||
|
||||
40
docker/supervisord-go-no-mosquitto.conf
Normal file
40
docker/supervisord-go-no-mosquitto.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:corescope-ingestor]
|
||||
command=/app/corescope-ingestor -config /app/config.json
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:corescope-server]
|
||||
command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:caddy]
|
||||
command=/usr/sbin/caddy run --config /etc/caddy/Caddyfile
|
||||
environment=XDG_DATA_HOME="/data"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
@@ -9,8 +9,93 @@
|
||||
let autoScroll = true;
|
||||
let nodeCache = {};
|
||||
let selectedNode = null;
|
||||
let observerIataById = {};
|
||||
let observerIataByName = {};
|
||||
let messageRequestId = 0;
|
||||
var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function getSelectedRegionsSnapshot() {
|
||||
var rp = RegionFilter.getRegionParam();
|
||||
return rp ? rp.split(',').filter(Boolean) : null;
|
||||
}
|
||||
|
||||
function normalizeObserverNameKey(name) {
|
||||
if (!name) return '';
|
||||
return String(name).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegionsById, observerRegionsByName) {
|
||||
if (!selectedRegions || !selectedRegions.length) return true;
|
||||
if (observerRegionsById && observerRegionsById.byId) {
|
||||
observerRegionsByName = observerRegionsById.byName || {};
|
||||
observerRegionsById = observerRegionsById.byId || {};
|
||||
}
|
||||
observerRegionsById = observerRegionsById || {};
|
||||
observerRegionsByName = observerRegionsByName || {};
|
||||
|
||||
var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null;
|
||||
var observerRegion = observerId ? observerRegionsById[observerId] : null;
|
||||
if (!observerRegion) {
|
||||
var observerName = msg?.data?.packet?.observer_name || msg?.data?.observer_name || msg?.data?.observer || null;
|
||||
var observerNameKey = normalizeObserverNameKey(observerName);
|
||||
if (observerName) observerRegion = observerRegionsByName[observerName];
|
||||
if (!observerRegion && observerNameKey) observerRegion = observerRegionsByName[observerNameKey];
|
||||
}
|
||||
if (!observerRegion) return false;
|
||||
return selectedRegions.indexOf(observerRegion) !== -1;
|
||||
}
|
||||
|
||||
async function loadObserverRegions() {
|
||||
try {
|
||||
var data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
var list = data && data.observers ? data.observers : [];
|
||||
var byId = {};
|
||||
var byName = {};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var o = list[i];
|
||||
var id = o.id || o.observer_id;
|
||||
var name = o.name || o.observer_name;
|
||||
if (!o.iata) continue;
|
||||
if (id) byId[id] = o.iata;
|
||||
if (name) {
|
||||
byName[name] = o.iata;
|
||||
var key = normalizeObserverNameKey(name);
|
||||
if (key) byName[key] = o.iata;
|
||||
}
|
||||
}
|
||||
observerIataById = byId;
|
||||
observerIataByName = byName;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function beginMessageRequest(hash, regionParam) {
|
||||
return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' };
|
||||
}
|
||||
|
||||
function isStaleMessageRequest(req) {
|
||||
if (!req) return true;
|
||||
var currentRegion = RegionFilter.getRegionParam() || '';
|
||||
if (req.id !== messageRequestId) return true;
|
||||
if (selectedHash !== req.hash) return true;
|
||||
if (currentRegion !== req.regionParam) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function reconcileSelectionAfterChannelRefresh() {
|
||||
if (!selectedHash || channels.some(ch => ch.hash === selectedHash)) return false;
|
||||
selectedHash = null;
|
||||
messages = [];
|
||||
history.replaceState(null, '', '#/channels');
|
||||
renderChannelList();
|
||||
const header = document.getElementById('chHeader');
|
||||
if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel';
|
||||
const msgEl = document.getElementById('chMessages');
|
||||
if (msgEl) msgEl.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||||
document.querySelector('.ch-layout')?.classList.remove('ch-show-main');
|
||||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function lookupNode(name) {
|
||||
var cached = nodeCache[name];
|
||||
if (cached !== undefined) {
|
||||
@@ -251,8 +336,14 @@
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); });
|
||||
regionChangeHandler = RegionFilter.onChange(function () {
|
||||
loadChannels(true).then(async function () {
|
||||
if (!selectedHash) return;
|
||||
await refreshMessages({ regionSwitch: true, forceNoCache: true });
|
||||
});
|
||||
});
|
||||
|
||||
loadObserverRegions();
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
});
|
||||
@@ -382,7 +473,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
function processWSBatch(msgs, selectedRegions) {
|
||||
var dominated = msgs.filter(function (m) {
|
||||
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
|
||||
});
|
||||
@@ -394,6 +485,7 @@
|
||||
|
||||
for (var i = 0; i < dominated.length; i++) {
|
||||
var m = dominated[i];
|
||||
if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataById, observerIataByName)) continue;
|
||||
var payload = m.data?.decoded?.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
@@ -494,7 +586,18 @@
|
||||
if (liveEl) liveEl.textContent = 'New message received';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSBatch(msgs) {
|
||||
var selectedRegions = getSelectedRegionsSnapshot();
|
||||
processWSBatch(msgs, selectedRegions);
|
||||
}
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
handleWSBatch(msgs);
|
||||
});
|
||||
window._channelsHandleWSBatchForTest = handleWSBatch;
|
||||
window._channelsProcessWSBatchForTest = processWSBatch;
|
||||
|
||||
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
|
||||
timeAgoTimer = setInterval(function () {
|
||||
@@ -536,6 +639,7 @@
|
||||
return ch;
|
||||
}).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||||
renderChannelList();
|
||||
reconcileSelectionAfterChannelRefresh();
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
const el = document.getElementById('chList');
|
||||
@@ -578,6 +682,8 @@
|
||||
}
|
||||
|
||||
async function selectChannel(hash) {
|
||||
const rp = RegionFilter.getRegionParam() || '';
|
||||
const request = beginMessageRequest(hash, rp);
|
||||
selectedHash = hash;
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
@@ -593,23 +699,42 @@
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
try {
|
||||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : '';
|
||||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages });
|
||||
if (isStaleMessageRequest(request)) return;
|
||||
messages = data.messages || [];
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
if (messages.length === 0 && rp) {
|
||||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||||
} else {
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
if (isStaleMessageRequest(request)) return;
|
||||
msgEl.innerHTML = `<div class="ch-empty">Failed to load messages: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMessages() {
|
||||
async function refreshMessages(opts) {
|
||||
if (!selectedHash) return;
|
||||
opts = opts || {};
|
||||
const msgEl = document.getElementById('chMessages');
|
||||
if (!msgEl) return;
|
||||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||||
try {
|
||||
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const requestHash = selectedHash;
|
||||
const rp = RegionFilter.getRegionParam() || '';
|
||||
const request = beginMessageRequest(requestHash, rp);
|
||||
const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : '';
|
||||
const data = await api(`/channels/${encodeURIComponent(requestHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache });
|
||||
if (isStaleMessageRequest(request)) return;
|
||||
const newMsgs = data.messages || [];
|
||||
if (opts.regionSwitch && rp && newMsgs.length === 0) {
|
||||
messages = [];
|
||||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// #92: Use message ID/hash for change detection instead of count + timestamp
|
||||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||||
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
|
||||
@@ -665,5 +790,25 @@
|
||||
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
window._channelsSetStateForTest = function (state) {
|
||||
if (!state) return;
|
||||
if (Array.isArray(state.channels)) channels = state.channels;
|
||||
if (Array.isArray(state.messages)) messages = state.messages;
|
||||
if (Object.prototype.hasOwnProperty.call(state, 'selectedHash')) selectedHash = state.selectedHash;
|
||||
};
|
||||
window._channelsSetObserverRegionsForTest = function (byId, byName) {
|
||||
observerIataById = byId || {};
|
||||
observerIataByName = byName || {};
|
||||
};
|
||||
window._channelsSelectChannelForTest = selectChannel;
|
||||
window._channelsRefreshMessagesForTest = refreshMessages;
|
||||
window._channelsLoadChannelsForTest = loadChannels;
|
||||
window._channelsBeginMessageRequestForTest = beginMessageRequest;
|
||||
window._channelsIsStaleMessageRequestForTest = isStaleMessageRequest;
|
||||
window._channelsReconcileSelectionForTest = reconcileSelectionAfterChannelRefresh;
|
||||
window._channelsGetStateForTest = function () {
|
||||
return { channels: channels, messages: messages, selectedHash: selectedHash };
|
||||
};
|
||||
window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion;
|
||||
registerPage('channels', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<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/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774923001">
|
||||
<link rel="stylesheet" href="home.css?v=1774923001">
|
||||
<link rel="stylesheet" href="live.css?v=1774923001">
|
||||
<link rel="stylesheet" href="style.css?v=1774926567">
|
||||
<link rel="stylesheet" href="home.css?v=1774926567">
|
||||
<link rel="stylesheet" href="live.css?v=1774926567">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,30 +81,31 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774923001"></script>
|
||||
<script src="customize.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774923001"></script>
|
||||
<script src="hop-resolver.js?v=1774923001"></script>
|
||||
<script src="hop-display.js?v=1774923001"></script>
|
||||
<script src="app.js?v=1774923001"></script>
|
||||
<script src="home.js?v=1774923001"></script>
|
||||
<script src="packet-filter.js?v=1774923001"></script>
|
||||
<script src="packets.js?v=1774923001"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774923001"></script>
|
||||
<script src="map.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774923001" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774926567"></script>
|
||||
<script src="customize.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774926567"></script>
|
||||
<script src="hop-resolver.js?v=1774926567"></script>
|
||||
<script src="hop-display.js?v=1774926567"></script>
|
||||
<script src="app.js?v=1774926567"></script>
|
||||
<script src="home.js?v=1774926567"></script>
|
||||
<script src="packet-filter.js?v=1774926567"></script>
|
||||
<script src="packets.js?v=1774926567"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774926567"></script>
|
||||
<script src="map.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@
|
||||
let _docActionHandler = null;
|
||||
let _docMenuCloseHandler = null;
|
||||
let _docColMenuCloseHandler = null;
|
||||
let _docEscHandler = null;
|
||||
|
||||
let directObsId = null;
|
||||
|
||||
@@ -173,15 +172,12 @@
|
||||
? _docActionHandler
|
||||
: kind === 'menu'
|
||||
? _docMenuCloseHandler
|
||||
: kind === 'colmenu'
|
||||
? _docColMenuCloseHandler
|
||||
: _docEscHandler;
|
||||
: _docColMenuCloseHandler;
|
||||
if (prev) document.removeEventListener(eventName, prev);
|
||||
document.addEventListener(eventName, handler);
|
||||
if (kind === 'action') _docActionHandler = handler;
|
||||
else if (kind === 'menu') _docMenuCloseHandler = handler;
|
||||
else if (kind === 'colmenu') _docColMenuCloseHandler = handler;
|
||||
else _docEscHandler = handler;
|
||||
else _docColMenuCloseHandler = handler;
|
||||
}
|
||||
|
||||
function renderTimestampCell(isoString) {
|
||||
@@ -403,7 +399,6 @@
|
||||
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
|
||||
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
|
||||
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
|
||||
if (_docEscHandler) { document.removeEventListener('keydown', _docEscHandler); _docEscHandler = null; }
|
||||
removeAllByopOverlays();
|
||||
packets = [];
|
||||
hashIndex = new Map(); selectedId = null;
|
||||
@@ -972,7 +967,7 @@
|
||||
}
|
||||
|
||||
// Escape to close packet detail panel
|
||||
bindDocumentHandler('esc', 'keydown', function pktEsc(e) {
|
||||
document.addEventListener('keydown', function pktEsc(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDetailPanel();
|
||||
}
|
||||
|
||||
@@ -1300,9 +1300,6 @@ console.log('\n=== compare.js: comparePacketSets ===');
|
||||
{
|
||||
console.log('\nPackets page — detail pane initial state:');
|
||||
const packetsSource = fs.readFileSync('public/packets.js', 'utf8');
|
||||
const removeAllByopOverlaysSource = extractFunctionSourceFromText(packetsSource, 'removeAllByopOverlays');
|
||||
const showBYOPSource = extractFunctionSourceFromText(packetsSource, 'showBYOP');
|
||||
const bindDocumentHandlerSource = extractFunctionSourceFromText(packetsSource, 'bindDocumentHandler');
|
||||
|
||||
test('split-layout starts with detail-collapsed class', () => {
|
||||
// The template literal that creates the split-layout must include detail-collapsed
|
||||
@@ -1344,175 +1341,6 @@ console.log('\n=== compare.js: comparePacketSets ===');
|
||||
assert.ok(packetsSource.includes("if (prev) document.removeEventListener(eventName, prev);"),
|
||||
'bindDocumentHandler should remove previous handler before re-binding');
|
||||
});
|
||||
|
||||
test('BYOP repeated opens keep exactly one overlay', () => {
|
||||
assert.ok(removeAllByopOverlaysSource, 'removeAllByopOverlays source should be present');
|
||||
assert.ok(showBYOPSource, 'showBYOP source should be present');
|
||||
const ctx = vm.createContext({
|
||||
document: createPacketsTestDocument(),
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
renderDecodedPacket: () => '',
|
||||
routeTypeName: () => 'UNKNOWN',
|
||||
payloadTypeName: () => 'UNKNOWN',
|
||||
});
|
||||
vm.runInContext(`${removeAllByopOverlaysSource}\n${showBYOPSource}`, ctx);
|
||||
ctx.showBYOP();
|
||||
ctx.showBYOP();
|
||||
assert.strictEqual(ctx.document.getOverlayCount(), 1, 'repeated opens should leave one overlay');
|
||||
});
|
||||
|
||||
test('BYOP close removes all overlays', () => {
|
||||
assert.ok(removeAllByopOverlaysSource, 'removeAllByopOverlays source should be present');
|
||||
assert.ok(showBYOPSource, 'showBYOP source should be present');
|
||||
const ctx = vm.createContext({
|
||||
document: createPacketsTestDocument(),
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
renderDecodedPacket: () => '',
|
||||
routeTypeName: () => 'UNKNOWN',
|
||||
payloadTypeName: () => 'UNKNOWN',
|
||||
});
|
||||
vm.runInContext(`${removeAllByopOverlaysSource}\n${showBYOPSource}`, ctx);
|
||||
ctx.showBYOP();
|
||||
// Simulate stale stacked overlay from prior bad state; close should clear all.
|
||||
ctx.document.appendOverlay();
|
||||
assert.strictEqual(ctx.document.getOverlayCount(), 2, 'setup should contain two overlays');
|
||||
ctx.document.getFirstOverlay().querySelector('.byop-x').onclick();
|
||||
assert.strictEqual(ctx.document.getOverlayCount(), 0, 'close should remove all BYOP overlays');
|
||||
});
|
||||
|
||||
test('bindDocumentHandler removes previous handlers across SPA re-init', () => {
|
||||
assert.ok(bindDocumentHandlerSource, 'bindDocumentHandler source should be present');
|
||||
const doc = createBindingTestDocument();
|
||||
const ctx = vm.createContext({
|
||||
document: doc,
|
||||
_docActionHandler: null,
|
||||
_docMenuCloseHandler: null,
|
||||
_docColMenuCloseHandler: null,
|
||||
_docEscHandler: null,
|
||||
});
|
||||
vm.runInContext(bindDocumentHandlerSource, ctx);
|
||||
|
||||
let clicks = 0;
|
||||
const clickHandlerV1 = () => { clicks += 1; };
|
||||
const clickHandlerV2 = () => { clicks += 1; };
|
||||
ctx.bindDocumentHandler('action', 'click', clickHandlerV1);
|
||||
ctx.bindDocumentHandler('action', 'click', clickHandlerV2);
|
||||
doc.dispatch('click', { type: 'click' });
|
||||
assert.strictEqual(clicks, 1, 'only latest click handler should fire once');
|
||||
assert.strictEqual(doc.getRemoveCount('click'), 1, 'rebind should remove prior click handler');
|
||||
|
||||
let esc = 0;
|
||||
const escHandlerV1 = () => { esc += 1; };
|
||||
const escHandlerV2 = () => { esc += 1; };
|
||||
ctx.bindDocumentHandler('esc', 'keydown', escHandlerV1);
|
||||
ctx.bindDocumentHandler('esc', 'keydown', escHandlerV2);
|
||||
doc.dispatch('keydown', { key: 'Escape' });
|
||||
assert.strictEqual(esc, 1, 'only latest esc handler should fire once');
|
||||
assert.strictEqual(doc.getRemoveCount('keydown'), 1, 'rebind should remove prior keydown handler');
|
||||
});
|
||||
}
|
||||
|
||||
function extractFunctionSourceFromText(source, functionName) {
|
||||
const start = source.indexOf(`function ${functionName}(`);
|
||||
if (start === -1) return null;
|
||||
let braceStart = source.indexOf('{', start);
|
||||
if (braceStart === -1) return null;
|
||||
let depth = 0;
|
||||
for (let i = braceStart; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') depth -= 1;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createPacketsTestDocument() {
|
||||
let overlays = [];
|
||||
let activeElement = null;
|
||||
|
||||
function createFocusable() {
|
||||
const focusable = {
|
||||
onclick: null,
|
||||
addEventListener: () => {},
|
||||
focus: () => { activeElement = focusable; }
|
||||
};
|
||||
return focusable;
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
let removed = false;
|
||||
const closeBtn = createFocusable();
|
||||
const decodeBtn = createFocusable();
|
||||
const textarea = createFocusable();
|
||||
textarea.value = '';
|
||||
const result = { innerHTML: '' };
|
||||
const modal = {
|
||||
querySelectorAll: () => [textarea, decodeBtn, closeBtn],
|
||||
};
|
||||
const overlayObj = {
|
||||
className: '',
|
||||
innerHTML: '',
|
||||
addEventListener: () => {},
|
||||
querySelector: (sel) => {
|
||||
if (sel === '.byop-modal') return modal;
|
||||
if (sel === '.byop-x') return closeBtn;
|
||||
if (sel === '#byopHex') return textarea;
|
||||
if (sel === '#byopDecode') return decodeBtn;
|
||||
if (sel === '#byopResult') return result;
|
||||
return null;
|
||||
},
|
||||
remove: () => {
|
||||
removed = true;
|
||||
overlays = overlays.filter(o => o !== overlayObj);
|
||||
},
|
||||
__removed: () => removed,
|
||||
};
|
||||
return overlayObj;
|
||||
}
|
||||
|
||||
const triggerBtn = { focus: () => { activeElement = triggerBtn; } };
|
||||
|
||||
return {
|
||||
body: {
|
||||
appendChild: (el) => { overlays.push(el); }
|
||||
},
|
||||
querySelector: (sel) => (sel === '[data-action="pkt-byop"]' ? triggerBtn : null),
|
||||
querySelectorAll: (sel) => {
|
||||
if (sel !== '.byop-overlay') return [];
|
||||
return overlays.filter(o => !o.__removed || !o.__removed());
|
||||
},
|
||||
createElement: () => createOverlay(),
|
||||
appendOverlay: function () {
|
||||
const extra = this.createElement('div');
|
||||
extra.className = 'modal-overlay byop-overlay';
|
||||
this.body.appendChild(extra);
|
||||
return extra;
|
||||
},
|
||||
getFirstOverlay: () => overlays[0],
|
||||
getOverlayCount: () => overlays.length,
|
||||
getLastOverlay: () => overlays[overlays.length - 1],
|
||||
get activeElement() { return activeElement; },
|
||||
};
|
||||
}
|
||||
|
||||
function createBindingTestDocument() {
|
||||
const listeners = new Map();
|
||||
const removeCounts = new Map();
|
||||
return {
|
||||
addEventListener: (event, handler) => {
|
||||
listeners.set(event, handler);
|
||||
},
|
||||
removeEventListener: (event, handler) => {
|
||||
if (listeners.get(event) === handler) listeners.delete(event);
|
||||
removeCounts.set(event, (removeCounts.get(event) || 0) + 1);
|
||||
},
|
||||
dispatch: (event, payload) => {
|
||||
const handler = listeners.get(event);
|
||||
if (handler) handler(payload || {});
|
||||
},
|
||||
getRemoveCount: (event) => removeCounts.get(event) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== APP.JS: formatEngineBadge =====
|
||||
@@ -2099,6 +1927,368 @@ console.log('\n=== customize.js: initState merge behavior ===');
|
||||
assert.strictEqual(state.theme.navBg, '#fedcba');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CHANNELS.JS: WS Region Filter helper =====
|
||||
console.log('\n=== channels.js: shouldProcessWSMessageForRegion ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000 };
|
||||
ctx.history = { replaceState() {} };
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
const shouldProcess = ctx.window._channelsShouldProcessWSMessageForRegion;
|
||||
|
||||
test('helper is exported', () => assert.ok(typeof shouldProcess === 'function'));
|
||||
|
||||
test('allows all when no region selected', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs1' } } };
|
||||
assert.strictEqual(shouldProcess(msg, null, { obs1: 'SJC' }), true);
|
||||
assert.strictEqual(shouldProcess(msg, [], { obs1: 'SJC' }), true);
|
||||
});
|
||||
|
||||
test('allows message when observer region matches selection', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs1' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC', 'SFO'], { obs1: 'SJC' }), true);
|
||||
});
|
||||
|
||||
test('drops message when observer region is outside selection', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs2' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs2: 'LAX' }), false);
|
||||
});
|
||||
|
||||
test('drops message when observer_id is missing under selected region', () => {
|
||||
const msg = { data: {} };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false);
|
||||
});
|
||||
|
||||
test('falls back to observer_name mapping when observer_id is missing', () => {
|
||||
const msg = { data: { packet: { observer_name: 'Observer Alpha' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'LAX' }, { 'Observer Alpha': 'SJC' }), true);
|
||||
});
|
||||
|
||||
test('drops message when observer region lookup missing', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs9' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== channels.js: WS batch + region snapshot integration ===');
|
||||
{
|
||||
function makeChannelsWsSandbox(regionParam) {
|
||||
const ctx = makeSandbox();
|
||||
const dom = {};
|
||||
function makeEl(id) {
|
||||
if (dom[id]) return dom[id];
|
||||
dom[id] = {
|
||||
id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
clientHeight: 80,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; },
|
||||
setAttribute() {},
|
||||
removeAttribute() {},
|
||||
focus() {},
|
||||
};
|
||||
return dom[id];
|
||||
}
|
||||
|
||||
const headerText = { textContent: '' };
|
||||
makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null);
|
||||
makeEl('chMessages');
|
||||
makeEl('chList');
|
||||
makeEl('chScrollBtn');
|
||||
makeEl('chAriaLive');
|
||||
makeEl('chBackBtn');
|
||||
makeEl('chRegionFilter');
|
||||
|
||||
const appEl = {
|
||||
innerHTML: '',
|
||||
querySelector(sel) {
|
||||
if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel);
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return makeEl(sel);
|
||||
},
|
||||
addEventListener() {},
|
||||
};
|
||||
|
||||
ctx.document.getElementById = makeEl;
|
||||
ctx.document.querySelector = (sel) => {
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return null;
|
||||
};
|
||||
ctx.document.querySelectorAll = () => [];
|
||||
ctx.document.addEventListener = () => {};
|
||||
ctx.document.removeEventListener = () => {};
|
||||
ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} };
|
||||
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
|
||||
ctx.history = { replaceState() {} };
|
||||
ctx.matchMedia = () => ({ matches: false });
|
||||
ctx.window.matchMedia = ctx.matchMedia;
|
||||
ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; };
|
||||
ctx.RegionFilter = {
|
||||
init() {},
|
||||
onChange() { return () => {}; },
|
||||
offChange() {},
|
||||
getRegionParam() { return regionParam || ''; },
|
||||
};
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.api = (path) => {
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
if (path.indexOf('/channels') === 0) return Promise.resolve({ channels: [] });
|
||||
return Promise.resolve({ messages: [] });
|
||||
};
|
||||
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 };
|
||||
ctx.ROLE_EMOJI = {};
|
||||
ctx.ROLE_LABELS = {};
|
||||
ctx.timeAgo = () => '1m ago';
|
||||
ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; };
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
|
||||
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
ctx._pageHandlers.init(appEl);
|
||||
return { ctx, dom };
|
||||
}
|
||||
|
||||
test('WS batch respects region snapshot and observer_name fallback', () => {
|
||||
const env = makeChannelsWsSandbox('SJC');
|
||||
env.ctx.window._channelsSetObserverRegionsForTest({ obs1: 'SJC' }, { 'Observer Beta': 'SJC' });
|
||||
env.ctx.window._channelsSetStateForTest({
|
||||
selectedHash: 'general',
|
||||
channels: [{ hash: 'general', name: 'general', messageCount: 0, lastActivityMs: 0 }],
|
||||
messages: [],
|
||||
});
|
||||
|
||||
env.ctx.window._channelsHandleWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash: 'hash1',
|
||||
decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text: 'Alice: hello world' } },
|
||||
packet: { observer_name: 'Observer Beta' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash: 'hash2',
|
||||
decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text: 'Bob: dropped' } },
|
||||
packet: { observer_name: 'Observer Zeta' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const state = env.ctx.window._channelsGetStateForTest();
|
||||
assert.strictEqual(state.messages.length, 1, 'only matching-region message should be appended');
|
||||
assert.strictEqual(state.messages[0].sender, 'Alice');
|
||||
assert.strictEqual(state.channels[0].messageCount, 1, 'channel count increments only for accepted message');
|
||||
});
|
||||
|
||||
test('stale selectChannel response is discarded after region change', async () => {
|
||||
const ctx = makeSandbox();
|
||||
const dom = {};
|
||||
function makeEl(id) {
|
||||
if (dom[id]) return dom[id];
|
||||
dom[id] = {
|
||||
id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
clientHeight: 80,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; },
|
||||
setAttribute() {},
|
||||
removeAttribute() {},
|
||||
focus() {},
|
||||
};
|
||||
return dom[id];
|
||||
}
|
||||
const headerText = { textContent: '' };
|
||||
makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null);
|
||||
makeEl('chMessages');
|
||||
makeEl('chList');
|
||||
makeEl('chScrollBtn');
|
||||
makeEl('chAriaLive');
|
||||
makeEl('chBackBtn');
|
||||
makeEl('chRegionFilter');
|
||||
const appEl = {
|
||||
innerHTML: '',
|
||||
querySelector(sel) {
|
||||
if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel);
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return makeEl(sel);
|
||||
},
|
||||
addEventListener() {},
|
||||
};
|
||||
let region = 'SJC';
|
||||
let resolver = null;
|
||||
ctx.document.getElementById = makeEl;
|
||||
ctx.document.querySelector = (sel) => {
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return null;
|
||||
};
|
||||
ctx.document.querySelectorAll = () => [];
|
||||
ctx.document.addEventListener = () => {};
|
||||
ctx.document.removeEventListener = () => {};
|
||||
ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} };
|
||||
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
|
||||
ctx.history = { replaceState() {} };
|
||||
ctx.matchMedia = () => ({ matches: false });
|
||||
ctx.window.matchMedia = ctx.matchMedia;
|
||||
ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; };
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return region; } };
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.api = (path) => {
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
if (path.indexOf('/channels?') === 0 || path === '/channels') return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 2, lastActivity: null }] });
|
||||
if (path.indexOf('/channels/general/messages') === 0) {
|
||||
return new Promise((resolve) => { resolver = resolve; });
|
||||
}
|
||||
return Promise.resolve({ messages: [] });
|
||||
};
|
||||
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 };
|
||||
ctx.ROLE_EMOJI = {};
|
||||
ctx.ROLE_LABELS = {};
|
||||
ctx.timeAgo = () => '1m ago';
|
||||
ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; };
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
|
||||
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
ctx._pageHandlers.init(appEl);
|
||||
await Promise.resolve();
|
||||
const selectPromise = ctx.window._channelsSelectChannelForTest('general');
|
||||
region = 'LAX';
|
||||
ctx.window._channelsBeginMessageRequestForTest('other', 'LAX');
|
||||
resolver({ messages: [{ sender: 'Alice', text: 'stale', timestamp: '2025-01-01T00:00:00Z' }] });
|
||||
await selectPromise;
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
assert.strictEqual(state.selectedHash, 'general', 'stale select response must not clear or overwrite selection');
|
||||
assert.strictEqual(state.messages.length, 0, 'stale response must be discarded');
|
||||
});
|
||||
|
||||
test('loadChannels clears selected hash when channel no longer exists in region', async () => {
|
||||
const ctx = makeSandbox();
|
||||
const dom = {};
|
||||
function makeEl(id) {
|
||||
if (dom[id]) return dom[id];
|
||||
dom[id] = {
|
||||
id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
clientHeight: 80,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; },
|
||||
setAttribute() {},
|
||||
removeAttribute() {},
|
||||
focus() {},
|
||||
};
|
||||
return dom[id];
|
||||
}
|
||||
const headerText = { textContent: '' };
|
||||
makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null);
|
||||
makeEl('chMessages');
|
||||
makeEl('chList');
|
||||
makeEl('chScrollBtn');
|
||||
makeEl('chAriaLive');
|
||||
makeEl('chBackBtn');
|
||||
makeEl('chRegionFilter');
|
||||
const appEl = {
|
||||
innerHTML: '',
|
||||
querySelector(sel) {
|
||||
if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel);
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return makeEl(sel);
|
||||
},
|
||||
addEventListener() {},
|
||||
};
|
||||
const historyCalls = [];
|
||||
let channelCall = 0;
|
||||
ctx.document.getElementById = makeEl;
|
||||
ctx.document.querySelector = (sel) => {
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return null;
|
||||
};
|
||||
ctx.document.querySelectorAll = () => [];
|
||||
ctx.document.addEventListener = () => {};
|
||||
ctx.document.removeEventListener = () => {};
|
||||
ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} };
|
||||
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
|
||||
ctx.history = { replaceState(_a, _b, url) { historyCalls.push(url); } };
|
||||
ctx.matchMedia = () => ({ matches: false });
|
||||
ctx.window.matchMedia = ctx.matchMedia;
|
||||
ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; };
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return 'SJC'; } };
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.api = (path) => {
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
if (path.indexOf('/channels') === 0) {
|
||||
channelCall++;
|
||||
if (channelCall === 1) return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 1, lastActivity: null }] });
|
||||
return Promise.resolve({ channels: [{ hash: 'newchan', name: 'newchan', messageCount: 1, lastActivity: null }] });
|
||||
}
|
||||
if (path.indexOf('/channels/general/messages') === 0) return Promise.resolve({ messages: [{ sender: 'Alice', text: 'hi', timestamp: '2025-01-01T00:00:00Z' }] });
|
||||
return Promise.resolve({ messages: [] });
|
||||
};
|
||||
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 };
|
||||
ctx.ROLE_EMOJI = {};
|
||||
ctx.ROLE_LABELS = {};
|
||||
ctx.timeAgo = () => '1m ago';
|
||||
ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; };
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
|
||||
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
ctx._pageHandlers.init(appEl);
|
||||
await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('general');
|
||||
await ctx.window._channelsLoadChannelsForTest(true);
|
||||
ctx.window._channelsReconcileSelectionForTest();
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
assert.strictEqual(state.selectedHash, null, 'selection should clear when channel disappears after region update');
|
||||
assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root');
|
||||
});
|
||||
}
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);
|
||||
|
||||
Reference in New Issue
Block a user