Compare commits

...
Author SHA1 Message Date
a827fd3b43 fix: gate telemetry on sensor flag, fix 0°C emission, safe migration with PRAGMA check
Agent-Logs-Url: https://github.com/Kpa-clawbot/meshcore-analyzer/sessions/1c2af64b-0e8a-4dd0-ae80-e296f70437e9

Co-authored-by: KpaBap <746025+KpaBap@users.noreply.github.com>
2026-03-28 20:35:50 +00:00
Kpa-clawbotandCopilot 54cbc648e0 feat: decode telemetry from adverts — battery voltage + temperature on nodes
Sensor nodes embed telemetry (battery_mv, temperature_c) in their advert
appdata after the null-terminated name. This commit adds decoding and
storage for both the Go ingestor and Node.js backend.

Changes:
- decoder.go/decoder.js: Parse telemetry bytes from advert appdata
  (battery_mv as uint16 LE millivolts, temperature_c as int16 LE /100)
- db.go/db.js: Add battery_mv INTEGER and temperature_c REAL columns
  to nodes and inactive_nodes tables, with migration for existing DBs
- main.go/server.js: Update node telemetry on advert processing
- server db.go: Include battery_mv/temperature_c in node API responses
- Tests: Decoder telemetry tests (positive, negative temp, no telemetry),
  DB migration test, node telemetry update test, server API shape tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 12:07:42 -07:00
Kpa-clawbotandCopilot aba4270ceb fix: undefined err in packets_v view creation (use vErr)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 12:00:04 -07:00
Kpa-clawbotandCopilot 57b0188158 fix: create packets_v VIEW in Go ingestor schema (#217)
Fresh Go installs failed with 'no such table: packets_v' because the
ingestor created tables but never the VIEW that the Go server queries.

Add DROP VIEW IF EXISTS + CREATE VIEW packets_v to applySchema(), using
the v3 definition (observer_idx → observers.rowid JOIN). The view is
rebuilt on every startup to stay current with any definition changes.

Add tests: verify view exists after OpenStore, and verify it returns
correct observer_id/observer_name via the LEFT JOIN.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 11:28:38 -07:00
Kpa-clawbotandCopilot f374a4a775 fix: enforce consistent types between Go ingestor writes and server reads
Schema:
- observers.noise_floor: INTEGER → REAL (dBm has decimals)
- battery_mv, uptime_secs remain INTEGER (always whole numbers)

Ingestor write side (cmd/ingestor/db.go):
- UpsertObserver now accepts ObserverMeta with battery_mv (int),
  uptime_secs (int64), noise_floor (float64)
- COALESCE preserves existing values when meta is nil
- Added migration: cast integer noise_floor values to REAL

Ingestor MQTT handler (cmd/ingestor/main.go — already updated):
- extractObserverMeta extracts hardware fields from status messages
- battery_mv/uptime_secs cast via math.Round to int on write

Server read side (cmd/server/db.go):
- Observer.BatteryMv: *float64 → *int (matches INTEGER storage)
- Observer.UptimeSecs: *float64 → *int64 (matches INTEGER storage)
- Observer.NoiseFloor: *float64 (unchanged, matches REAL storage)
- GetObservers/GetObserverByID: use sql.NullInt64 intermediaries
  for battery_mv/uptime_secs, sql.NullFloat64 for noise_floor

Proto (proto/observer.proto — already correct):
- battery_mv: int32, uptime_secs: int64, noise_floor: double

Tests:
- TestUpsertObserverWithMeta: verifies correct SQLite types via typeof()
- TestUpsertObserverMetaPreservesExisting: nil-meta preserves values
- TestExtractObserverMeta: float-to-int rounding, empty message
- TestSchemaNoiseFloorIsReal: PRAGMA table_info validation
- TestObserverTypeConsistency: server reads typed values correctly
- TestObserverTypesInGetObservers: list endpoint type consistency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 11:22:14 -07:00
Kpa-clawbotandCopilot 6d31cb2ad6 feat: add pprof profiling controlled by ENABLE_PPROF env var
Add net/http/pprof support to both Go server (default port 6060) and
ingestor (default port 6061). Profiling is off by default — only
starts the pprof HTTP listener when ENABLE_PPROF=true.

PPROF_PORT env var overrides the default port for each binary.

Enable on staging-go in docker-compose with exposed ports 6060/6061.
Not enabled on prod.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 11:18:33 -07:00
Kpa-clawbotandCopilot 1619f4857e fix: noise_floor/battery_mv/uptime_secs scanned as float64 to handle REAL values
SQLite stores these as REAL on some instances. Go *int scan silently
fails, dropping the entire observer row (404 on detail, missing from list).
Reported for YC-Base-Repeater and YC-Work-Repeater.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 11:04:49 -07:00
Kpa-clawbotandGitHub 58d19ec303 Merge pull request #214 from Kpa-clawbot/fix/sqlite-write-concurrency
Reviewed by Kobayashi — LGTM. Fixes SQLite BUSY contention with busy_timeout + single connection serialization.
2026-03-28 10:13:44 -07:00
you 331dc0090e test: add load test with throughput and latency metrics
TestLoadTestThroughput: 1000 messages × 4 writes each = 4000 writes,
20 concurrent goroutines. Reports msgs/sec, p50/p95/p99 latency,
SQLITE_BUSY count, and total errors. Hard-asserts zero BUSY errors.
2026-03-28 16:54:06 +00:00
you cef8156a86 fix: set MaxIdleConns(1) to match MaxOpenConns(1)
Prevents unnecessary connection close/reopen churn from the default
MaxIdleConns(2) when only 1 connection is ever open.
2026-03-28 16:37:56 +00:00
you 9751141ffc feat: add observability metrics and concurrency tests
Observability:
- Add DBStats struct with atomic counters for tx_inserted, tx_dupes,
  obs_inserted, node_upserts, observer_upserts, write_errors
- Log SQLite config on startup (busy_timeout, max_open_conns, journal)
- Periodic stats logging every 5 minutes + final stats on shutdown
- Instrument all write paths with counter increments

Tests:
- TestConcurrentWrites: 20 goroutines × 50 writes (1000 total) with
  interleaved InsertTransmission + UpsertNode + UpsertObserver calls.
  Verifies zero errors and data integrity under concurrent load.
- TestDBStats: verifies counter accuracy for inserts, duplicates,
  upserts, and that LogStats does not panic
2026-03-28 16:36:50 +00:00
you 9c5ffbfb0c fix: resolve SQLite SQLITE_BUSY write contention in ingestor
Three changes to eliminate concurrent write collisions:

1. Add _busy_timeout=5000 to ingestor SQLite DSN (matches server)
   - SQLite will wait up to 5s for the write lock instead of
     immediately returning SQLITE_BUSY

2. Set SetMaxOpenConns(1) on ingestor DB connection pool
   - Serializes all DB access at the Go sql.DB level
   - Prevents multiple goroutines from opening overlapping writes

3. Change SetOrderMatters(false) to SetOrderMatters(true)
   - MQTT handlers now run sequentially per client
   - Eliminates concurrent handler execution that caused
     overlapping multi-statement write flows

Root cause: concurrent MQTT handlers (SetOrderMatters=false) each
performed multiple separate writes (transmission lookup/insert,
observation insert, node upsert, observer upsert) without transactions
or connection limits. SQLite only permits one writer at a time, so
under bursty MQTT traffic the ingestor was competing with itself.
2026-03-28 16:16:07 +00:00
Kpa-clawbotandCopilot 3361643bc0 fix: #208 search results keyboard accessible — tabindex, role, arrow-key nav
- Search result items: tabindex='0', role='option', data-href (replaces inline onclick)
- Delegated click handler via activateSearchItem()
- Keydown handler: Enter/Space activates, ArrowDown/ArrowUp navigates items
- ArrowDown from search input focuses first result
- searchResults container: role='listbox'
- Bump cache busters

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 02:43:09 -07:00
Kpa-clawbotandCopilot f04f1b8e77 fix: accessibility — chart labels, table scope, form labels (#210, #211, #212)
#210: Add role="img" aria-label to 9 Chart.js canvases in node-analytics.js
and observer-detail.js with descriptive labels.

#211: Add scope="col" to all <th> elements across analytics.js, audio-lab.js,
compare.js, node-analytics.js, nodes.js, observer-detail.js, observers.js,
and packets.js (40+ headers).

#212: Add aria-label to packet filter input and time window select in
packets.js. Add for/id associations to all customize.js inputs: branding,
theme colors, node/type colors, heatmap sliders, onboarding fields, and
export controls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 02:42:01 -07:00
Kpa-clawbotandCopilot 447c5d7073 fix: mobile responsive — #203 live bottom-sheet, #204 perf layout, #205 nodes col-hide
#203: Live page node detail panel becomes a bottom-sheet on mobile
      (width:100%, bottom:0, max-height:60vh, rounded top corners).
#204: Perf page reduces padding to 12px, perf-cards stack in 2-col
      grid, tables get smaller font/padding on mobile.
#205: Nodes table hides Public Key column on mobile via .col-pubkey
      class (same pattern as packets page .col-region/.col-rpt).

Cache busters bumped in index.html.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 02:38:23 -07:00
Kpa-clawbotandCopilot aa2e8ed420 ci: remove Node deploy steps, update badges for Go
- Remove build-node and deploy-node jobs (Node staging on port 81)
- Rename build-go → build and deploy-go → deploy
- Update publish job to depend only on deploy (not deploy-node)
- Update README badges to show Go coverage (server/ingestor) instead of Node backend
- Remove Node staging references from deployment summary
- node-test job remains (frontend tests + Playwright)

Pipeline is now: node-test + go-test → build → deploy → publish

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 01:59:31 -07:00
31 changed files with 2508 additions and 1359 deletions
+22 -298
View File
@@ -16,14 +16,14 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Pipeline (TWO INDEPENDENT TRACKS):
# Track 1 (Node): node-test → build-node → deploy-node ──┐
# Track 2 (Go): go-test → build-go → deploy-go ──┼──→ publish
# └─ (both wait)
# Pipeline:
# node-test (frontend tests) ──┐
# go-test ├──→ build → deploy → publish
# └─ (both wait)
#
# Proto validation flow:
# 1. go-test job: verify .proto files compile (syntax check)
# 2. deploy-node job: capture fresh fixtures from prod, validate protos match actual API responses
# 2. deploy job: capture fresh fixtures from prod, validate protos match actual API responses
jobs:
# ───────────────────────────────────────────────────────────────
@@ -266,35 +266,10 @@ jobs:
if-no-files-found: ignore
# ───────────────────────────────────────────────────────────────
# 3. Build Node Docker Image — Track 1
# 3. Build Docker Image
# ───────────────────────────────────────────────────────────────
build-node:
name: "🏗️ Build Node Docker Image"
needs: [node-test]
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Validate JavaScript syntax
run: sh scripts/validate.sh
- name: Build Node.js Docker image
run: |
echo "${GITHUB_SHA::7}" > .git-commit
docker build -t meshcore-analyzer:latest .
echo "Built meshcore-analyzer:latest ($(git rev-parse --short HEAD))"
# ───────────────────────────────────────────────────────────────
# 4. Build Go Docker Image — Track 2
# ───────────────────────────────────────────────────────────────
build-go:
name: "🏗️ Build Go Docker Image"
build:
name: "🏗️ Build Docker Image"
needs: [go-test]
runs-on: self-hosted
steps:
@@ -315,267 +290,17 @@ jobs:
echo "Built Go staging image"
# ───────────────────────────────────────────────────────────────
# 5. Deploy Node Staging — start on port 81, healthcheck, smoke test
# 4. Deploy Staging — start on port 82, healthcheck, smoke test
# ───────────────────────────────────────────────────────────────
deploy-node:
name: "🚀 Deploy Node Staging"
needs: [build-node]
deploy:
name: "🚀 Deploy Staging"
needs: [build]
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Prepare staging environment
run: |
set -e
# Source environment overrides from deploy dir or home
if [ -f /opt/meshcore-deploy/.env ]; then
set -a; source /opt/meshcore-deploy/.env; set +a
echo "Sourced /opt/meshcore-deploy/.env"
elif [ -f "$HOME/.env" ]; then
set -a; source "$HOME/.env"; set +a
echo "Sourced $HOME/.env"
else
echo "No .env found, using defaults"
fi
# Ensure data directories exist
STAGING_DIR="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
mkdir -p "${PROD_DATA_DIR:-$HOME/meshcore-data}" "$STAGING_DIR"
# Ensure staging has a Caddyfile (generate from template if missing)
if [ ! -f "$STAGING_DIR/Caddyfile" ]; then
cp docker/Caddyfile.staging "$STAGING_DIR/Caddyfile"
echo "Generated staging Caddyfile"
fi
# Ensure staging has a config.json (copy from prod if missing)
if [ ! -f "$STAGING_DIR/config.json" ]; then
PROD_DIR="${PROD_DATA_DIR:-$HOME/meshcore-data}"
if [ -f "$PROD_DIR/config.json" ]; then
cp "$PROD_DIR/config.json" "$STAGING_DIR/config.json"
elif [ -f /opt/meshcore-deploy/config.json ]; then
cp /opt/meshcore-deploy/config.json "$STAGING_DIR/config.json"
else
echo '{}' > "$STAGING_DIR/config.json"
echo "WARNING: No config.json found, created empty one"
fi
fi
# Copy compose file to deploy dir so manage.sh can use it
mkdir -p /opt/meshcore-deploy
cp docker-compose.yml /opt/meshcore-deploy/docker-compose.yml
- name: Start Node staging on port 81
run: |
# Force remove stale containers and volumes
docker rm -f meshcore-staging 2>/dev/null || true
docker volume prune -f 2>/dev/null || true
# Clean up stale ports
fuser -k 81/tcp 2>/dev/null || true
docker compose --profile staging up -d staging
- name: Healthcheck Node staging container
run: |
for i in $(seq 1 300); do
HEALTH=$(docker inspect meshcore-staging --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" = "healthy" ]; then
echo "Node staging healthy after ${i}s"
break
fi
if [ "$i" -eq 300 ]; then
echo "Node staging failed health check after 300s"
docker logs meshcore-staging --tail 50
exit 1
fi
sleep 1
done
- name: Smoke test Node staging API
run: |
curl -f http://localhost:81/api/stats || exit 1
curl -f http://localhost:81/api/nodes || exit 1
echo "Node staging smoke tests passed ✅"
- name: 🔍 Validate API contract (protos vs prod fixtures)
run: |
set -e
echo "Refreshing Node fixtures from staging container..."
mkdir -p proto/testdata/node-fixtures
# ─── Simple endpoints (no parameters) ──────────────────────────
ENDPOINTS=(
"stats" "health" "perf" "nodes" "packets" "observers" "channels"
"analytics/rf" "analytics/topology" "analytics/channels"
"analytics/hash-sizes" "analytics/distance" "analytics/subpaths"
"config/theme" "config/regions" "config/client" "config/cache" "config/map"
"iata-coords"
)
for endpoint in "${ENDPOINTS[@]}"; do
fixture_name=$(echo "$endpoint" | tr '/' '-')
echo " Fetching $endpoint → ${fixture_name}.json"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/$endpoint" \
> "proto/testdata/node-fixtures/${fixture_name}.json" 2>/dev/null || {
echo " ⚠ Failed to fetch $endpoint (container may not have data yet)"
}
done
# ─── Dynamic ID endpoints (require real data) ─────────────────
echo ""
echo "Fetching endpoints that require dynamic IDs..."
# Get a real pubkey from nodes
PUBKEY=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes?limit=1" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['nodes'][0]['public_key'] if d.get('nodes') and len(d['nodes']) > 0 else '')" 2>/dev/null || echo "")
if [ -n "$PUBKEY" ]; then
echo " Using pubkey: ${PUBKEY:0:16}..."
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY" \
> "proto/testdata/node-fixtures/node-detail.json" 2>/dev/null && \
echo " ✓ node-detail.json"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY/health" \
> "proto/testdata/node-fixtures/node-health.json" 2>/dev/null && \
echo " ✓ node-health.json"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY/paths" \
> "proto/testdata/node-fixtures/node-paths.json" 2>/dev/null && \
echo " ✓ node-paths.json"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY/analytics" \
> "proto/testdata/node-fixtures/node-analytics.json" 2>/dev/null && \
echo " ✓ node-analytics.json"
else
echo " ⚠ No nodes available — skipping node detail endpoints"
fi
# Node search
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/search?q=repeater" \
> "proto/testdata/node-fixtures/node-search.json" 2>/dev/null && \
echo " ✓ node-search.json" || echo " ⚠ node-search failed"
# Bulk health
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/bulk-health" \
> "proto/testdata/node-fixtures/bulk-health.json" 2>/dev/null && \
echo " ✓ bulk-health.json" || echo " ⚠ bulk-health failed"
# Get a real hash from packets
HASH=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?limit=1" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['packets'][0]['hash'] if d.get('packets') and len(d['packets']) > 0 else '')" 2>/dev/null || echo "")
if [ -n "$HASH" ]; then
echo " Using hash: $HASH"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$HASH" \
> "proto/testdata/node-fixtures/packet-detail.json" 2>/dev/null && \
echo " ✓ packet-detail.json"
else
echo " ⚠ No packets available — skipping packet-detail"
fi
# ─── Per-type packet fixtures (one of each payload type) ──────
echo ""
echo "Fetching per-type packet fixtures..."
# payload_type: 0=REQ, 1=TXT_MSG, 4=ADVERT, 5=GRP_TXT
TYPES="0:req 1:txtmsg 4:advert 5:grptxt"
for entry in $TYPES; do
TYPE_NUM="${entry%%:*}"
TYPE_NAME="${entry##*:}"
TYPE_HASH=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?type=${TYPE_NUM}&limit=1" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); pkts=d.get('packets',[]); print(pkts[0]['hash'] if pkts else '')" 2>/dev/null || echo "")
if [ -n "$TYPE_HASH" ]; then
if [ "$TYPE_NAME" = "grptxt" ]; then
# Save first as decrypted (most common), then find an undecrypted one
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$TYPE_HASH" \
> "proto/testdata/node-fixtures/packet-type-grptxt-decrypted.json" 2>/dev/null && \
echo " ✓ packet-type-grptxt-decrypted.json (hash: $TYPE_HASH)"
# Find a decryption_failed packet
UNDEC_HASH=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?type=5&limit=20" 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin);[print(p['hash']) or exit() for p in d.get('packets',[]) if 'decryption_failed' in p.get('decoded_json','') or 'no_key' in p.get('decoded_json','')]" 2>/dev/null || echo "")
if [ -n "$UNDEC_HASH" ]; then
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$UNDEC_HASH" \
> "proto/testdata/node-fixtures/packet-type-grptxt-undecrypted.json" 2>/dev/null && \
echo " ✓ packet-type-grptxt-undecrypted.json (hash: $UNDEC_HASH)"
else
echo " ⚠ No undecrypted GRP_TXT found"
fi
else
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$TYPE_HASH" \
> "proto/testdata/node-fixtures/packet-type-${TYPE_NAME}.json" 2>/dev/null && \
echo " ✓ packet-type-${TYPE_NAME}.json (hash: $TYPE_HASH)"
fi
else
echo " ⚠ No type=$TYPE_NUM ($TYPE_NAME) packets available"
fi
done
# Packet timestamps
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/timestamps?since=2026-03-01T00:00:00Z" \
> "proto/testdata/node-fixtures/packet-timestamps.json" 2>/dev/null && \
echo " ✓ packet-timestamps.json" || echo " ⚠ packet-timestamps failed"
# Packets grouped and since
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?limit=5&groupByHash=true" \
> "proto/testdata/node-fixtures/packets-grouped.json" 2>/dev/null && \
echo " ✓ packets-grouped.json" || echo " ⚠ packets-grouped failed"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?limit=5&since=2026-03-01T00:00:00Z&groupByHash=true" \
> "proto/testdata/node-fixtures/packets-since.json" 2>/dev/null && \
echo " ✓ packets-since.json" || echo " ⚠ packets-since failed"
# Get a real observer ID
OBSID=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/observers" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['id'] if d and len(d) > 0 else '')" 2>/dev/null || echo "")
if [ -n "$OBSID" ]; then
echo " Using observer ID: $OBSID"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/observers/$OBSID" \
> "proto/testdata/node-fixtures/observer-detail.json" 2>/dev/null && \
echo " ✓ observer-detail.json"
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/observers/$OBSID/analytics" \
> "proto/testdata/node-fixtures/observer-analytics.json" 2>/dev/null && \
echo " ✓ observer-analytics.json"
else
echo " ⚠ No observers available — skipping observer detail endpoints"
fi
# Channel messages
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/channels/public/messages?limit=5" \
> "proto/testdata/node-fixtures/channel-messages.json" 2>/dev/null && \
echo " ✓ channel-messages.json" || echo " ⚠ channel-messages failed"
# WebSocket message capture (capture one message if available)
# Non-blocking: if no live packets, skip with warning
echo " Capturing WebSocket message..."
if docker exec meshcore-prod timeout 5 node -e "
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:3000');
ws.on('message', (data) => {
console.log(data);
ws.close();
process.exit(0);
});
ws.on('error', () => { process.exit(1); });
" > "proto/testdata/node-fixtures/websocket-message.json" 2>/dev/null; then
echo " ✓ websocket-message.json"
else
echo " ⚠ websocket-message failed (no live packets) — skipping"
fi
echo ""
echo "Running proto validator..."
python3 tools/validate-protos.py || {
echo "❌ Proto validation failed — API contract drift detected"
echo "This means a Node.js API response doesn't match the proto definition."
echo "Fix by updating the .proto files in proto/ to match the actual API responses."
exit 1
}
echo "✅ Proto validation passed — API contract is consistent"
# ───────────────────────────────────────────────────────────────
# 6. Deploy Go Staging — start on port 82, healthcheck, smoke test
# ───────────────────────────────────────────────────────────────
deploy-go:
name: "🚀 Deploy Go Staging"
needs: [build-go]
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start Go staging on port 82
- name: Start staging on port 82
run: |
# Force remove stale containers
docker rm -f meshcore-staging-go 2>/dev/null || true
@@ -583,37 +308,37 @@ jobs:
fuser -k 82/tcp 2>/dev/null || true
docker compose --profile staging-go up -d staging-go
- name: Healthcheck Go staging container
- name: Healthcheck staging container
run: |
for i in $(seq 1 120); do
HEALTH=$(docker inspect meshcore-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" = "healthy" ]; then
echo "Go staging healthy after ${i}s"
echo "Staging healthy after ${i}s"
break
fi
if [ "$i" -eq 120 ]; then
echo "Go staging failed health check after 120s"
echo "Staging failed health check after 120s"
docker logs meshcore-staging-go --tail 50
exit 1
fi
sleep 1
done
- name: Smoke test Go staging API
- name: Smoke test staging API
run: |
if curl -sf http://localhost:82/api/stats | grep -q engine; then
echo "Go staging verified — engine field present ✅"
echo "Staging verified — engine field present ✅"
else
echo "Go staging /api/stats did not return engine field"
echo "Staging /api/stats did not return engine field"
exit 1
fi
# ───────────────────────────────────────────────────────────────
# 7. Publish Badges & Summary — waits for both tracks to complete
# 5. Publish Badges & Summary
# ───────────────────────────────────────────────────────────────
publish:
name: "📝 Publish Badges & Summary"
needs: [deploy-node, deploy-go]
needs: [deploy]
runs-on: self-hosted
steps:
- name: Checkout code
@@ -648,8 +373,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Node Staging:** http://<VM_HOST>:81" >> $GITHUB_STEP_SUMMARY
echo "**Go Staging:** http://<VM_HOST>:82" >> $GITHUB_STEP_SUMMARY
echo "**Staging:** http://<VM_HOST>:82" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To promote to production:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
+2 -2
View File
@@ -1,7 +1,7 @@
# MeshCore Analyzer
[![Backend Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/backend-tests.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Backend Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/backend-coverage.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Go Server Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/go-server-coverage.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Go Ingestor Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/go-ingestor-coverage.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Frontend Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/frontend-tests.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Frontend Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/.badges/frontend-coverage.json)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
[![Deploy](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml/badge.svg)](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
+614 -422
View File
File diff suppressed because it is too large Load Diff
+599 -2
View File
@@ -1,10 +1,15 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
)
func tempDBPath(t *testing.T) string {
@@ -58,6 +63,16 @@ func TestOpenStore(t *testing.T) {
t.Errorf("missing table %s, got %v", e, tables)
}
}
// Verify packets_v view exists
var viewCount int
err = s.db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='view' AND name='packets_v'").Scan(&viewCount)
if err != nil {
t.Fatal(err)
}
if viewCount != 1 {
t.Error("packets_v view not created")
}
}
func TestInsertTransmission(t *testing.T) {
@@ -110,6 +125,54 @@ func TestInsertTransmission(t *testing.T) {
}
}
func TestPacketsViewQueryable(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Insert observer so the LEFT JOIN resolves
if err := s.UpsertObserver("obs1", "TestObserver", "SJC", nil); err != nil {
t.Fatal(err)
}
snr := 3.5
rssi := -95.0
data := &PacketData{
RawHex: "AABB",
Timestamp: "2026-01-01T00:00:00Z",
ObserverID: "obs1",
Hash: "viewtesthash",
RouteType: 1,
PayloadType: 4,
PathJSON: "[]",
DecodedJSON: `{"type":"ADVERT"}`,
SNR: &snr,
RSSI: &rssi,
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
// Query through packets_v — the view the Go server relies on
var obsID, obsName sql.NullString
var hash string
err = s.db.QueryRow("SELECT observer_id, observer_name, hash FROM packets_v LIMIT 1").Scan(&obsID, &obsName, &hash)
if err != nil {
t.Fatalf("packets_v query failed: %v", err)
}
if hash != "viewtesthash" {
t.Errorf("hash=%s, want viewtesthash", hash)
}
if !obsID.Valid || obsID.String != "obs1" {
t.Errorf("observer_id=%v, want obs1", obsID)
}
if !obsName.Valid || obsName.String != "TestObserver" {
t.Errorf("observer_name=%v, want TestObserver", obsName)
}
}
func TestUpsertNode(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
@@ -156,7 +219,7 @@ func TestUpsertObserver(t *testing.T) {
}
defer s.Close()
if err := s.UpsertObserver("obs1", "Observer1", "SJC"); err != nil {
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
@@ -170,6 +233,165 @@ func TestUpsertObserver(t *testing.T) {
}
}
func TestUpsertObserverWithMeta(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
battery := 3500
uptime := int64(86400)
noise := -115.5
meta := &ObserverMeta{
BatteryMv: &battery,
UptimeSecs: &uptime,
NoiseFloor: &noise,
}
if err := s.UpsertObserver("obs1", "Observer1", "SJC", meta); err != nil {
t.Fatal(err)
}
// Verify correct types in DB
var batteryMv int
var uptimeSecs int64
var noiseFloor float64
err = s.db.QueryRow("SELECT battery_mv, uptime_secs, noise_floor FROM observers WHERE id = 'obs1'").
Scan(&batteryMv, &uptimeSecs, &noiseFloor)
if err != nil {
t.Fatal(err)
}
if batteryMv != 3500 {
t.Errorf("battery_mv=%d, want 3500", batteryMv)
}
if uptimeSecs != 86400 {
t.Errorf("uptime_secs=%d, want 86400", uptimeSecs)
}
if noiseFloor != -115.5 {
t.Errorf("noise_floor=%f, want -115.5", noiseFloor)
}
// Verify typeof returns correct SQLite types
var typBattery, typUptime, typNoise string
s.db.QueryRow("SELECT typeof(battery_mv), typeof(uptime_secs), typeof(noise_floor) FROM observers WHERE id = 'obs1'").
Scan(&typBattery, &typUptime, &typNoise)
if typBattery != "integer" {
t.Errorf("typeof(battery_mv)=%s, want integer", typBattery)
}
if typUptime != "integer" {
t.Errorf("typeof(uptime_secs)=%s, want integer", typUptime)
}
if typNoise != "real" {
t.Errorf("typeof(noise_floor)=%s, want real", typNoise)
}
}
func TestUpsertObserverMetaPreservesExisting(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// First upsert with metadata
battery := 3500
noise := -115.5
meta := &ObserverMeta{
BatteryMv: &battery,
NoiseFloor: &noise,
}
if err := s.UpsertObserver("obs1", "Observer1", "SJC", meta); err != nil {
t.Fatal(err)
}
// Second upsert without metadata — should preserve existing values
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
var batteryMv int
var noiseFloor float64
s.db.QueryRow("SELECT battery_mv, noise_floor FROM observers WHERE id = 'obs1'").
Scan(&batteryMv, &noiseFloor)
if batteryMv != 3500 {
t.Errorf("battery_mv=%d after nil-meta upsert, want 3500 (preserved)", batteryMv)
}
if noiseFloor != -115.5 {
t.Errorf("noise_floor=%f after nil-meta upsert, want -115.5 (preserved)", noiseFloor)
}
}
func TestExtractObserverMeta(t *testing.T) {
// Float values from JSON (typical MQTT payload)
msg := map[string]interface{}{
"battery_mv": 3500.0,
"uptime_secs": 86400.0,
"noise_floor": -115.5,
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta")
}
if meta.BatteryMv == nil || *meta.BatteryMv != 3500 {
t.Errorf("BatteryMv=%v, want 3500", meta.BatteryMv)
}
if meta.UptimeSecs == nil || *meta.UptimeSecs != 86400 {
t.Errorf("UptimeSecs=%v, want 86400", meta.UptimeSecs)
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -115.5 {
t.Errorf("NoiseFloor=%v, want -115.5", meta.NoiseFloor)
}
// Battery with fractional part should round
msg2 := map[string]interface{}{
"battery_mv": 3500.7,
}
meta2 := extractObserverMeta(msg2)
if meta2 == nil || meta2.BatteryMv == nil || *meta2.BatteryMv != 3501 {
t.Errorf("battery_mv rounding: got %v, want 3501", meta2)
}
// Empty message → nil
meta3 := extractObserverMeta(map[string]interface{}{})
if meta3 != nil {
t.Errorf("expected nil for empty message, got %v", meta3)
}
}
func TestSchemaNoiseFloorIsReal(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Check column type affinity via PRAGMA
rows, err := s.db.Query("PRAGMA table_info(observers)")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid int
var colName, colType string
var notNull, pk int
var dflt interface{}
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil {
if colName == "noise_floor" && colType != "REAL" {
t.Errorf("noise_floor column type=%s, want REAL", colType)
}
if colName == "battery_mv" && colType != "INTEGER" {
t.Errorf("battery_mv column type=%s, want INTEGER", colType)
}
if colName == "uptime_secs" && colType != "INTEGER" {
t.Errorf("uptime_secs column type=%s, want INTEGER", colType)
}
}
}
}
func TestInsertTransmissionWithObserver(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
@@ -178,7 +400,7 @@ func TestInsertTransmissionWithObserver(t *testing.T) {
defer s.Close()
// Insert observer first
if err := s.UpsertObserver("obs1", "Observer1", "SJC"); err != nil {
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
@@ -626,3 +848,378 @@ func TestSchemaCompatibility(t *testing.T) {
}
}
}
func TestConcurrentWrites(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Pre-create an observer for observer_idx resolution
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
const goroutines = 20
const writesPerGoroutine = 50
errCh := make(chan error, goroutines*writesPerGoroutine)
done := make(chan struct{})
for g := 0; g < goroutines; g++ {
go func(gIdx int) {
defer func() { done <- struct{}{} }()
for i := 0; i < writesPerGoroutine; i++ {
hash := fmt.Sprintf("concurrent_%d_%d_____", gIdx, i) // pad to 16+ chars
snr := 5.0
rssi := -100.0
data := &PacketData{
RawHex: "0A00D69F",
Timestamp: time.Now().UTC().Format(time.RFC3339),
ObserverID: "obs1",
Hash: hash[:16],
RouteType: 2,
PayloadType: 4, // ADVERT
PathJSON: "[]",
DecodedJSON: `{"type":"ADVERT"}`,
SNR: &snr,
RSSI: &rssi,
}
if _, err := s.InsertTransmission(data); err != nil {
errCh <- fmt.Errorf("goroutine %d write %d: %w", gIdx, i, err)
return
}
// Also do node + observer upserts to simulate full pipeline
lat := 37.0
lon := -122.0
pubKey := fmt.Sprintf("node_%d_%d________", gIdx, i)
if err := s.UpsertNode(pubKey[:16], "Node", "repeater", &lat, &lon, data.Timestamp); err != nil {
errCh <- fmt.Errorf("goroutine %d node upsert %d: %w", gIdx, i, err)
return
}
obsID := fmt.Sprintf("obs_%d_%d__________", gIdx, i)
if err := s.UpsertObserver(obsID[:16], "Obs", "SJC", nil); err != nil {
errCh <- fmt.Errorf("goroutine %d observer upsert %d: %w", gIdx, i, err)
return
}
}
}(g)
}
// Wait for all goroutines
for g := 0; g < goroutines; g++ {
<-done
}
close(errCh)
var errors []error
for err := range errCh {
errors = append(errors, err)
}
if len(errors) > 0 {
t.Errorf("got %d errors from %d concurrent writers (first: %v)", len(errors), goroutines, errors[0])
}
// Verify data integrity
var txCount, obsCount, nodeCount, observerCount int
s.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount)
s.db.QueryRow("SELECT COUNT(*) FROM observations").Scan(&obsCount)
s.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount)
s.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&observerCount)
expectedTx := goroutines * writesPerGoroutine
if txCount != expectedTx {
t.Errorf("transmissions count=%d, want %d", txCount, expectedTx)
}
if obsCount != expectedTx {
t.Errorf("observations count=%d, want %d", obsCount, expectedTx)
}
t.Logf("Concurrent write test: %d goroutines × %d writes = %d total, 0 errors",
goroutines, writesPerGoroutine, goroutines*writesPerGoroutine)
t.Logf("Stats: tx_inserted=%d tx_dupes=%d obs_inserted=%d write_errors=%d",
s.Stats.TransmissionsInserted.Load(),
s.Stats.DuplicateTransmissions.Load(),
s.Stats.ObservationsInserted.Load(),
s.Stats.WriteErrors.Load(),
)
}
func TestDBStats(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Initial stats should be zero
if s.Stats.TransmissionsInserted.Load() != 0 {
t.Error("initial TransmissionsInserted should be 0")
}
if s.Stats.WriteErrors.Load() != 0 {
t.Error("initial WriteErrors should be 0")
}
// Insert a transmission
data := &PacketData{
RawHex: "0A00D69F",
Timestamp: "2026-03-28T00:00:00Z",
Hash: "stats_test_12345",
RouteType: 2,
PathJSON: "[]",
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
if s.Stats.TransmissionsInserted.Load() != 1 {
t.Errorf("TransmissionsInserted=%d, want 1", s.Stats.TransmissionsInserted.Load())
}
if s.Stats.ObservationsInserted.Load() != 1 {
t.Errorf("ObservationsInserted=%d, want 1", s.Stats.ObservationsInserted.Load())
}
// Insert duplicate
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
if s.Stats.DuplicateTransmissions.Load() != 1 {
t.Errorf("DuplicateTransmissions=%d, want 1", s.Stats.DuplicateTransmissions.Load())
}
// Node upsert
lat := 37.0
lon := -122.0
if err := s.UpsertNode("pk1", "Node1", "repeater", &lat, &lon, "2026-03-28T00:00:00Z"); err != nil {
t.Fatal(err)
}
if s.Stats.NodeUpserts.Load() != 1 {
t.Errorf("NodeUpserts=%d, want 1", s.Stats.NodeUpserts.Load())
}
// Observer upsert
if err := s.UpsertObserver("obs1", "Obs1", "SJC", nil); err != nil {
t.Fatal(err)
}
if s.Stats.ObserverUpserts.Load() != 1 {
t.Errorf("ObserverUpserts=%d, want 1", s.Stats.ObserverUpserts.Load())
}
// LogStats should not panic
s.LogStats()
}
func TestLoadTestThroughput(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Pre-create observer
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
const totalMessages = 1000
const goroutines = 20
perGoroutine := totalMessages / goroutines
// Simulate full pipeline: InsertTransmission + UpsertNode + UpsertObserver + IncrementAdvertCount
// This matches the real handleMessage write pattern for ADVERT packets
latencies := make([]time.Duration, totalMessages)
var busyErrors atomic.Int64
var totalErrors atomic.Int64
errCh := make(chan error, totalMessages)
done := make(chan struct{})
start := time.Now()
for g := 0; g < goroutines; g++ {
go func(gIdx int) {
defer func() { done <- struct{}{} }()
for i := 0; i < perGoroutine; i++ {
msgStart := time.Now()
idx := gIdx*perGoroutine + i
hash := fmt.Sprintf("load_%04d_%04d____", gIdx, i)
snr := 5.0
rssi := -100.0
data := &PacketData{
RawHex: "0A00D69F",
Timestamp: time.Now().UTC().Format(time.RFC3339),
ObserverID: "obs1",
Hash: hash[:16],
RouteType: 2,
PayloadType: 4,
PathJSON: "[]",
DecodedJSON: `{"type":"ADVERT","pubKey":"` + hash[:16] + `"}`,
SNR: &snr,
RSSI: &rssi,
}
_, err := s.InsertTransmission(data)
if err != nil {
totalErrors.Add(1)
if strings.Contains(err.Error(), "database is locked") || strings.Contains(err.Error(), "SQLITE_BUSY") {
busyErrors.Add(1)
}
errCh <- err
continue
}
lat := 37.0 + float64(gIdx)*0.001
lon := -122.0 + float64(i)*0.001
pubKey := fmt.Sprintf("node_%04d_%04d____", gIdx, i)
if err := s.UpsertNode(pubKey[:16], "Node", "repeater", &lat, &lon, data.Timestamp); err != nil {
totalErrors.Add(1)
if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "BUSY") {
busyErrors.Add(1)
}
}
if err := s.IncrementAdvertCount(pubKey[:16]); err != nil {
totalErrors.Add(1)
}
obsID := fmt.Sprintf("obs_%04d_%04d_____", gIdx, i)
if err := s.UpsertObserver(obsID[:16], "Obs", "SJC", nil); err != nil {
totalErrors.Add(1)
if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "BUSY") {
busyErrors.Add(1)
}
}
latencies[idx] = time.Since(msgStart)
}
}(g)
}
for g := 0; g < goroutines; g++ {
<-done
}
close(errCh)
elapsed := time.Since(start)
// Calculate p50, p95, p99
validLatencies := make([]time.Duration, 0, totalMessages)
for _, l := range latencies {
if l > 0 {
validLatencies = append(validLatencies, l)
}
}
sort.Slice(validLatencies, func(i, j int) bool { return validLatencies[i] < validLatencies[j] })
p50 := validLatencies[len(validLatencies)*50/100]
p95 := validLatencies[len(validLatencies)*95/100]
p99 := validLatencies[len(validLatencies)*99/100]
msgsPerSec := float64(totalMessages) / elapsed.Seconds()
t.Logf("=== LOAD TEST RESULTS ===")
t.Logf("Messages: %d (%d goroutines × %d each)", totalMessages, goroutines, perGoroutine)
t.Logf("Writes/msg: 4 (InsertTx + UpsertNode + IncrAdvertCount + UpsertObserver)")
t.Logf("Total writes: %d", totalMessages*4)
t.Logf("Duration: %s", elapsed.Round(time.Millisecond))
t.Logf("Throughput: %.1f msgs/sec (%.1f writes/sec)", msgsPerSec, msgsPerSec*4)
t.Logf("Latency p50: %s", p50.Round(time.Microsecond))
t.Logf("Latency p95: %s", p95.Round(time.Microsecond))
t.Logf("Latency p99: %s", p99.Round(time.Microsecond))
t.Logf("SQLITE_BUSY: %d", busyErrors.Load())
t.Logf("Total errors: %d", totalErrors.Load())
t.Logf("Stats: tx=%d dupes=%d obs=%d nodes=%d observers=%d write_err=%d",
s.Stats.TransmissionsInserted.Load(),
s.Stats.DuplicateTransmissions.Load(),
s.Stats.ObservationsInserted.Load(),
s.Stats.NodeUpserts.Load(),
s.Stats.ObserverUpserts.Load(),
s.Stats.WriteErrors.Load(),
)
// Hard assertions
if busyErrors.Load() > 0 {
t.Errorf("SQLITE_BUSY errors: %d (expected 0)", busyErrors.Load())
}
if totalErrors.Load() > 0 {
t.Errorf("Total errors: %d (expected 0)", totalErrors.Load())
}
var txCount int
s.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount)
if txCount != totalMessages {
t.Errorf("transmissions=%d, want %d", txCount, totalMessages)
}
}
func TestUpdateNodeTelemetry(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
lat := 37.0
lon := -122.0
if err := s.UpsertNode("telem1", "TelemetryNode", "sensor", &lat, &lon, "2026-03-25T00:00:00Z"); err != nil {
t.Fatal(err)
}
battery := 3700
temp := 28.5
if err := s.UpdateNodeTelemetry("telem1", &battery, &temp); err != nil {
t.Fatal(err)
}
var bv int
var tc float64
err = s.db.QueryRow("SELECT battery_mv, temperature_c FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc)
if err != nil {
t.Fatal(err)
}
if bv != 3700 {
t.Errorf("battery_mv=%d, want 3700", bv)
}
if tc != 28.5 {
t.Errorf("temperature_c=%f, want 28.5", tc)
}
newTemp := -5.0
if err := s.UpdateNodeTelemetry("telem1", nil, &newTemp); err != nil {
t.Fatal(err)
}
err = s.db.QueryRow("SELECT battery_mv, temperature_c FROM nodes WHERE public_key = 'telem1'").Scan(&bv, &tc)
if err != nil {
t.Fatal(err)
}
if bv != 3700 {
t.Errorf("battery_mv after nil update=%d, want 3700 (preserved)", bv)
}
if tc != -5.0 {
t.Errorf("temperature_c after update=%f, want -5.0", tc)
}
}
func TestTelemetryMigrationAddsColumns(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
_, err = s.db.Exec("SELECT battery_mv, temperature_c FROM nodes LIMIT 1")
if err != nil {
t.Errorf("nodes table should have battery_mv and temperature_c columns: %v", err)
}
_, err = s.db.Exec("SELECT battery_mv, temperature_c FROM inactive_nodes LIMIT 1")
if err != nil {
t.Errorf("inactive_nodes table should have battery_mv and temperature_c columns: %v", err)
}
var count int
s.db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = 'node_telemetry_v1'").Scan(&count)
if count != 1 {
t.Errorf("migration node_telemetry_v1 should be recorded, count=%d", count)
}
}
+31 -2
View File
@@ -111,6 +111,8 @@ type Payload struct {
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"`
BatteryMv *int `json:"battery_mv,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
ChannelHash int `json:"channelHash,omitempty"`
ChannelHashHex string `json:"channelHashHex,omitempty"`
DecryptionStatus string `json:"decryptionStatus,omitempty"`
@@ -251,10 +253,37 @@ func decodeAdvert(buf []byte) Payload {
off += 8
}
if p.Flags.HasName {
name := string(appdata[off:])
name = strings.TrimRight(name, "\x00")
// Find null terminator to separate name from trailing telemetry bytes
nameEnd := len(appdata)
for i := off; i < len(appdata); i++ {
if appdata[i] == 0x00 {
nameEnd = i
break
}
}
name := string(appdata[off:nameEnd])
name = sanitizeName(name)
p.Name = name
off = nameEnd
// Skip null terminator(s)
for off < len(appdata) && appdata[off] == 0x00 {
off++
}
}
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
// Only sensor nodes (advType=4) carry telemetry bytes.
if p.Flags.Sensor && off+4 <= len(appdata) {
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
tempC := float64(tempRaw) / 100.0
if batteryMv > 0 && batteryMv <= 10000 {
p.BatteryMv = &batteryMv
}
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
if tempRaw >= -5000 && tempRaw <= 10000 {
p.TemperatureC = &tempC
}
}
}
+151
View File
@@ -1355,3 +1355,154 @@ func TestDecodeGrpTxtGarbageMarkedFailed(t *testing.T) {
t.Errorf("type=%s, want GRP_TXT", p.Type)
}
}
func TestDecodeAdvertWithTelemetry(t *testing.T) {
pubkey := strings.Repeat("AA", 32)
timestamp := "78563412"
signature := strings.Repeat("BB", 64)
flags := "94" // sensor(4) | hasLocation(0x10) | hasName(0x80)
lat := "40933402"
lon := "E0E6B8F8"
name := hex.EncodeToString([]byte("Sensor1"))
nullTerm := "00"
batteryLE := make([]byte, 2)
binary.LittleEndian.PutUint16(batteryLE, 3700)
tempLE := make([]byte, 2)
binary.LittleEndian.PutUint16(tempLE, uint16(int16(2850)))
hexStr := "1200" + pubkey + timestamp + signature + flags + lat + lon +
name + nullTerm +
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
pkt, err := DecodePacket(hexStr, nil)
if err != nil {
t.Fatal(err)
}
if pkt.Payload.Name != "Sensor1" {
t.Errorf("name=%s, want Sensor1", pkt.Payload.Name)
}
if pkt.Payload.BatteryMv == nil {
t.Fatal("battery_mv should not be nil")
}
if *pkt.Payload.BatteryMv != 3700 {
t.Errorf("battery_mv=%d, want 3700", *pkt.Payload.BatteryMv)
}
if pkt.Payload.TemperatureC == nil {
t.Fatal("temperature_c should not be nil")
}
if math.Abs(*pkt.Payload.TemperatureC-28.50) > 0.01 {
t.Errorf("temperature_c=%f, want 28.50", *pkt.Payload.TemperatureC)
}
}
func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) {
pubkey := strings.Repeat("CC", 32)
timestamp := "00000000"
signature := strings.Repeat("DD", 64)
flags := "84" // sensor(4) | hasName(0x80), no location
name := hex.EncodeToString([]byte("Cold"))
nullTerm := "00"
batteryLE := make([]byte, 2)
binary.LittleEndian.PutUint16(batteryLE, 4200)
tempLE := make([]byte, 2)
var negTemp int16 = -550
binary.LittleEndian.PutUint16(tempLE, uint16(negTemp))
hexStr := "1200" + pubkey + timestamp + signature + flags +
name + nullTerm +
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
pkt, err := DecodePacket(hexStr, nil)
if err != nil {
t.Fatal(err)
}
if pkt.Payload.Name != "Cold" {
t.Errorf("name=%s, want Cold", pkt.Payload.Name)
}
if pkt.Payload.BatteryMv == nil || *pkt.Payload.BatteryMv != 4200 {
t.Errorf("battery_mv=%v, want 4200", pkt.Payload.BatteryMv)
}
if pkt.Payload.TemperatureC == nil {
t.Fatal("temperature_c should not be nil")
}
if math.Abs(*pkt.Payload.TemperatureC-(-5.50)) > 0.01 {
t.Errorf("temperature_c=%f, want -5.50", *pkt.Payload.TemperatureC)
}
}
func TestDecodeAdvertWithoutTelemetry(t *testing.T) {
pubkey := strings.Repeat("EE", 32)
timestamp := "00000000"
signature := strings.Repeat("FF", 64)
flags := "82" // repeater(2) | hasName(0x80)
name := hex.EncodeToString([]byte("Node1"))
hexStr := "1200" + pubkey + timestamp + signature + flags + name
pkt, err := DecodePacket(hexStr, nil)
if err != nil {
t.Fatal(err)
}
if pkt.Payload.Name != "Node1" {
t.Errorf("name=%s, want Node1", pkt.Payload.Name)
}
if pkt.Payload.BatteryMv != nil {
t.Errorf("battery_mv should be nil for advert without telemetry, got %d", *pkt.Payload.BatteryMv)
}
if pkt.Payload.TemperatureC != nil {
t.Errorf("temperature_c should be nil for advert without telemetry, got %f", *pkt.Payload.TemperatureC)
}
}
func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) {
// A repeater node with 4 trailing bytes after the name should NOT decode telemetry.
pubkey := strings.Repeat("AB", 32)
timestamp := "00000000"
signature := strings.Repeat("CD", 64)
flags := "82" // repeater(2) | hasName(0x80)
name := hex.EncodeToString([]byte("Rptr"))
nullTerm := "00"
extraBytes := "B40ED403" // battery-like and temp-like bytes
hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes
pkt, err := DecodePacket(hexStr, nil)
if err != nil {
t.Fatal(err)
}
if pkt.Payload.BatteryMv != nil {
t.Errorf("battery_mv should be nil for non-sensor node, got %d", *pkt.Payload.BatteryMv)
}
if pkt.Payload.TemperatureC != nil {
t.Errorf("temperature_c should be nil for non-sensor node, got %f", *pkt.Payload.TemperatureC)
}
}
func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) {
// 0°C is a valid temperature and must be emitted.
pubkey := strings.Repeat("12", 32)
timestamp := "00000000"
signature := strings.Repeat("34", 64)
flags := "84" // sensor(4) | hasName(0x80)
name := hex.EncodeToString([]byte("FreezeSensor"))
nullTerm := "00"
batteryLE := make([]byte, 2)
binary.LittleEndian.PutUint16(batteryLE, 3600)
tempLE := make([]byte, 2) // tempRaw=0 → 0°C
hexStr := "1200" + pubkey + timestamp + signature + flags +
name + nullTerm +
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
pkt, err := DecodePacket(hexStr, nil)
if err != nil {
t.Fatal(err)
}
if pkt.Payload.TemperatureC == nil {
t.Fatal("temperature_c should not be nil for 0°C")
}
if *pkt.Payload.TemperatureC != 0.0 {
t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC)
}
}
+556 -491
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -26,12 +26,13 @@ func setupTestDBv2(t *testing.T) *DB {
schema := `
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0,
battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE observers (
id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT, first_seen TEXT,
packet_count INTEGER DEFAULT 0, model TEXT, firmware TEXT,
client_version TEXT, radio TEXT, battery_mv INTEGER, uptime_secs INTEGER, noise_floor INTEGER
client_version TEXT, radio TEXT, battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT, raw_hex TEXT NOT NULL,
+59 -20
View File
@@ -128,23 +128,25 @@ type Node struct {
LastSeen *string `json:"last_seen"`
FirstSeen *string `json:"first_seen"`
AdvertCount int `json:"advert_count"`
BatteryMv *int `json:"battery_mv"`
TemperatureC *float64 `json:"temperature_c"`
}
// Observer represents a row from the observers table.
type Observer struct {
ID string `json:"id"`
Name *string `json:"name"`
IATA *string `json:"iata"`
LastSeen *string `json:"last_seen"`
FirstSeen *string `json:"first_seen"`
PacketCount int `json:"packet_count"`
Model *string `json:"model"`
Firmware *string `json:"firmware"`
ClientVersion *string `json:"client_version"`
Radio *string `json:"radio"`
BatteryMv *int `json:"battery_mv"`
UptimeSecs *int `json:"uptime_secs"`
NoiseFloor *int `json:"noise_floor"`
ID string `json:"id"`
Name *string `json:"name"`
IATA *string `json:"iata"`
LastSeen *string `json:"last_seen"`
FirstSeen *string `json:"first_seen"`
PacketCount int `json:"packet_count"`
Model *string `json:"model"`
Firmware *string `json:"firmware"`
ClientVersion *string `json:"client_version"`
Radio *string `json:"radio"`
BatteryMv *int `json:"battery_mv"`
UptimeSecs *int64 `json:"uptime_secs"`
NoiseFloor *float64 `json:"noise_floor"`
}
// Transmission represents a row from the transmissions table.
@@ -739,7 +741,7 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB
var total int
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM nodes %s", w), args...).Scan(&total)
querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order)
querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order)
qArgs := append(args, limit, offset)
rows, err := db.conn.Query(querySQL, qArgs...)
@@ -765,7 +767,7 @@ func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, er
if limit <= 0 {
limit = 10
}
rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count
rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c
FROM nodes WHERE name LIKE ? OR public_key LIKE ? ORDER BY last_seen DESC LIMIT ?`,
"%"+query+"%", query+"%", limit)
if err != nil {
@@ -785,7 +787,7 @@ func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, er
// GetNodeByPubkey returns a single node.
func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count FROM nodes WHERE public_key = ?", pubkey)
rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes WHERE public_key = ?", pubkey)
if err != nil {
return nil, err
}
@@ -958,9 +960,21 @@ func (db *DB) GetObservers() ([]Observer, error) {
var observers []Observer
for rows.Next() {
var o Observer
if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &o.BatteryMv, &o.UptimeSecs, &o.NoiseFloor); err != nil {
var batteryMv, uptimeSecs sql.NullInt64
var noiseFloor sql.NullFloat64
if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor); err != nil {
continue
}
if batteryMv.Valid {
v := int(batteryMv.Int64)
o.BatteryMv = &v
}
if uptimeSecs.Valid {
o.UptimeSecs = &uptimeSecs.Int64
}
if noiseFloor.Valid {
o.NoiseFloor = &noiseFloor.Float64
}
observers = append(observers, o)
}
return observers, nil
@@ -969,11 +983,23 @@ func (db *DB) GetObservers() ([]Observer, error) {
// GetObserverByID returns a single observer.
func (db *DB) GetObserverByID(id string) (*Observer, error) {
var o Observer
var batteryMv, uptimeSecs sql.NullInt64
var noiseFloor sql.NullFloat64
err := db.conn.QueryRow("SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor FROM observers WHERE id = ?", id).
Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &o.BatteryMv, &o.UptimeSecs, &o.NoiseFloor)
Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor)
if err != nil {
return nil, err
}
if batteryMv.Valid {
v := int(batteryMv.Int64)
o.BatteryMv = &v
}
if uptimeSecs.Valid {
o.UptimeSecs = &uptimeSecs.Int64
}
if noiseFloor.Valid {
o.NoiseFloor = &noiseFloor.Float64
}
return &o, nil
}
@@ -1634,11 +1660,13 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} {
var name, role, lastSeen, firstSeen sql.NullString
var lat, lon sql.NullFloat64
var advertCount int
var batteryMv sql.NullInt64
var temperatureC sql.NullFloat64
if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount); err != nil {
if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC); err != nil {
return nil
}
return map[string]interface{}{
m := map[string]interface{}{
"public_key": pk,
"name": nullStr(name),
"role": nullStr(role),
@@ -1651,6 +1679,17 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} {
"hash_size": nil,
"hash_size_inconsistent": false,
}
if batteryMv.Valid {
m["battery_mv"] = int(batteryMv.Int64)
} else {
m["battery_mv"] = nil
}
if temperatureC.Valid {
m["temperature_c"] = temperatureC.Float64
} else {
m["temperature_c"] = nil
}
return m
}
func nullStr(ns sql.NullString) interface{} {
+129 -2
View File
@@ -28,7 +28,9 @@ func setupTestDB(t *testing.T) *DB {
lon REAL,
last_seen TEXT,
first_seen TEXT,
advert_count INTEGER DEFAULT 0
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
);
CREATE TABLE observers (
@@ -44,7 +46,7 @@ func setupTestDB(t *testing.T) *DB {
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor INTEGER
noise_floor REAL
);
CREATE TABLE transmissions (
@@ -369,6 +371,88 @@ func TestGetObserverByIDNotFound(t *testing.T) {
}
}
func TestObserverTypeConsistency(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert observer with typed metadata matching ingestor writes
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
VALUES ('obs_typed', 'TypedObs', 'SJC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 10, 3500, 86400, -115.5)`)
obs, err := db.GetObserverByID("obs_typed")
if err != nil {
t.Fatal(err)
}
// battery_mv should be *int
if obs.BatteryMv == nil {
t.Fatal("BatteryMv should not be nil")
}
if *obs.BatteryMv != 3500 {
t.Errorf("BatteryMv=%d, want 3500", *obs.BatteryMv)
}
// uptime_secs should be *int64
if obs.UptimeSecs == nil {
t.Fatal("UptimeSecs should not be nil")
}
if *obs.UptimeSecs != 86400 {
t.Errorf("UptimeSecs=%d, want 86400", *obs.UptimeSecs)
}
// noise_floor should be *float64
if obs.NoiseFloor == nil {
t.Fatal("NoiseFloor should not be nil")
}
if *obs.NoiseFloor != -115.5 {
t.Errorf("NoiseFloor=%f, want -115.5", *obs.NoiseFloor)
}
// Verify NULL handling: observer without metadata
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs_null', 'NullObs', 'SFO', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 5)`)
obsNull, err := db.GetObserverByID("obs_null")
if err != nil {
t.Fatal(err)
}
if obsNull.BatteryMv != nil {
t.Errorf("BatteryMv should be nil for observer without metadata, got %d", *obsNull.BatteryMv)
}
if obsNull.UptimeSecs != nil {
t.Errorf("UptimeSecs should be nil for observer without metadata, got %d", *obsNull.UptimeSecs)
}
if obsNull.NoiseFloor != nil {
t.Errorf("NoiseFloor should be nil for observer without metadata, got %f", *obsNull.NoiseFloor)
}
}
func TestObserverTypesInGetObservers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
VALUES ('obs1', 'Obs1', 'SJC', '2026-06-01T00:00:00Z', '2026-01-01T00:00:00Z', 10, 4200, 172800, -110.3)`)
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
if len(observers) != 1 {
t.Fatalf("expected 1 observer, got %d", len(observers))
}
o := observers[0]
if o.BatteryMv == nil || *o.BatteryMv != 4200 {
t.Errorf("BatteryMv=%v, want 4200", o.BatteryMv)
}
if o.UptimeSecs == nil || *o.UptimeSecs != 172800 {
t.Errorf("UptimeSecs=%v, want 172800", o.UptimeSecs)
}
if o.NoiseFloor == nil || *o.NoiseFloor != -110.3 {
t.Errorf("NoiseFloor=%v, want -110.3", o.NoiseFloor)
}
}
func TestGetDistinctIATAs(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
@@ -1386,6 +1470,49 @@ func TestGetChannelsStaleMessage(t *testing.T) {
}
}
func TestNodeTelemetryFields(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c)
VALUES ('pk_telem1', 'SensorNode', 'sensor', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 5, 3700, 28.5)`)
node, err := db.GetNodeByPubkey("pk_telem1")
if err != nil {
t.Fatal(err)
}
if node == nil {
t.Fatal("expected node, got nil")
}
if node["battery_mv"] != 3700 {
t.Errorf("battery_mv=%v, want 3700", node["battery_mv"])
}
if node["temperature_c"] != 28.5 {
t.Errorf("temperature_c=%v, want 28.5", node["temperature_c"])
}
nodes, _, _, err := db.GetNodes(50, 0, "sensor", "", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 sensor node, got %d", len(nodes))
}
if nodes[0]["battery_mv"] != 3700 {
t.Errorf("GetNodes battery_mv=%v, want 3700", nodes[0]["battery_mv"])
}
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count)
VALUES ('pk_notelem', 'PlainNode', 'repeater', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 3)`)
node2, _ := db.GetNodeByPubkey("pk_notelem")
if node2["battery_mv"] != nil {
t.Errorf("expected nil battery_mv for node without telemetry, got %v", node2["battery_mv"])
}
if node2["temperature_c"] != nil {
t.Errorf("expected nil temperature_c for node without telemetry, got %v", node2["temperature_c"])
}
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
+13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"os"
"os/exec"
"os/signal"
@@ -54,6 +55,18 @@ func resolveBuildTime() string {
}
func main() {
// pprof profiling — off by default, enable with ENABLE_PPROF=true
if os.Getenv("ENABLE_PPROF") == "true" {
pprofPort := os.Getenv("PPROF_PORT")
if pprofPort == "" {
pprofPort = "6060"
}
go func() {
log.Printf("[pprof] profiling UI at http://localhost:%s/debug/pprof/", pprofPort)
log.Fatal(http.ListenAndServe(":"+pprofPort, nil))
}()
}
var (
configDir string
port int
+12
View File
@@ -996,6 +996,12 @@
"elementShape": {
"type": "number"
}
},
"battery_mv": {
"type": "nullable_number"
},
"temperature_c": {
"type": "nullable_number"
}
}
},
@@ -1097,6 +1103,12 @@
},
"last_heard": {
"type": "string"
},
"battery_mv": {
"type": "nullable_number"
},
"temperature_c": {
"type": "nullable_number"
}
}
}
+37 -3
View File
@@ -33,7 +33,9 @@ db.exec(`
lon REAL,
last_seen TEXT,
first_seen TEXT,
advert_count INTEGER DEFAULT 0
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
);
CREATE TABLE IF NOT EXISTS observers (
@@ -60,7 +62,9 @@ db.exec(`
lon REAL,
last_seen TEXT,
first_seen TEXT,
advert_count INTEGER DEFAULT 0
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
);
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
@@ -324,6 +328,22 @@ for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv',
}
}
// --- One-time migration: add telemetry columns to nodes and inactive_nodes ---
{
const done = db.prepare(`SELECT 1 FROM _migrations WHERE name = 'node_telemetry_v1'`).get();
if (!done) {
console.log('[migration] Adding telemetry columns to nodes/inactive_nodes...');
const nodeCols = db.pragma('table_info(nodes)').map(c => c.name);
if (!nodeCols.includes('battery_mv')) db.exec(`ALTER TABLE nodes ADD COLUMN battery_mv INTEGER`);
if (!nodeCols.includes('temperature_c')) db.exec(`ALTER TABLE nodes ADD COLUMN temperature_c REAL`);
const inactiveCols = db.pragma('table_info(inactive_nodes)').map(c => c.name);
if (!inactiveCols.includes('battery_mv')) db.exec(`ALTER TABLE inactive_nodes ADD COLUMN battery_mv INTEGER`);
if (!inactiveCols.includes('temperature_c')) db.exec(`ALTER TABLE inactive_nodes ADD COLUMN temperature_c REAL`);
db.prepare(`INSERT INTO _migrations (name) VALUES ('node_telemetry_v1')`).run();
console.log('[migration] node telemetry columns added');
}
}
// --- Prepared statements ---
const stmts = {
upsertNode: db.prepare(`
@@ -339,6 +359,12 @@ const stmts = {
incrementAdvertCount: db.prepare(`
UPDATE nodes SET advert_count = advert_count + 1 WHERE public_key = @public_key
`),
updateNodeTelemetry: db.prepare(`
UPDATE nodes SET
battery_mv = COALESCE(@battery_mv, battery_mv),
temperature_c = COALESCE(@temperature_c, temperature_c)
WHERE public_key = @public_key
`),
upsertObserver: db.prepare(`
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
@@ -511,6 +537,14 @@ function incrementAdvertCount(publicKey) {
stmts.incrementAdvertCount.run({ public_key: publicKey });
}
function updateNodeTelemetry(data) {
stmts.updateNodeTelemetry.run({
public_key: data.public_key,
battery_mv: data.battery_mv ?? null,
temperature_c: data.temperature_c ?? null,
});
}
function upsertNode(data) {
const now = new Date().toISOString();
stmts.upsertNode.run({
@@ -898,4 +932,4 @@ function moveStaleNodes(nodeDays) {
return moved;
}
module.exports = { db, schemaVersion, observerIdToRowid, resolveObserverIdx, insertTransmission, upsertNode, incrementAdvertCount, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, searchNodes, getNodeHealth, getNodeAnalytics, removePhantomNodes, moveStaleNodes };
module.exports = { db, schemaVersion, observerIdToRowid, resolveObserverIdx, insertTransmission, upsertNode, incrementAdvertCount, updateNodeTelemetry, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, searchNodes, getNodeHealth, getNodeAnalytics, removePhantomNodes, moveStaleNodes };
+24 -2
View File
@@ -135,10 +135,32 @@ function decodeAdvert(buf) {
off += 8;
}
if (result.flags.hasName) {
let name = appdata.subarray(off).toString('utf8');
// Strip non-printable characters (< 0x20 except tab/newline) and DEL
// Find null terminator to separate name from trailing telemetry bytes
let nameEnd = appdata.length;
for (let i = off; i < appdata.length; i++) {
if (appdata[i] === 0x00) { nameEnd = i; break; }
}
let name = appdata.subarray(off, nameEnd).toString('utf8');
name = name.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
result.name = name;
off = nameEnd;
// Skip null terminator(s)
while (off < appdata.length && appdata[off] === 0x00) off++;
}
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
// Only sensor nodes (advType=4) carry telemetry bytes.
if (result.flags.sensor && off + 4 <= appdata.length) {
const batteryMv = appdata.readUInt16LE(off);
const tempRaw = appdata.readInt16LE(off + 2);
const tempC = tempRaw / 100.0;
if (batteryMv > 0 && batteryMv <= 10000) {
result.battery_mv = batteryMv;
}
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
if (tempRaw >= -5000 && tempRaw <= 10000) {
result.temperature_c = tempC;
}
}
}
+4
View File
@@ -58,12 +58,16 @@ services:
ports:
- "${STAGING_GO_HTTP_PORT:-82}:80"
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
- "6060:6060" # pprof server
- "6061:6061" # pprof ingestor
volumes:
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/config.json:/app/config.json:ro
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
- caddy-data-staging-go:/data/caddy
environment:
- NODE_ENV=staging
- ENABLE_PPROF=true
- PPROF_PORT=6060
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
interval: 30s
+26 -17
View File
@@ -177,6 +177,15 @@
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
});
// #206 — Wrap analytics tables in scroll containers on mobile
el.querySelectorAll('.analytics-table').forEach(tbl => {
if (!tbl.parentElement.classList.contains('analytics-table-scroll')) {
const wrapper = document.createElement('div');
wrapper.className = 'analytics-table-scroll';
tbl.parentElement.insertBefore(wrapper, tbl);
wrapper.appendChild(tbl);
}
});
});
// Deep-link scroll to section within tab
const sectionId = new URLSearchParams((location.hash.split('?')[1] || '')).get('section');
@@ -395,7 +404,7 @@
function renderSNRByType(snrByType) {
if (!snrByType.length) return '<div class="text-muted">No data</div>';
let html = '<table class="analytics-table"><thead><tr><th>Type</th><th>Packets</th><th>Avg SNR</th><th>Min</th><th>Max</th><th>Distribution</th></tr></thead><tbody>';
let html = '<table class="analytics-table"><thead><tr><th scope="col">Type</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Min</th><th scope="col">Max</th><th scope="col">Distribution</th></tr></thead><tbody>';
snrByType.forEach(t => {
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
@@ -535,7 +544,7 @@
function renderPairTable(pairs) {
if (!pairs.length) return '<div class="text-muted">Not enough multi-hop data</div>';
let html = '<table class="analytics-table"><thead><tr><th>Node A</th><th>Node B</th><th>Co-appearances</th></tr></thead><tbody>';
let html = '<table class="analytics-table"><thead><tr><th scope="col">Node A</th><th scope="col">Node B</th><th scope="col">Co-appearances</th></tr></thead><tbody>';
pairs.slice(0, 12).forEach(p => {
html += `<tr>
<td>${p.nameA ? `<a href="#/nodes/${encodeURIComponent(p.pubkeyA)}" class="analytics-link">${esc(p.nameA)}</a>` : `<span class="mono">${p.hopA}</span>`}</td>
@@ -598,7 +607,7 @@
function renderCrossObserver(nodes) {
if (!nodes.length) return '<div class="text-muted">No nodes seen by multiple observers</div>';
let html = `<table class="analytics-table">
<thead><tr><th>Node</th><th>Observers</th><th>Hop Distances</th></tr></thead><tbody>`;
<thead><tr><th scope="col">Node</th><th scope="col">Observers</th><th scope="col">Hop Distances</th></tr></thead><tbody>`;
nodes.forEach(n => {
const name = n.name
? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>`
@@ -719,7 +728,7 @@
var ths = '';
for (var i = 0; i < cols.length; i++) {
var c = cols[i];
ths += '<th class="sortable' + (c.key === activeCol ? ' sort-active' : '') + '" data-sort-col="' + c.key + '">' +
ths += '<th scope="col" class="sortable' + (c.key === activeCol ? ' sort-active' : '') + '" data-sort-col="' + c.key + '">' +
c.label + channelSortArrow(c.key, activeCol, dir) + '</th>';
}
return '<thead><tr>' + ths + '</tr></thead>';
@@ -880,7 +889,7 @@
<p class="text-muted">Nodes advertising with 2+ byte hash paths</p>
${data.multiByteNodes.length ? `
<table class="analytics-table">
<thead><tr><th>Node</th><th>Hash Size</th><th>Adverts</th><th>Last Seen</th></tr></thead>
<thead><tr><th scope="col">Node</th><th scope="col">Hash Size</th><th scope="col">Adverts</th><th scope="col">Last Seen</th></tr></thead>
<tbody>
${data.multiByteNodes.map(n => `<tr class="clickable-row" data-action="navigate" data-value="#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}" tabindex="0" role="row">
<td><strong>${esc(n.name)}</strong></td>
@@ -896,7 +905,7 @@
<div class="analytics-card flex-1">
<h3>Top Path Hops</h3>
<table class="analytics-table">
<thead><tr><th>Hop</th><th>Node</th><th>Bytes</th><th>Appearances</th></tr></thead>
<thead><tr><th scope="col">Hop</th><th scope="col">Node</th><th scope="col">Bytes</th><th scope="col">Appearances</th></tr></thead>
<tbody>
${data.topHops.map(h => {
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
@@ -952,7 +961,7 @@
ihEl.innerHTML = '<div class="text-muted" style="padding:4px">✅ No inconsistencies detected — all nodes are reporting consistent hash sizes.</div>';
} else {
ihEl.innerHTML = `<table class="analytics-table" style="background:var(--card-bg);border:1px solid var(--border);border-radius:8px;overflow:hidden">
<thead><tr><th>Node</th><th>Role</th><th>Current Hash</th><th>Sizes Seen</th></tr></thead>
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Current Hash</th><th scope="col">Sizes Seen</th></tr></thead>
<tbody>${inconsistent.map((n, i) => {
const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280';
const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?';
@@ -1123,7 +1132,7 @@
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
el.innerHTML = `<table class="analytics-table">
<thead><tr><th>Hop</th><th>Appearances</th><th>Max Distance</th><th>Assessment</th><th>Colliding Nodes</th></tr></thead>
<thead><tr><th scope="col">Hop</th><th scope="col">Appearances</th><th scope="col">Max Distance</th><th scope="col">Assessment</th><th scope="col">Colliding Nodes</th></tr></thead>
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
@@ -1179,7 +1188,7 @@
return `<h4>${title}</h4>
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops</p>
<table class="analytics-table"><thead><tr>
<th>#</th><th>Route</th><th>Occurrences</th><th>% of paths</th><th>Frequency</th>
<th scope="col">#</th><th scope="col">Route</th><th scope="col">Occurrences</th><th scope="col">% of paths</th><th scope="col">Frequency</th>
</tr></thead><tbody>
${data.subpaths.map((s, i) => {
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
@@ -1434,7 +1443,7 @@
${myKeys.size ? `<h3>⭐ My Claimed Nodes</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th>Node</th><th>Role</th><th>Packets</th><th>Avg SNR</th><th>Observers</th><th>Last Heard</th></tr></thead>
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Last Heard</th></tr></thead>
<tbody>
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
const s = n.health.stats;
@@ -1452,7 +1461,7 @@
<h3>🏆 Most Active Nodes</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Total Packets</th><th>Packets Today</th><th>Analytics</th></tr></thead>
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Total Packets</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
<tbody>
${byPackets.slice(0, 15).map((n, i) => `<tr>
<td>${i + 1}</td>
@@ -1467,7 +1476,7 @@
<h3>📶 Best Signal Quality</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Avg SNR</th><th>Observers</th><th>Analytics</th></tr></thead>
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Analytics</th></tr></thead>
<tbody>
${bySnr.slice(0, 15).map((n, i) => `<tr>
<td>${i + 1}</td>
@@ -1482,7 +1491,7 @@
<h3>👀 Most Observed Nodes</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Observers</th><th>Avg SNR</th><th>Analytics</th></tr></thead>
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Observers</th><th scope="col">Avg SNR</th><th scope="col">Analytics</th></tr></thead>
<tbody>
${byObservers.slice(0, 15).map((n, i) => `<tr>
<td>${i + 1}</td>
@@ -1497,7 +1506,7 @@
<h3> Recently Active</h3>
<table class="analytics-table" style="margin-bottom:24px">
<thead><tr><th>Node</th><th>Role</th><th>Last Heard</th><th>Packets Today</th><th>Analytics</th></tr></thead>
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Last Heard</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
<tbody>
${byRecent.slice(0, 15).map(n => `<tr>
<td>${nodeLink(n)}${claimedBadge(n)}</td>
@@ -1529,7 +1538,7 @@
// Category stats
const cats = data.catStats;
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th>Type</th><th>Count</th><th>Avg (km)</th><th>Median (km)</th><th>Min (km)</th><th>Max (km)</th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (km)</th><th scope="col">Median (km)</th><th scope="col">Min (km)</th><th scope="col">Max (km)</th></tr></thead><tbody>`;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
@@ -1549,7 +1558,7 @@
}
// Top hops leaderboard
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th>#</th><th>From</th><th>To</th><th>Distance (km)</th><th>Type</th><th>SNR</th><th>Packet</th><th></th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (km)</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
@@ -1563,7 +1572,7 @@
// Top paths
if (data.topPaths.length) {
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th>#</th><th>Total Distance (km)</th><th>Hops</th><th>Route</th><th>Packet</th><th></th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (km)</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
data.topPaths.slice(0, 10).forEach((p, i) => {
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
+37 -3
View File
@@ -523,21 +523,21 @@ window.addEventListener('DOMContentLoaded', () => {
const pktList = packets.packets || packets;
if (Array.isArray(pktList)) {
for (const p of pktList.slice(0, 5)) {
html += `<div class="search-result-item" onclick="location.hash='#/packets/${p.packet_hash || p.hash || p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
html += `<div class="search-result-item" tabindex="0" role="option" data-href="#/packets/${p.packet_hash || p.hash || p.id}">
<span class="search-result-type">Packet</span>${truncate(p.packet_hash || '', 16)} ${payloadTypeName(p.payload_type)}</div>`;
}
}
const nodeList = Array.isArray(nodes) ? nodes : (nodes.nodes || []);
for (const n of nodeList.slice(0, 5)) {
if (n.name && n.name.toLowerCase().includes(q.toLowerCase())) {
html += `<div class="search-result-item" onclick="location.hash='#/nodes/${n.public_key}';document.getElementById('searchOverlay').classList.add('hidden')">
html += `<div class="search-result-item" tabindex="0" role="option" data-href="#/nodes/${n.public_key}">
<span class="search-result-type">Node</span>${n.name} ${truncate(n.public_key || '', 16)}</div>`;
}
}
const chList = Array.isArray(channels) ? channels : [];
for (const c of chList) {
if (c.name && c.name.toLowerCase().includes(q.toLowerCase())) {
html += `<div class="search-result-item" onclick="location.hash='#/channels/${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
html += `<div class="search-result-item" tabindex="0" role="option" data-href="#/channels/${c.channel_hash}">
<span class="search-result-type">Channel</span>${c.name}</div>`;
}
}
@@ -547,6 +547,40 @@ window.addEventListener('DOMContentLoaded', () => {
}, 300);
});
// #208 — Search results keyboard: click, Enter/Space, arrow-key navigation
function activateSearchItem(item) {
if (!item || !item.dataset.href) return;
location.hash = item.dataset.href;
searchOverlay.classList.add('hidden');
}
searchResults.addEventListener('click', (e) => {
activateSearchItem(e.target.closest('.search-result-item'));
});
searchResults.addEventListener('keydown', (e) => {
const item = e.target.closest('.search-result-item');
if (!item) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activateSearchItem(item);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const next = item.nextElementSibling;
if (next && next.classList.contains('search-result-item')) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = item.previousElementSibling;
if (prev && prev.classList.contains('search-result-item')) prev.focus();
else searchInput.focus();
}
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const first = searchResults.querySelector('.search-result-item');
if (first) first.focus();
}
});
// --- Login ---
// (removed — no auth yet)
+1 -1
View File
@@ -263,7 +263,7 @@
<div class="alab-section">
<h3>🎹 Note Sequence</h3>
<table class="alab-note-table">
<tr><th></th><th>#</th><th>Payload Index</th><th>Byte</th><th> MIDI</th><th> Freq</th><th>Duration (why)</th><th>Gap (why)</th></tr>
<tr><th scope="col"></th><th scope="col">#</th><th scope="col">Payload Index</th><th scope="col">Byte</th><th scope="col"> MIDI</th><th scope="col"> Freq</th><th scope="col">Duration (why)</th><th scope="col">Gap (why)</th></tr>
${m.notes.map((n, i) => {
const durWhy = `byte ${n.byte} → map(0...255 → 50...400ms) × tempo`;
const gapWhy = i < m.notes.length - 1
+13 -4
View File
@@ -57,6 +57,15 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
'<div id="compareContent"></div>' +
'</div>';
// #209 — Keyboard accessibility for compare table rows
app.addEventListener('keydown', function (e) {
var row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
location.hash = row.dataset.value;
});
loadObservers();
}
@@ -316,9 +325,9 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
el.innerHTML =
(hashes.length > displayLimit ? '<div class="text-muted" style="margin-bottom:8px">Showing first ' + displayLimit + ' of ' + hashes.length.toLocaleString() + ' packets.</div>' : '') +
'<table class="data-table compare-table">' +
'<div class="analytics-table-scroll"><table class="data-table compare-table">' +
'<thead><tr>' +
'<th>Hash</th><th>Time</th><th>Type</th><th>Observer</th>' +
'<th scope="col">Hash</th><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Observer</th>' +
'</tr></thead>' +
'<tbody>' + displayed.map(function (h) {
var p = mapA.get(h) || mapB.get(h);
@@ -332,7 +341,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
} else {
obsLabel = nameB;
}
return '<tr style="cursor:pointer" onclick="location.hash=\'#/packets/' + escapeHtml(h) + '\'">' +
return '<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/' + escapeHtml(h) + '" onclick="location.hash=\'#/packets/' + escapeHtml(h) + '\'">' +
'<td class="mono" style="font-size:0.85em">' + escapeHtml(h.substring(0, 12)) + '</td>' +
'<td>' + timeAgo(p.timestamp || p.first_seen) + '</td>' +
'<td><span class="payload-badge badge-' + payloadTypeColor(p.payload_type) + '">' + escapeHtml(typeName) + '</span></td>' +
@@ -340,7 +349,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
'</tr>';
}).join('') +
'</tbody>' +
'</table>';
'</table></div>';
}
registerPage('compare', { init: init, destroy: destroy });
+24 -24
View File
@@ -642,21 +642,21 @@
var b = state.branding;
var logoPreview = b.logoUrl ? '<img class="cust-preview-img" src="' + escAttr(b.logoUrl) + '" alt="Logo preview" onerror="this.style.display=\'none\'">' : '';
return '<div class="cust-panel' + (activeTab === 'branding' ? ' active' : '') + '" data-panel="branding">' +
'<div class="cust-field"><label>Site Name</label><input type="text" data-key="branding.siteName" value="' + escAttr(b.siteName) + '"></div>' +
'<div class="cust-field"><label>Tagline</label><input type="text" data-key="branding.tagline" value="' + escAttr(b.tagline) + '"></div>' +
'<div class="cust-field"><label>Logo URL</label><input type="text" data-key="branding.logoUrl" value="' + escAttr(b.logoUrl) + '" placeholder="https://...">' + logoPreview + '</div>' +
'<div class="cust-field"><label>Favicon URL</label><input type="text" data-key="branding.faviconUrl" value="' + escAttr(b.faviconUrl) + '" placeholder="https://..."></div>' +
'<div class="cust-field"><label for="cust-siteName">Site Name</label><input type="text" id="cust-siteName" data-key="branding.siteName" value="' + escAttr(b.siteName) + '"></div>' +
'<div class="cust-field"><label for="cust-tagline">Tagline</label><input type="text" id="cust-tagline" data-key="branding.tagline" value="' + escAttr(b.tagline) + '"></div>' +
'<div class="cust-field"><label for="cust-logoUrl">Logo URL</label><input type="text" id="cust-logoUrl" data-key="branding.logoUrl" value="' + escAttr(b.logoUrl) + '" placeholder="https://...">' + logoPreview + '</div>' +
'<div class="cust-field"><label for="cust-faviconUrl">Favicon URL</label><input type="text" id="cust-faviconUrl" data-key="branding.faviconUrl" value="' + escAttr(b.faviconUrl) + '" placeholder="https://..."></div>' +
'</div>';
}
function renderColorRow(key, val, def, dataAttr) {
var isFont = key === 'font' || key === 'mono';
var inputHtml = isFont
? '<input type="text" data-' + dataAttr + '="' + key + '" value="' + escAttr(val) + '" style="width:160px;font-size:11px;font-family:var(--mono);padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--text)">'
: '<input type="color" data-' + dataAttr + '="' + key + '" value="' + val + '">' +
? '<input type="text" id="cust-' + dataAttr + '-' + key + '" data-' + dataAttr + '="' + key + '" value="' + escAttr(val) + '" style="width:160px;font-size:11px;font-family:var(--mono);padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--text)">'
: '<input type="color" id="cust-' + dataAttr + '-' + key + '" data-' + dataAttr + '="' + key + '" value="' + val + '">' +
'<span class="cust-hex" data-hex="' + key + '">' + val + '</span>';
return '<div class="cust-color-row">' +
'<div><label>' + THEME_LABELS[key] + '</label>' +
'<div><label for="cust-' + dataAttr + '-' + key + '">' + THEME_LABELS[key] + '</label>' +
'<div class="cust-hint">' + (THEME_HINTS[key] || '') + '</div></div>' +
inputHtml +
(val !== def ? '<button class="cust-reset-btn" data-reset-theme="' + key + '">Reset</button>' : '') +
@@ -708,9 +708,9 @@
var val = state.nodeColors[key];
var def = DEFAULTS.nodeColors[key];
rows += '<div class="cust-color-row">' +
'<div><label>' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + '</label>' +
'<div><label for="cust-node-' + key + '">' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + '</label>' +
'<div class="cust-hint">' + (NODE_HINTS[key] || '') + '</div></div>' +
'<input type="color" data-node="' + key + '" value="' + val + '">' +
'<input type="color" id="cust-node-' + key + '" data-node="' + key + '" value="' + val + '">' +
'<span class="cust-node-dot" style="background:' + val + '" data-dot="' + key + '"></span>' +
'<span class="cust-hex" data-nhex="' + key + '">' + val + '</span>' +
(val !== def ? '<button class="cust-reset-btn" data-reset-node="' + key + '">Reset</button>' : '') +
@@ -721,9 +721,9 @@
var tval = state.typeColors[tkey];
var tdef = DEFAULTS.typeColors[tkey];
typeRows += '<div class="cust-color-row">' +
'<div><label>' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + '</label>' +
'<div><label for="cust-type-' + tkey + '">' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + '</label>' +
'<div class="cust-hint">' + (TYPE_HINTS[tkey] || '') + '</div></div>' +
'<input type="color" data-type-color="' + tkey + '" value="' + tval + '">' +
'<input type="color" id="cust-type-' + tkey + '" data-type-color="' + tkey + '" value="' + tval + '">' +
'<span class="cust-node-dot" style="background:' + tval + '" data-tdot="' + tkey + '"></span>' +
'<span class="cust-hex" data-thex="' + tkey + '">' + tval + '</span>' +
(tval !== tdef ? '<button class="cust-reset-btn" data-reset-type="' + tkey + '">Reset</button>' : '') +
@@ -742,13 +742,13 @@
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Heatmap Opacity</p>' +
'<div class="cust-color-row">' +
'<div><label>🗺️ Nodes Map</label>' +
'<div><label for="custHeatOpacity">🗺️ Nodes Map</label>' +
'<div class="cust-hint">Heatmap overlay on the Nodes → Map page (0100%)</div></div>' +
'<input type="range" id="custHeatOpacity" min="0" max="100" value="' + heatPct + '" style="width:120px;cursor:pointer">' +
'<span id="custHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + heatPct + '%</span>' +
'</div>' +
'<div class="cust-color-row">' +
'<div><label>📡 Live Map</label>' +
'<div><label for="custLiveHeatOpacity">📡 Live Map</label>' +
'<div class="cust-hint">Heatmap overlay on the Live page (0100%)</div></div>' +
'<input type="range" id="custLiveHeatOpacity" min="0" max="100" value="' + liveHeatPct + '" style="width:120px;cursor:pointer">' +
'<span id="custLiveHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + liveHeatPct + '%</span>' +
@@ -761,13 +761,13 @@
var stepsHtml = h.steps.map(function (s, i) {
return '<div class="cust-list-item" data-step="' + i + '">' +
'<div class="cust-list-row">' +
'<input class="cust-emoji-input" data-step-field="emoji" data-idx="' + i + '" value="' + escAttr(s.emoji) + '" placeholder="📡">' +
'<input data-step-field="title" data-idx="' + i + '" value="' + escAttr(s.title) + '" placeholder="Title">' +
'<input class="cust-emoji-input" data-step-field="emoji" data-idx="' + i + '" value="' + escAttr(s.emoji) + '" placeholder="📡" aria-label="Step ' + (i + 1) + ' emoji">' +
'<input data-step-field="title" data-idx="' + i + '" value="' + escAttr(s.title) + '" placeholder="Title" aria-label="Step ' + (i + 1) + ' title">' +
'<button class="cust-list-btn" data-move-step="' + i + '" data-dir="up" title="Move up">↑</button>' +
'<button class="cust-list-btn" data-move-step="' + i + '" data-dir="down" title="Move down">↓</button>' +
'<button class="cust-list-btn danger" data-rm-step="' + i + '" title="Remove">✕</button>' +
'</div>' +
'<textarea data-step-field="description" data-idx="' + i + '" placeholder="Description" rows="2">' + esc(s.description) + '</textarea>' +
'<textarea data-step-field="description" data-idx="' + i + '" placeholder="Description" rows="2" aria-label="Step ' + (i + 1) + ' description">' + esc(s.description) + '</textarea>' +
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code> <code>- list</code></div>' +
'</div>';
}).join('');
@@ -775,10 +775,10 @@
var checkHtml = h.checklist.map(function (c, i) {
return '<div class="cust-list-item" data-check="' + i + '">' +
'<div class="cust-list-row">' +
'<input data-check-field="question" data-idx="' + i + '" value="' + escAttr(c.question) + '" placeholder="Question">' +
'<input data-check-field="question" data-idx="' + i + '" value="' + escAttr(c.question) + '" placeholder="Question" aria-label="Checklist item ' + (i + 1) + ' question">' +
'<button class="cust-list-btn danger" data-rm-check="' + i + '" title="Remove">✕</button>' +
'</div>' +
'<textarea data-check-field="answer" data-idx="' + i + '" placeholder="Answer" rows="2">' + esc(c.answer) + '</textarea>' +
'<textarea data-check-field="answer" data-idx="' + i + '" placeholder="Answer" rows="2" aria-label="Checklist item ' + (i + 1) + ' answer">' + esc(c.answer) + '</textarea>' +
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code> <code>- list</code></div>' +
'</div>';
}).join('');
@@ -786,16 +786,16 @@
var linksHtml = h.footerLinks.map(function (l, i) {
return '<div class="cust-list-item" data-link="' + i + '">' +
'<div class="cust-list-row">' +
'<input data-link-field="label" data-idx="' + i + '" value="' + escAttr(l.label) + '" placeholder="Label">' +
'<input data-link-field="label" data-idx="' + i + '" value="' + escAttr(l.label) + '" placeholder="Label" aria-label="Footer link ' + (i + 1) + ' label">' +
'<button class="cust-list-btn danger" data-rm-link="' + i + '" title="Remove">✕</button>' +
'</div>' +
'<input data-link-field="url" data-idx="' + i + '" value="' + escAttr(l.url) + '" placeholder="URL">' +
'<input data-link-field="url" data-idx="' + i + '" value="' + escAttr(l.url) + '" placeholder="URL" aria-label="Footer link ' + (i + 1) + ' URL">' +
'</div>';
}).join('');
return '<div class="cust-panel' + (activeTab === 'home' ? ' active' : '') + '" data-panel="home">' +
'<div class="cust-field"><label>Hero Title</label><input type="text" data-key="home.heroTitle" value="' + escAttr(h.heroTitle) + '"></div>' +
'<div class="cust-field"><label>Hero Subtitle</label><input type="text" data-key="home.heroSubtitle" value="' + escAttr(h.heroSubtitle) + '"></div>' +
'<div class="cust-field"><label for="cust-heroTitle">Hero Title</label><input type="text" id="cust-heroTitle" data-key="home.heroTitle" value="' + escAttr(h.heroTitle) + '"></div>' +
'<div class="cust-field"><label for="cust-heroSubtitle">Hero Subtitle</label><input type="text" id="cust-heroSubtitle" data-key="home.heroSubtitle" value="' + escAttr(h.heroSubtitle) + '"></div>' +
'<p class="cust-section-title" style="margin-top:20px">Steps</p>' + stepsHtml +
'<button class="cust-add-btn" id="addStep">+ Add Step</button>' +
'<p class="cust-section-title" style="margin-top:24px">FAQ / Checklist</p>' + checkHtml +
@@ -870,11 +870,11 @@
'<div class="cust-export-btns" style="margin-bottom:12px">' +
'<button class="cust-dl-btn" id="custDownload">💾 Download theme.json</button>' +
'<button class="cust-dl-btn" id="custImportFile">📂 Import File</button>' +
'<input type="file" id="custImportInput" accept=".json,application/json" style="display:none">' +
'<input type="file" id="custImportInput" accept=".json,application/json" style="display:none" aria-label="Import theme file">' +
'<button class="cust-copy-btn" id="custCopy">📋 Copy</button>' +
'</div>' +
'<details style="margin-top:8px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted)">Raw JSON</summary>' +
'<textarea class="cust-export-area" id="custExportJson" style="margin-top:8px">' + esc(json) + '</textarea>' +
'<textarea class="cust-export-area" id="custExportJson" style="margin-top:8px" aria-label="Theme JSON data">' + esc(json) + '</textarea>' +
'</details>' +
'</div>';
}
+28 -28
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774685398">
<link rel="stylesheet" href="home.css?v=1774685398">
<link rel="stylesheet" href="live.css?v=1774685398">
<link rel="stylesheet" href="style.css?v=1774690966">
<link rel="stylesheet" href="home.css?v=1774690966">
<link rel="stylesheet" href="live.css?v=1774690966">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -74,36 +74,36 @@
<div id="searchOverlay" class="search-overlay hidden" aria-label="Search packets, nodes, channels">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search packets, nodes, channels…" autofocus>
<div id="searchResults" class="search-results"></div>
<div id="searchResults" class="search-results" role="listbox"></div>
</div>
</div>
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774685398"></script>
<script src="customize.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774685398"></script>
<script src="hop-resolver.js?v=1774685398"></script>
<script src="hop-display.js?v=1774685398"></script>
<script src="app.js?v=1774685398"></script>
<script src="home.js?v=1774685398"></script>
<script src="packet-filter.js?v=1774685398"></script>
<script src="packets.js?v=1774685398"></script>
<script src="map.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774685398" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774690966"></script>
<script src="customize.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774690966"></script>
<script src="hop-resolver.js?v=1774690966"></script>
<script src="hop-display.js?v=1774690966"></script>
<script src="app.js?v=1774690966"></script>
<script src="home.js?v=1774690966"></script>
<script src="packet-filter.js?v=1774690966"></script>
<script src="packets.js?v=1774690966"></script>
<script src="map.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774690966" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+6 -3
View File
@@ -286,6 +286,9 @@
.live-stat-pill { font-size: 11px; padding: 2px 7px; }
.live-toggles { font-size: 10px; gap: 6px; margin-left: 0; }
.live-title { font-size: 12px; letter-spacing: 1px; }
/* #203 — bottom-sheet node detail on mobile */
.live-node-detail { width: 100%; right: 0; left: 0; top: auto; bottom: 0; max-height: 60vh; border-radius: 16px 16px 0 0; overflow-y: auto; }
.live-node-detail.hidden { transform: translateY(100%); }
.feed-detail-card {
position: fixed !important;
right: 0 !important;
@@ -663,9 +666,9 @@
.vcr-mode { display: none; }
/* Row 2: timeline takes full width */
.vcr-timeline-container { order: 4; width: 100%; flex: none; height: 20px; }
/* Smaller buttons */
.vcr-btn { padding: 4px 8px; font-size: 0.75rem; min-height: 32px; min-width: 32px; }
.vcr-scope-btn { font-size: 0.6rem; padding: 2px 6px; min-height: 28px; }
/* #207 — 44px touch targets for VCR buttons */
.vcr-btn { padding: 4px 8px; font-size: 0.75rem; min-height: 44px; min-width: 44px; }
.vcr-scope-btn { font-size: 0.6rem; padding: 2px 6px; min-height: 44px; min-width: 44px; }
.vcr-prompt { order: 5; width: 100%; font-size: 0.7rem; }
}
+8 -8
View File
@@ -102,27 +102,27 @@
<div class="analytics-chart-card full">
<h4>Activity Timeline</h4>
<div class="analytics-chart-desc">Packet count per time bucket shows when this node is most active</div>
<canvas id="activityChart"></canvas>
<canvas id="activityChart" role="img" aria-label="Activity timeline chart"></canvas>
</div>
<div class="analytics-chart-card">
<h4>SNR Trend</h4>
<div class="analytics-chart-desc">Signal-to-noise ratio over time higher is better reception</div>
<canvas id="snrChart"></canvas>
<canvas id="snrChart" role="img" aria-label="SNR trend chart"></canvas>
</div>
<div class="analytics-chart-card">
<h4>Packet Types</h4>
<div class="analytics-chart-desc">Breakdown of advert, position, text, and other packet types</div>
<canvas id="packetTypeChart"></canvas>
<canvas id="packetTypeChart" role="img" aria-label="Packet types chart"></canvas>
</div>
<div class="analytics-chart-card">
<h4>Observer Coverage</h4>
<div class="analytics-chart-desc">Which stations hear this node and how often</div>
<canvas id="observerChart"></canvas>
<canvas id="observerChart" role="img" aria-label="Observer coverage chart"></canvas>
</div>
<div class="analytics-chart-card">
<h4>Hop Distribution</h4>
<div class="analytics-chart-desc">How many repeater hops packets take 0 means direct</div>
<canvas id="hopChart"></canvas>
<canvas id="hopChart" role="img" aria-label="Hop distribution chart"></canvas>
</div>
<div class="analytics-chart-card full">
<h4>Uptime Heatmap</h4>
@@ -132,14 +132,14 @@
${data.peerInteractions.length ? `<div class="analytics-chart-card full">
<h4>Peer Interactions</h4>
<div class="analytics-chart-desc">Nodes this device has exchanged messages with</div>
<table class="analytics-peer-table">
<thead><tr><th>Peer</th><th>Messages</th><th>Last Contact</th></tr></thead>
<div class="analytics-table-scroll"><table class="analytics-peer-table">
<thead><tr><th scope="col">Peer</th><th scope="col">Messages</th><th scope="col">Last Contact</th></tr></thead>
<tbody>${data.peerInteractions.map(p => `<tr>
<td><a href="#/nodes/${encodeURIComponent(p.peer_key)}" style="color:var(--accent)">${escapeHtml(p.peer_name)}</a></td>
<td>${p.messageCount}</td>
<td>${timeAgo(p.lastContact)}</td>
</tr>`).join('')}</tbody>
</table>
</table></div>
</div>` : ''}
</div>
</div>`;
+7 -7
View File
@@ -287,7 +287,7 @@
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<table class="data-table" style="font-size:12px">
<thead><tr><th>Observer</th><th>Region</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<thead><tr><th scope="col">Observer</th><th scope="col">Region</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Avg RSSI</th></tr></thead>
<tbody>
${observers.map(o => `<tr>
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
@@ -577,11 +577,11 @@
</div>
<table class="data-table" id="nodesTable">
<thead><tr>
<th class="sortable${sortState.column==='name'?' sort-active':''}" data-sort="name">Name${sortArrow('name')}</th>
<th class="sortable${sortState.column==='public_key'?' sort-active':''}" data-sort="public_key">Public Key${sortArrow('public_key')}</th>
<th class="sortable${sortState.column==='role'?' sort-active':''}" data-sort="role">Role${sortArrow('role')}</th>
<th class="sortable${sortState.column==='last_seen'?' sort-active':''}" data-sort="last_seen">Last Seen${sortArrow('last_seen')}</th>
<th class="sortable${sortState.column==='advert_count'?' sort-active':''}" data-sort="advert_count">Adverts${sortArrow('advert_count')}</th>
<th scope="col" class="sortable${sortState.column==='name'?' sort-active':''}" data-sort="name">Name${sortArrow('name')}</th>
<th scope="col" class="col-pubkey sortable${sortState.column==='public_key'?' sort-active':''}" data-sort="public_key">Public Key${sortArrow('public_key')}</th>
<th scope="col" class="sortable${sortState.column==='role'?' sort-active':''}" data-sort="role">Role${sortArrow('role')}</th>
<th scope="col" class="sortable${sortState.column==='last_seen'?' sort-active':''}" data-sort="last_seen">Last Seen${sortArrow('last_seen')}</th>
<th scope="col" class="sortable${sortState.column==='advert_count'?' sort-active':''}" data-sort="advert_count">Adverts${sortArrow('advert_count')}</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
</table>`;
@@ -674,7 +674,7 @@
const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale';
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}${isClaimed ? ' claimed-row' : ''}">
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}</td>
<td class="mono">${truncate(n.public_key, 16)}</td>
<td class="mono col-pubkey">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td class="${lastSeenClass}">${timeAgo(n.last_heard || n.last_seen)}</td>
<td>${n.advert_count || 0}</td>
+15 -6
View File
@@ -157,19 +157,19 @@
<div class="obs-charts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px">
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">Packets Over Time</h3>
<canvas id="obsTimeChart"></canvas>
<canvas id="obsTimeChart" role="img" aria-label="Packets over time chart"></canvas>
</div>
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">Packet Types</h3>
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart"></canvas></div>
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart" role="img" aria-label="Packet types chart"></canvas></div>
</div>
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">Unique Nodes Heard</h3>
<canvas id="obsNodesChart"></canvas>
<canvas id="obsNodesChart" role="img" aria-label="Unique nodes heard chart"></canvas>
</div>
<div class="chart-card" style="padding:12px">
<h3 style="margin:0 0 8px;font-size:0.95em">SNR Distribution</h3>
<canvas id="obsSnrChart"></canvas>
<canvas id="obsSnrChart" role="img" aria-label="SNR distribution chart"></canvas>
</div>
</div>
<div style="margin-top:20px">
@@ -299,12 +299,12 @@
const el = document.getElementById('obsRecentPackets');
if (!el || !packets.length) { if (el) el.innerHTML = '<div class="text-muted">No recent packets.</div>'; return; }
el.innerHTML = `<table class="data-table" style="font-size:0.85em">
<thead><tr><th>Time</th><th>Type</th><th>Hash</th><th>SNR</th><th>RSSI</th><th>Hops</th></tr></thead>
<thead><tr><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Hash</th><th scope="col">SNR</th><th scope="col">RSSI</th><th scope="col">Hops</th></tr></thead>
<tbody>${packets.map(p => {
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
return `<tr style="cursor:pointer" onclick="location.hash='#/packets/${p.hash || p.id}'">
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/${p.hash || p.id}" onclick="location.hash='#/packets/${p.hash || p.id}'">
<td>${timeAgo(p.timestamp)}</td>
<td>${typeName}</td>
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>
@@ -314,6 +314,15 @@
</tr>`;
}).join('')}</tbody>
</table>`;
// #209 — Keyboard accessibility for recent packet rows
el.addEventListener('keydown', function (e) {
var row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
location.hash = row.dataset.value;
});
}
registerPage('observer-detail', { init, destroy });
+13 -3
View File
@@ -25,6 +25,16 @@
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
var row = e.target.closest('tr[data-action="navigate"]');
if (row) location.hash = row.dataset.value;
});
// #209 — Keyboard accessibility for observer rows
app.addEventListener('keydown', function (e) {
var row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
location.hash = row.dataset.value;
});
// Auto-refresh every 30s
refreshTimer = setInterval(loadObservers, 30000);
@@ -113,13 +123,13 @@
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
<thead><tr>
<th>Status</th><th>Name</th><th>Region</th><th>Last Seen</th>
<th>Packets</th><th>Packets/Hour</th><th>Uptime</th>
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Seen</th>
<th scope="col">Packets</th><th scope="col">Packets/Hour</th><th scope="col">Uptime</th>
</tr></thead>
<tbody>${filtered.map(o => {
const h = healthStatus(o.last_seen);
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
<td class="mono">${o.name || o.id}</td>
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
+5 -4
View File
@@ -501,6 +501,7 @@
<div class="filter-group" style="flex:1;margin-bottom:8px">
<input type="text" id="packetFilterInput" class="packet-filter-input"
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
aria-label="Packet filter expression"
style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-family:var(--mono);font-size:13px;background:var(--input-bg);color:var(--text)">
<div id="packetFilterError" style="color:var(--status-red);font-size:11px;margin-top:2px;display:none"></div>
<div id="packetFilterCount" style="color:var(--text-muted);font-size:11px;margin-top:2px;display:none"></div>
@@ -528,7 +529,7 @@
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes"> My Nodes</button>
</div>
<div class="filter-group">
<select id="fTimeWindow" class="filter-select">
<select id="fTimeWindow" class="filter-select" aria-label="Time window filter">
<option value="15">Last 15 min</option>
<option value="30">Last 30 min</option>
<option value="60">Last 1 hour</option>
@@ -559,8 +560,8 @@
</div>
<table class="data-table" id="pktTable">
<thead><tr>
<th></th><th class="col-region">Region</th><th class="col-time">Time</th><th class="col-hash">Hash</th><th class="col-size">Size</th>
<th class="col-type">Type</th><th class="col-observer">Observer</th><th class="col-path">Path</th><th class="col-rpt">Rpt</th><th class="col-details">Details</th>
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
</table>
@@ -1524,7 +1525,7 @@
}
return `<table class="field-table">
<thead><tr><th>Offset</th><th>Field</th><th>Value</th><th>Description</th></tr></thead>
<thead><tr><th scope="col">Offset</th><th scope="col">Field</th><th scope="col">Value</th><th scope="col">Description</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
+4 -4
View File
@@ -5,7 +5,7 @@
let interval = null;
async function render(app) {
app.innerHTML = '<div style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
app.innerHTML = '<div id="perfWrapper" style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
await refresh();
}
@@ -125,7 +125,7 @@
const eps = Object.entries(server.endpoints);
if (eps.length) {
html += '<h3>Server Endpoints (sorted by total time)</h3>';
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Endpoint</th><th>Count</th><th>Avg</th><th>P50</th><th>P95</th><th>Max</th><th>Total</th></tr></thead><tbody>';
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Endpoint</th><th scope="col">Count</th><th scope="col">Avg</th><th scope="col">P50</th><th scope="col">P95</th><th scope="col">Max</th><th scope="col">Total</th></tr></thead><tbody>';
for (const [path, s] of eps) {
const total = Math.round(s.count * s.avgMs);
const cls = s.p95Ms > 200 ? ' class="perf-slow"' : s.p95Ms > 50 ? ' class="perf-warn"' : '';
@@ -137,7 +137,7 @@
// Client API calls
if (client && client.endpoints.length) {
html += '<h3>Client API Calls (this session)</h3>';
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Endpoint</th><th>Count</th><th>Avg</th><th>Max</th><th>Total</th></tr></thead><tbody>';
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Endpoint</th><th scope="col">Count</th><th scope="col">Avg</th><th scope="col">Max</th><th scope="col">Total</th></tr></thead><tbody>';
for (const s of client.endpoints) {
const cls = s.maxMs > 500 ? ' class="perf-slow"' : s.avgMs > 200 ? ' class="perf-warn"' : '';
html += `<tr${cls}><td><code>${s.path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.maxMs}ms</td><td>${s.totalMs}ms</td></tr>`;
@@ -148,7 +148,7 @@
// Slow queries
if (server.slowQueries.length) {
html += '<h3>Recent Slow Queries (&gt;100ms)</h3>';
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Time</th><th>Path</th><th>Duration</th><th>Status</th></tr></thead><tbody>';
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Time</th><th scope="col">Path</th><th scope="col">Duration</th><th scope="col">Status</th></tr></thead><tbody>';
for (const q of server.slowQueries.slice().reverse()) {
html += `<tr class="perf-slow"><td>${new Date(q.time).toLocaleTimeString()}</td><td><code>${q.path}</code></td><td>${q.ms}ms</td><td>${q.status}</td></tr>`;
}
+15 -1
View File
@@ -1205,7 +1205,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Hide low-value columns on mobile */
@media (max-width: 640px) {
.col-region, .col-rpt, .col-size { display: none; }
.col-region, .col-rpt, .col-size, .col-pubkey { display: none; }
}
/* Clickable hop links */
@@ -1389,6 +1389,12 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* #20 — Observers table horizontal scroll on mobile */
.obs-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.obs-table-scroll .obs-table { min-width: 640px; }
/* #206 — Analytics/Compare tables scroll wrappers on mobile */
.analytics-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.analytics-table-scroll .analytics-table,
.analytics-table-scroll .analytics-peer-table,
.analytics-table-scroll .compare-table { min-width: 480px; }
@media (max-width: 640px) {
.spark-bar { min-width: 60px; width: auto; }
}
@@ -1532,6 +1538,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
.perf-table .perf-warn td { color: var(--status-yellow); }
/* #204 — Perf page responsive */
@media (max-width: 640px) {
#perfWrapper { padding: 12px !important; }
.perf-card { min-width: 0; flex: 1 1 calc(50% - 8px); }
.perf-table { font-size: 11px; }
.perf-table th, .perf-table td { padding: 4px 6px; }
}
/* ─── Region filter bar ─── */
.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
.region-filter-container { margin: 0; padding: 0; display: inline-flex; align-items: center; }
+8
View File
@@ -716,6 +716,10 @@ for (const source of mqttSources) {
const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion';
db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now });
if (txResult && txResult.isNew) db.incrementAdvertCount(p.pubKey);
// Update telemetry if present in advert
if (p.battery_mv != null || p.temperature_c != null) {
db.updateNodeTelemetry({ public_key: p.pubKey, battery_mv: p.battery_mv ?? null, temperature_c: p.temperature_c ?? null });
}
// Invalidate this node's caches on advert
cache.invalidate('node:' + p.pubKey);
cache.invalidate('health:' + p.pubKey);
@@ -1057,6 +1061,10 @@ app.post('/api/packets', requireApiKey, (req, res) => {
const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion';
db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now });
if (txResult && txResult.isNew) db.incrementAdvertCount(p.pubKey);
// Update telemetry if present in advert
if (p.battery_mv != null || p.temperature_c != null) {
db.updateNodeTelemetry({ public_key: p.pubKey, battery_mv: p.battery_mv ?? null, temperature_c: p.temperature_c ?? null });
}
} else {
console.warn(`[advert] Skipping corrupted ADVERT (API): ${validation.reason}`);
}
+42
View File
@@ -213,6 +213,48 @@ console.log('── Spec Tests: Advert Payload ──');
assertEq(p.name, undefined, 'advert no name: name undefined');
}
// Telemetry: sensor node with battery + positive temperature
{
const pubkey = 'AA'.repeat(32);
const sig = 'BB'.repeat(64);
const flags = '84'; // sensor(4) | hasName(0x80)
const name = Buffer.from('S1').toString('hex') + '00'; // null-terminated
const battBuf = Buffer.alloc(2); battBuf.writeUInt16LE(3700);
const tempBuf = Buffer.alloc(2); tempBuf.writeInt16LE(2850); // 28.50°C
const hex = '1200' + pubkey + '00000000' + sig + flags + name +
battBuf.toString('hex') + tempBuf.toString('hex');
const p = decodePacket(hex).payload;
assertEq(p.battery_mv, 3700, 'telemetry: battery_mv decoded');
assert(Math.abs(p.temperature_c - 28.50) < 0.01, 'telemetry: temperature_c positive');
}
// Telemetry: sensor node with 0°C must still emit temperature_c
{
const pubkey = 'CC'.repeat(32);
const sig = 'DD'.repeat(64);
const flags = '84'; // sensor(4) | hasName(0x80)
const name = Buffer.from('S2').toString('hex') + '00';
const battBuf = Buffer.alloc(2); battBuf.writeUInt16LE(3600);
const tempBuf = Buffer.alloc(2); // 0°C
const hex = '1200' + pubkey + '00000000' + sig + flags + name +
battBuf.toString('hex') + tempBuf.toString('hex');
const p = decodePacket(hex).payload;
assert(p.temperature_c === 0, 'telemetry: 0°C is valid and emitted');
}
// Telemetry: non-sensor node with trailing bytes must NOT decode telemetry
{
const pubkey = 'EE'.repeat(32);
const sig = 'FF'.repeat(64);
const flags = '82'; // repeater(2) | hasName(0x80)
const name = Buffer.from('R1').toString('hex') + '00';
const extraBytes = 'B40ED403'; // battery-like and temp-like bytes
const hex = '1200' + pubkey + '00000000' + sig + flags + name + extraBytes;
const p = decodePacket(hex).payload;
assertEq(p.battery_mv, undefined, 'telemetry: non-sensor node: battery_mv must be undefined');
assertEq(p.temperature_c, undefined, 'telemetry: non-sensor node: temperature_c must be undefined');
}
console.log('── Spec Tests: Encrypted Payload Format ──');
// NOTE: Spec says v1 encrypted payloads have dest(1) + src(1) + MAC(2) + ciphertext