mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-12 13:09:44 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a827fd3b43 | ||
|
|
54cbc648e0 | ||
|
|
aba4270ceb | ||
|
|
57b0188158 | ||
|
|
f374a4a775 | ||
|
|
6d31cb2ad6 | ||
|
|
1619f4857e | ||
|
|
58d19ec303 | ||
|
|
331dc0090e | ||
|
|
cef8156a86 | ||
|
|
9751141ffc | ||
|
|
9c5ffbfb0c | ||
|
|
3361643bc0 | ||
|
|
f04f1b8e77 | ||
|
|
447c5d7073 | ||
|
|
aa2e8ed420 |
+22
-298
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# MeshCore Analyzer
|
||||
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/meshcore-analyzer/actions/workflows/deploy.yml)
|
||||
|
||||
+614
-422
File diff suppressed because it is too large
Load Diff
+599
-2
@@ -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, ¬Null, &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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (0–100%)</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 (0–100%)</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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (>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
@@ -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; }
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user