Compare commits

..

9 Commits

Author SHA1 Message Date
Kpa-clawbot
a2c9211dac Fix tilde expansion for .env paths in manage.sh (#318)
## Summary
- fix safe .env parser in manage.sh to expand a leading ~ before export
- ensure setup-time PROD_DATA_DIR read from .env also expands ~
- keep behavior unchanged for non-tilde values

## Why
xport "=" does not perform tilde expansion, so values like
PROD_DATA_DIR=~/meshcore-data stayed literal and broke path-based
operations in manage.sh.

## Validation
- ash -n manage.sh
- manual reasoning: PROD_DATA_DIR=~/meshcore-data now resolves to
$HOME/meshcore-data

## Notes
Docker Compose handling is unchanged (compose already expands ~); this
PR only fixes manage.sh runtime parsing.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 22:05:10 -07:00
Kpa-clawbot
6922d63b1c Add DISABLE_MOSQUITTO support for external brokers (#309)
## Summary
- add `DISABLE_MOSQUITTO` support in container startup by switching
supervisord config when disabled
- add a no-mosquitto supervisord config
(`docker/supervisord-go-no-mosquitto.conf`)
- fix Compose port mapping regression so host ports map to fixed
internal listener ports (`80`, `443`, `1883`)
- add compose variants without MQTT port publishing
(`docker-compose.no-mosquitto.yml`,
`docker-compose.staging.no-mosquitto.yml`)
- update `manage.sh` setup flow to ask `Use built-in MQTT broker?
[Y/n]`, skip MQTT port prompt when disabled, persist
`DISABLE_MOSQUITTO`, and use no-mosquitto compose files when
starting/stopping/restarting
- align `.env.example` staging keys with compose
(`STAGING_GO_HTTP_PORT`, `STAGING_GO_MQTT_PORT`)
- fix staging Caddyfile generation to use `STAGING_GO_HTTP_PORT`
- fix `.env.example` staging default comments to match actual values
(82/1885)

## Validation performed
-  `bash -n manage.sh` passes.
-  With `DISABLE_MOSQUITTO=true`, no-mosquitto compose overrides are
selected, Mosquitto is not started, and MQTT port is not published.
-  With `DISABLE_MOSQUITTO=false`, standard compose files are used,
Mosquitto starts, and MQTT port mapping is present.
- ℹ️ Runtime Docker validation requires a running Docker host.

Fixes #267

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:36:29 -07:00
Kpa-clawbot
f28a3146da Fix channel region crosstalk in frontend (#307)
## Summary
Fixes frontend region crosstalk on Channels page by applying region
filtering to message fetches and live WS GRP_TXT handling.

## Changes
- Append `region` query param to channel message API calls in
`selectChannel` and `refreshMessages`.
- Add WS region guard in `public/channels.js` using observer→IATA map
with selected-region snapshot at handler entry.
- On region switch, reload channels and re-fetch selected channel
messages; if empty under selected region, clear pane and show `Channel
not available in selected region`.
- Bump cache busters in `public/index.html`.
- Add frontend helper tests for extracted WS region filter helper in
`test-frontend-helpers.js`.

## Validation
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`

Refs #280

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 20:25:11 -07:00
Kpa-clawbot
3a1d7263b4 Fix issue #266: normalize .env LF + auto-fix CRLF (#305)
## Summary
- renormalized .env.example to LF in git index (git add --renormalize
.env.example)
- added early CRLF detection and automatic conversion for .env in
manage.sh
- retained existing safe .env parsing with \r stripping

## Validation
- 
ode test-packet-filter.js
- 
ode test-aging.js
- 
ode test-frontend-helpers.js
- ash -n manage.sh (validated via Git Bash)

Fixes #266

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:47:20 -07:00
Kpa-clawbot
92188e8c12 ci: add manual workflow_dispatch trigger (#302)
Adds `workflow_dispatch` trigger to the CI/CD pipeline so it can be
manually triggered from the Actions tab.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:28:46 -07:00
Kpa-clawbot
380da0ee0b docs: add AGENTS.md rule 12 — PR review follow-up comments (#301)
Adds rule 12 to AGENTS.md: when review feedback is addressed, post a
follow-up comment on the PR listing what was fixed with the commit hash.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:23:30 -07:00
Kpa-clawbot
7155b5b017 Fix observer client/radio identity persistence (#298)
## Summary
- fix observer upsert write path in `cmd/ingestor` to persist identity
fields
- map status payload fields into observer metadata: `model`,
`firmware`/`firmware_version`, `client_version`/`clientVersion`, `radio`
- keep NULL-safe behavior when identity fields are missing
- add regression tests for identity persistence and missing-field
handling

## Root cause
The ingestor only wrote telemetry (`battery_mv`, `uptime_secs`,
`noise_floor`) and never included observer identity columns in the
upsert statement, leaving `model`, `firmware`, `client_version`, and
`radio` NULL on fresh DBs.

## Testing
- `cd cmd/ingestor && go test ./...`
- `cd cmd/server && go test ./...`

Fixes #295

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:22:34 -07:00
Kpa-clawbot
30fe629bb4 feat(setup): add port negotiation + managed .env updates (#297)
## Summary
Implement issue #236 by rewriting `manage.sh` setup step 3 into a full
ports negotiation flow with `.env` lifecycle management and preflight
validation.

## What Changed
- Reworked setup step 3 to **Ports & Networking**.
- Added layered port detection (`ss -> lsof -> netstat -> nc`), conflict
reporting, and next-available suggestions.
- Added interactive confirmation/override prompts for HTTP/HTTPS/MQTT
ports.
- Added rerun behavior: when `.env` already has ports, prompt to keep or
re-negotiate.
- Added `.env` managed-key merge/update logic for:
  - `PROD_HTTP_PORT`
  - `PROD_HTTPS_PORT`
  - `PROD_MQTT_PORT`
  - `PROD_DATA_DIR`
- Added `.env` creation from `.env.example` when missing.
- Added atomic `.env` write flow (temp file + move).
- Added preflight port validation before setup step 5 start, and in
`./manage.sh start` (when prod container is not already running).
- Updated `.env.example` comments to clarify managed keys.
- Addressed PR #297 review fixes:
- unified staging container name usage via
`STAGING_CONTAINER="corescope-staging-go"`
  - safe `.env` parsing (removed unsafe `eval`)
- DNS resolution fallback chain: `dig -> host -> nslookup -> getent
hosts`
  - explicit warning when no DNS resolver tool is available
- ensured negotiated `selected_http` is persisted via
`write_env_managed_values` to `PROD_HTTP_PORT`

## How It Works
1. Step 3 loads existing `.env` values (if present) and displays current
managed values.
2. If current ports are set, prompts to keep or re-negotiate.
3. On re-negotiate, checks default ports `80`, `443`, `1883` with
layered detection and suggests alternatives on conflicts.
4. Prompts admin to confirm or override each port.
5. Runs existing Domain/HTTPS/Caddyfile flow unchanged in behavior, but
wired to negotiated HTTP port for HTTP-only mode.
6. Persists managed values to `.env` while preserving all other
keys/comments.
7. Shows final resolved HTTP/HTTPS/MQTT mapping and asks explicit
confirmation before build/start.
8. Before starting containers, validates selected ports are still free
and fails with remediation if not.

## Validation performed
| Scenario | Command / Check | Result |
|---|---|---|
| Required frontend helper tests | `node test-packet-filter.js && node
test-aging.js && node test-frontend-helpers.js` |  Passed (all
assertions green) |
| Script syntax | `bash -n manage.sh` |  Passed |
| Staging container consistency | Verified `logs`/`promote` and
status/restart/stop paths use `STAGING_CONTAINER`
(`corescope-staging-go`) |  Confirmed |
| DNS fallback behavior | Reviewed new `resolve_domain_ipv4` chain (`dig
-> host -> nslookup -> getent`) and no-tool warning path |  Confirmed |
| Port→.env round-trip | Verified step 3 writes `selected_http` via
`write_env_managed_values` to `PROD_HTTP_PORT` |  Confirmed |
| Unsafe `.env` loading removed | Confirmed `eval "$(sed ...)"` replaced
with safe line-by-line key/value export parser |  Confirmed |

Fixes #236

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:22:03 -07:00
Kpa-clawbot
65c95611f9 Fix #249 BYOP dialog stacking / close behavior (#300)
## Summary
Fixes BYOP modal stacking on the Packets page by preventing duplicate
global click handlers and enforcing a single BYOP overlay instance.

## Root cause
Packets page init could register document-level click handlers
repeatedly across SPA navigations. Clicking BYOP then spawned multiple
overlays, and each close action removed only one layer.

## Changes
- `public/packets.js`
- Added `bindDocumentHandler(...)` to de-duplicate document click
handlers.
- Applied it to packets action delegation, filter menu outside-click
close, and column menu close.
  - Added `removeAllByopOverlays()` and call it before opening BYOP.
  - Tagged BYOP overlay with `.byop-overlay` class.
  - Updated close logic to remove all BYOP overlays in one click.
- Scoped BYOP result lookup to the active overlay
(`overlay.querySelector`).
  - Added destroy cleanup for document handlers and stray BYOP overlays.
- `test-frontend-helpers.js`
  - Added regression tests for:
    - BYOP singleton overlay behavior
    - one-click close removing all overlays
    - document click handler de-dup logic
- `public/index.html`
  - Bumped cache busters for JS/CSS assets.

## Validation
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`

All passed locally.

Fixes #249

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:46:51 -07:00
19 changed files with 3630 additions and 2525 deletions

View File

@@ -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

View File

@@ -5,6 +5,7 @@ on:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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) {

View 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:

View 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:

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View 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

2522
manage.sh

File diff suppressed because it is too large Load Diff

View File

@@ -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 ? '&region=' + 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 ? '&region=' + 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 });
})();

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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`);