Compare commits

..

2 Commits

Author SHA1 Message Date
Kpa-clawbot
0c1c0b4849 fix: remove networkidle waits and timeout bumps that regressed #248 speed
PR #248 specifically removed networkidle waits and kept tight timeouts
for speed in an SPA where they're unnecessary. This commit removes the
networkidle and timeout increases added in the previous commit while
keeping the actual flakiness fixes:

Kept:
- Deterministic seed data script (tools/seed-test-data.js)
- Retry logic (1 retry per test)
- Fresh navigation for groupByHash test
- Compare page explicit waitForFunction/waitForSelector waits
- Observers page waitForFunction for table rows

Reverted:
- Default timeout 10s→15s (back to 10s)
- All added networkidle waits (6 instances)
- Map marker timeout 3s→8s (back to 3s)
- Node detail timeout bumps
- Nodes page timeout bumps

Principle: fix flakiness with deterministic data and targeted waits,
not with slower timeouts and networkidle.
2026-03-29 16:57:52 +00:00
Kpa-clawbot
b47571c7f0 fix: make E2E Playwright tests more resilient to flaky CI
Key changes:
- Add retry logic (1 retry) to test runner for transient failures
- Increase default timeout from 10s to 15s for slow CI runners
- Add networkidle waits after navigation on nodes, map, packets,
  channels, and observers pages
- Add explicit waitForSelector timeouts on node detail, map markers,
  and observer table rows
- Fresh page navigation for groupByHash test to avoid stale state
- Wait for compare results to render before asserting on cards
- Increase map marker wait timeout from 3s to 8s
- Add seed-test-data.js to CI pipeline to ensure deterministic test
  data exists (nodes with locations, packets, channels, observers)
- Seed data creates 8 nodes, 3 observers, channel messages, and
  various packet types for full page coverage
2026-03-29 16:53:12 +00:00
31 changed files with 766 additions and 574 deletions

View File

@@ -0,0 +1 @@
{"schemaVersion":1,"label":"backend coverage","message":"87.79%","color":"brightgreen"}

View File

@@ -0,0 +1 @@
{"schemaVersion":1,"label":"backend tests","message":"998 passed","color":"brightgreen"}

1
.badges/coverage.json Normal file
View File

@@ -0,0 +1 @@
{"schemaVersion":1,"label":"coverage","message":"76%","color":"yellow"}

View File

@@ -1 +0,0 @@
{"schemaVersion":1,"label":"e2e tests","message":"pending","color":"lightgrey"}

View File

@@ -1 +0,0 @@
{"schemaVersion":1,"label":"go ingestor coverage","message":"pending","color":"lightgrey"}

View File

@@ -1 +0,0 @@
{"schemaVersion":1,"label":"go server coverage","message":"pending","color":"lightgrey"}

1
.badges/tests.json Normal file
View File

@@ -0,0 +1 @@
{"schemaVersion":1,"label":"tests","message":"844/844 passed","color":"brightgreen"}

View File

@@ -1,28 +1,35 @@
name: CI/CD Pipeline
name: Deploy
on:
push:
branches: [master]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.gitignore'
- 'docs/**'
pull_request:
branches: [master]
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
group: deploy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
STAGING_COMPOSE_FILE: docker-compose.staging.yml
STAGING_SERVICE: staging-go
STAGING_CONTAINER: corescope-staging-go
# Pipeline (sequential, fail-fast):
# go-test → e2e-test → build → deploy → publish
# PRs stop after build. Master continues to deploy + publish.
# 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 job: capture fresh fixtures from prod, validate protos match actual API responses
jobs:
# ───────────────────────────────────────────────────────────────
# 1. Go Build & Test
# 1. Go Build & Test — compiles + tests Go modules, coverage badges
# ───────────────────────────────────────────────────────────────
go-test:
name: "✅ Go Build & Test"
@@ -33,10 +40,20 @@ jobs:
with:
fetch-depth: 0
- name: Clean Go module cache
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
- name: Skip if docs-only change
id: docs-check
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
NON_DOCS=$(echo "$CHANGED" | grep -cvE '\.(md)$|^LICENSE$|^\.gitignore$|^docs/' || true)
if [ "$NON_DOCS" -eq 0 ]; then
echo "docs_only=true" >> $GITHUB_OUTPUT
echo "📄 Docs-only PR — skipping heavy CI"
fi
fi
- name: Set up Go 1.22
if: steps.docs-check.outputs.docs_only != 'true'
uses: actions/setup-go@v6
with:
go-version: '1.22'
@@ -45,6 +62,7 @@ jobs:
cmd/ingestor/go.sum
- name: Build and test Go server (with coverage)
if: steps.docs-check.outputs.docs_only != 'true'
run: |
set -e -o pipefail
cd cmd/server
@@ -54,6 +72,7 @@ jobs:
go tool cover -func=server-coverage.out | tail -1
- name: Build and test Go ingestor (with coverage)
if: steps.docs-check.outputs.docs_only != 'true'
run: |
set -e -o pipefail
cd cmd/ingestor
@@ -62,11 +81,15 @@ jobs:
echo "--- Go Ingestor Coverage ---"
go tool cover -func=ingestor-coverage.out | tail -1
- name: Verify proto syntax
- name: Verify proto syntax (all .proto files compile)
if: steps.docs-check.outputs.docs_only != 'true'
run: |
set -e
echo "Installing protoc..."
sudo apt-get update -qq
sudo apt-get install -y protobuf-compiler
echo "Checking proto syntax..."
for proto in proto/*.proto; do
echo " ✓ $(basename "$proto")"
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
@@ -74,27 +97,37 @@ jobs:
echo "✅ All .proto files are syntactically valid"
- name: Generate Go coverage badges
if: success()
if: always() && steps.docs-check.outputs.docs_only != 'true'
run: |
mkdir -p .badges
# Parse server coverage
SERVER_COV="0"
if [ -f cmd/server/server-coverage.out ]; then
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
fi
SERVER_COLOR="red"
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green"
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
SERVER_COLOR="green"
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
SERVER_COLOR="yellow"
fi
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
echo "Go server coverage: ${SERVER_COV}% (${SERVER_COLOR})"
# Parse ingestor coverage
INGESTOR_COV="0"
if [ -f cmd/ingestor/ingestor-coverage.out ]; then
INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
fi
INGESTOR_COLOR="red"
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green"
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
INGESTOR_COLOR="green"
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
INGESTOR_COLOR="yellow"
fi
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
echo "Go ingestor coverage: ${INGESTOR_COV}% (${INGESTOR_COLOR})"
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
@@ -102,9 +135,16 @@ jobs:
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
- name: Cancel workflow on failure
if: failure()
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
- name: Upload Go coverage badges
if: success()
uses: actions/upload-artifact@v6
if: always()
uses: actions/upload-artifact@v5
with:
name: go-badges
path: .badges/go-*.json
@@ -112,11 +152,10 @@ jobs:
if-no-files-found: ignore
# ───────────────────────────────────────────────────────────────
# 2. Playwright E2E Tests (against Go server with fixture DB)
# 2. Node.js Tests — backend unit tests + Playwright E2E, coverage
# ───────────────────────────────────────────────────────────────
e2e-test:
name: "🎭 Playwright E2E Tests"
needs: [go-test]
node-test:
name: "🧪 Node.js Tests"
runs-on: [self-hosted, Linux]
defaults:
run:
@@ -127,43 +166,88 @@ jobs:
with:
fetch-depth: 0
- name: Skip if docs-only change
id: docs-check
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
NON_DOCS=$(echo "$CHANGED" | grep -cvE '\.(md)$|^LICENSE$|^\.gitignore$|^docs/' || true)
if [ "$NON_DOCS" -eq 0 ]; then
echo "docs_only=true" >> $GITHUB_OUTPUT
echo "📄 Docs-only PR — skipping heavy CI"
fi
fi
- name: Set up Node.js 22
if: steps.docs-check.outputs.docs_only != 'true'
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Clean Go module cache
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
- name: Set up Go 1.22
uses: actions/setup-go@v6
with:
go-version: '1.22'
cache-dependency-path: cmd/server/go.sum
- name: Build Go server
run: |
cd cmd/server
go build -o ../../corescope-server .
echo "Go server built successfully"
- name: Install npm dependencies
if: steps.docs-check.outputs.docs_only != 'true'
run: npm ci --production=false
- name: Install Playwright browser
- name: Detect changed files
if: steps.docs-check.outputs.docs_only != 'true'
id: changes
run: |
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
FRONTEND=$(git diff --name-only HEAD~1 | grep -cE '^public/' || true)
TESTS=$(git diff --name-only HEAD~1 | grep -cE '^test-|^tools/' || true)
CI=$(git diff --name-only HEAD~1 | grep -cE '\.github/|package\.json|test-all\.sh|scripts/' || true)
# If CI/test infra changed, run everything
if [ "$CI" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
# If test files changed, run everything
if [ "$TESTS" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
echo "backend=$([[ $BACKEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
echo "frontend=$([[ $FRONTEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
- name: Run backend tests with coverage
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.backend == 'true'
run: |
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
TOTAL_PASS=$(grep -oP '\d+(?= passed)' test-output.txt | awk '{s+=$1} END {print s}')
TOTAL_FAIL=$(grep -oP '\d+(?= failed)' test-output.txt | awk '{s+=$1} END {print s}')
BE_COVERAGE=$(grep 'Statements' test-output.txt | tail -1 | grep -oP '[\d.]+(?=%)')
mkdir -p .badges
BE_COLOR="red"
[ "$(echo "$BE_COVERAGE > 60" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="yellow"
[ "$(echo "$BE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="brightgreen"
echo "{\"schemaVersion\":1,\"label\":\"backend tests\",\"message\":\"${TOTAL_PASS} passed\",\"color\":\"brightgreen\"}" > .badges/backend-tests.json
echo "{\"schemaVersion\":1,\"label\":\"backend coverage\",\"message\":\"${BE_COVERAGE}%\",\"color\":\"${BE_COLOR}\"}" > .badges/backend-coverage.json
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
- name: Run backend tests (quick, no coverage)
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.backend == 'false'
run: npm run test:unit
- name: Install Playwright browser
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: |
# Install chromium (skips download if already cached on self-hosted runner)
npx playwright install chromium 2>/dev/null || true
# Install system deps only if missing (apt-get is slow)
npx playwright install-deps chromium 2>/dev/null || true
- name: Instrument frontend JS for coverage
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: sh scripts/instrument-frontend.sh
- name: Start Go server with fixture DB
- name: Start instrumented test server on port 13581
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: |
# Kill any stale server on 13581
fuser -k 13581/tcp 2>/dev/null || true
sleep 1
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented &
sleep 2
COVERAGE=1 PORT=13581 node server.js &
echo $! > .server.pid
echo "Server PID: $(cat .server.pid)"
# Health-check poll loop (up to 30s)
for i in $(seq 1 30); do
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
echo "Server ready after ${i}s"
@@ -171,27 +255,42 @@ jobs:
fi
if [ "$i" -eq 30 ]; then
echo "Server failed to start within 30s"
echo "Last few lines from server logs:"
ps aux | grep "PORT=13581" || echo "No server process found"
exit 1
fi
sleep 1
done
- name: Run Playwright E2E tests (fail-fast)
run: |
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
- name: Seed test data for Playwright
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: BASE_URL=http://localhost:13581 node tools/seed-test-data.js
# DISABLED: Coverage collector takes 8+ min. E2E tests extract window.__coverage__ directly.
# - name: Collect frontend coverage
# if: success()
# run: |
# BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true
- name: Run Playwright E2E + coverage collection concurrently
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: |
# Run E2E tests and coverage collection in parallel — both use the same server
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt &
E2E_PID=$!
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt &
COV_PID=$!
# Wait for both — E2E must pass, coverage is best-effort
E2E_EXIT=0
wait $E2E_PID || E2E_EXIT=$?
wait $COV_PID || true
# Fail if E2E failed
[ $E2E_EXIT -ne 0 ] && exit $E2E_EXIT
true
- name: Generate frontend coverage badges
if: success()
if: always() && steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: |
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
mkdir -p .badges
# Merge E2E + coverage collector data if both exist
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
@@ -202,21 +301,44 @@ jobs:
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
fi
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
- name: Stop test server
if: success()
if: always() && steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
run: |
if [ -f .server.pid ]; then
kill $(cat .server.pid) 2>/dev/null || true
rm -f .server.pid
echo "Server stopped"
fi
- name: Upload E2E badges
if: success()
uses: actions/upload-artifact@v6
- name: Run frontend E2E (quick, no coverage)
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'false'
run: |
fuser -k 13581/tcp 2>/dev/null || true
PORT=13581 node server.js &
SERVER_PID=$!
# Wait for server to be ready (up to 15s)
for i in $(seq 1 15); do
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
sleep 1
done
BASE_URL=http://localhost:13581 node tools/seed-test-data.js || true
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
kill $SERVER_PID 2>/dev/null || true
- name: Cancel workflow on failure
if: failure()
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
- name: Upload Node.js test badges
if: always()
uses: actions/upload-artifact@v5
with:
name: e2e-badges
name: node-badges
path: .badges/
retention-days: 1
if-no-files-found: ignore
@@ -226,7 +348,8 @@ jobs:
# ───────────────────────────────────────────────────────────────
build:
name: "🏗️ Build Docker Image"
needs: [e2e-test]
if: github.event_name == 'push'
needs: [go-test, node-test]
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
@@ -242,15 +365,11 @@ jobs:
echo "${GITHUB_SHA::7}" > .git-commit
APP_VERSION=$(node -p "require('./package.json').version") \
GIT_COMMIT="${GITHUB_SHA::7}" \
APP_VERSION=$(grep -oP 'APP_VERSION:-\K[^}]+' docker-compose.yml | head -1 || echo "3.0.0")
GIT_COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
export APP_VERSION GIT_COMMIT BUILD_TIME
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE"
echo "Built Go staging image ✅"
docker compose --profile staging-go build staging-go
echo "Built Go staging image"
# ───────────────────────────────────────────────────────────────
# 4. Deploy Staging (master only)
# 4. Deploy Staging — start on port 82, healthcheck, smoke test
# ───────────────────────────────────────────────────────────────
deploy:
name: "🚀 Deploy Staging"
@@ -261,8 +380,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Deploy staging
run: ./manage.sh restart staging
- name: Start staging on port 82
run: |
# Force remove stale containers
docker rm -f corescope-staging-go 2>/dev/null || true
# Clean up stale ports
fuser -k 82/tcp 2>/dev/null || true
docker compose --profile staging-go up -d staging-go
- name: Healthcheck staging container
run: |
@@ -290,7 +414,7 @@ jobs:
fi
# ───────────────────────────────────────────────────────────────
# 5. Publish Badges & Summary (master only)
# 5. Publish Badges & Summary
# ───────────────────────────────────────────────────────────────
publish:
name: "📝 Publish Badges & Summary"
@@ -303,16 +427,16 @@ jobs:
- name: Download Go coverage badges
continue-on-error: true
uses: actions/download-artifact@v6
uses: actions/download-artifact@v5
with:
name: go-badges
path: .badges/
- name: Download E2E badges
- name: Download Node.js test badges
continue-on-error: true
uses: actions/download-artifact@v6
uses: actions/download-artifact@v5
with:
name: e2e-badges
name: node-badges
path: .badges/
- name: Publish coverage badges to repo
@@ -329,3 +453,12 @@ jobs:
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
echo "" >> $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
echo "ssh deploy@\$VM_HOST" >> $GITHUB_STEP_SUMMARY
echo "cd /opt/corescope-deploy" >> $GITHUB_STEP_SUMMARY
echo "./manage.sh promote" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

1
.gitignore vendored
View File

@@ -28,4 +28,3 @@ reps.txt
cmd/server/server.exe
cmd/ingestor/ingestor.exe
# CI trigger
!test-fixtures/e2e-fixture.db

View File

@@ -1,10 +0,0 @@
{
"include": [
"public/*.js"
],
"exclude": [
"public/vendor/**",
"public/leaflet-*.js",
"public/qrcode*.js"
]
}

View File

@@ -1,10 +1,10 @@
# CoreScope
[![Go Server Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/CoreScope/master/.badges/go-server-coverage.json)](https://github.com/Kpa-clawbot/CoreScope/actions/workflows/deploy.yml)
[![Go Ingestor Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/CoreScope/master/.badges/go-ingestor-coverage.json)](https://github.com/Kpa-clawbot/CoreScope/actions/workflows/deploy.yml)
[![E2E Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/CoreScope/master/.badges/e2e-tests.json)](https://github.com/Kpa-clawbot/CoreScope/actions/workflows/deploy.yml)
[![Frontend Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/CoreScope/master/.badges/frontend-coverage.json)](https://github.com/Kpa-clawbot/CoreScope/actions/workflows/deploy.yml)
[![Deploy](https://github.com/Kpa-clawbot/CoreScope/actions/workflows/deploy.yml/badge.svg)](https://github.com/Kpa-clawbot/CoreScope/actions/workflows/deploy.yml)
[![Go Server Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/.badges/go-server-coverage.json)](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
[![Go Ingestor Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/.badges/go-ingestor-coverage.json)](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
[![Frontend Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/.badges/frontend-tests.json)](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
[![Frontend Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/.badges/frontend-coverage.json)](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
[![Deploy](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml/badge.svg)](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
> High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.
@@ -79,7 +79,7 @@ Full experience on your phone — proper touch controls, iOS safe area support,
No Go installation needed — everything builds inside the container.
```bash
git clone https://github.com/Kpa-clawbot/CoreScope.git
git clone https://github.com/Kpa-clawbot/corescope.git
cd corescope
./manage.sh setup
```

View File

@@ -60,10 +60,10 @@ func (c *Config) NodeDaysOrDefault() int {
}
type HealthThresholds struct {
InfraDegradedHours float64 `json:"infraDegradedHours"`
InfraSilentHours float64 `json:"infraSilentHours"`
NodeDegradedHours float64 `json:"nodeDegradedHours"`
NodeSilentHours float64 `json:"nodeSilentHours"`
InfraDegradedMs int `json:"infraDegradedMs"`
InfraSilentMs int `json:"infraSilentMs"`
NodeDegradedMs int `json:"nodeDegradedMs"`
NodeSilentMs int `json:"nodeSilentMs"`
}
// ThemeFile mirrors theme.json overlay.
@@ -126,46 +126,34 @@ func LoadTheme(baseDirs ...string) *ThemeFile {
func (c *Config) GetHealthThresholds() HealthThresholds {
h := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
InfraDegradedMs: 86400000,
InfraSilentMs: 259200000,
NodeDegradedMs: 3600000,
NodeSilentMs: 86400000,
}
if c.HealthThresholds != nil {
if c.HealthThresholds.InfraDegradedHours > 0 {
h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours
if c.HealthThresholds.InfraDegradedMs > 0 {
h.InfraDegradedMs = c.HealthThresholds.InfraDegradedMs
}
if c.HealthThresholds.InfraSilentHours > 0 {
h.InfraSilentHours = c.HealthThresholds.InfraSilentHours
if c.HealthThresholds.InfraSilentMs > 0 {
h.InfraSilentMs = c.HealthThresholds.InfraSilentMs
}
if c.HealthThresholds.NodeDegradedHours > 0 {
h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours
if c.HealthThresholds.NodeDegradedMs > 0 {
h.NodeDegradedMs = c.HealthThresholds.NodeDegradedMs
}
if c.HealthThresholds.NodeSilentHours > 0 {
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
if c.HealthThresholds.NodeSilentMs > 0 {
h.NodeSilentMs = c.HealthThresholds.NodeSilentMs
}
}
return h
}
// GetHealthMs returns degraded/silent thresholds in ms for a given role.
// GetHealthMs returns degraded/silent thresholds for a given role.
func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) {
const hourMs = 3600000
if role == "repeater" || role == "room" {
return int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs)
}
return int(h.NodeDegradedHours * hourMs), int(h.NodeSilentHours * hourMs)
}
// ToClientMs returns the thresholds as ms for the frontend.
func (h HealthThresholds) ToClientMs() map[string]int {
const hourMs = 3600000
return map[string]int{
"infraDegradedMs": int(h.InfraDegradedHours * hourMs),
"infraSilentMs": int(h.InfraSilentHours * hourMs),
"nodeDegradedMs": int(h.NodeDegradedHours * hourMs),
"nodeSilentMs": int(h.NodeSilentHours * hourMs),
return h.InfraDegradedMs, h.InfraSilentMs
}
return h.NodeDegradedMs, h.NodeSilentMs
}
func (c *Config) ResolveDBPath(baseDir string) string {

View File

@@ -23,10 +23,10 @@ func TestLoadConfigValidJSON(t *testing.T) {
"SJC": "San Jose",
},
"healthThresholds": map[string]interface{}{
"infraDegradedHours": 2,
"infraSilentHours": 4,
"nodeDegradedHours": 0.5,
"nodeSilentHours": 2,
"infraDegradedMs": 100000,
"infraSilentMs": 200000,
"nodeDegradedMs": 50000,
"nodeSilentMs": 100000,
},
"liveMap": map[string]interface{}{
"propagationBufferMs": 3000,
@@ -178,68 +178,68 @@ func TestGetHealthThresholdsDefaults(t *testing.T) {
cfg := &Config{}
ht := cfg.GetHealthThresholds()
if ht.InfraDegradedHours != 24 {
t.Errorf("expected 24, got %v", ht.InfraDegradedHours)
if ht.InfraDegradedMs != 86400000 {
t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs)
}
if ht.InfraSilentHours != 72 {
t.Errorf("expected 72, got %v", ht.InfraSilentHours)
if ht.InfraSilentMs != 259200000 {
t.Errorf("expected 259200000, got %d", ht.InfraSilentMs)
}
if ht.NodeDegradedHours != 1 {
t.Errorf("expected 1, got %v", ht.NodeDegradedHours)
if ht.NodeDegradedMs != 3600000 {
t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs)
}
if ht.NodeSilentHours != 24 {
t.Errorf("expected 24, got %v", ht.NodeSilentHours)
if ht.NodeSilentMs != 86400000 {
t.Errorf("expected 86400000, got %d", ht.NodeSilentMs)
}
}
func TestGetHealthThresholdsCustom(t *testing.T) {
cfg := &Config{
HealthThresholds: &HealthThresholds{
InfraDegradedHours: 2,
InfraSilentHours: 4,
NodeDegradedHours: 0.5,
NodeSilentHours: 2,
InfraDegradedMs: 100000,
InfraSilentMs: 200000,
NodeDegradedMs: 50000,
NodeSilentMs: 100000,
},
}
ht := cfg.GetHealthThresholds()
if ht.InfraDegradedHours != 2 {
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
if ht.InfraDegradedMs != 100000 {
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
}
if ht.InfraSilentHours != 4 {
t.Errorf("expected 4, got %v", ht.InfraSilentHours)
if ht.InfraSilentMs != 200000 {
t.Errorf("expected 200000, got %d", ht.InfraSilentMs)
}
if ht.NodeDegradedHours != 0.5 {
t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours)
if ht.NodeDegradedMs != 50000 {
t.Errorf("expected 50000, got %d", ht.NodeDegradedMs)
}
if ht.NodeSilentHours != 2 {
t.Errorf("expected 2, got %v", ht.NodeSilentHours)
if ht.NodeSilentMs != 100000 {
t.Errorf("expected 100000, got %d", ht.NodeSilentMs)
}
}
func TestGetHealthThresholdsPartialCustom(t *testing.T) {
cfg := &Config{
HealthThresholds: &HealthThresholds{
InfraDegradedHours: 2,
InfraDegradedMs: 100000,
// Others left as zero → should use defaults
},
}
ht := cfg.GetHealthThresholds()
if ht.InfraDegradedHours != 2 {
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
if ht.InfraDegradedMs != 100000 {
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
}
if ht.InfraSilentHours != 72 {
t.Errorf("expected default 72, got %v", ht.InfraSilentHours)
if ht.InfraSilentMs != 259200000 {
t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs)
}
}
func TestGetHealthMs(t *testing.T) {
ht := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
InfraDegradedMs: 86400000,
InfraSilentMs: 259200000,
NodeDegradedMs: 3600000,
NodeSilentMs: 86400000,
}
tests := []struct {

View File

@@ -513,10 +513,10 @@ func TestGetNetworkStatus(t *testing.T) {
seedTestData(t, db)
ht := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
InfraDegradedMs: 86400000,
InfraSilentMs: 259200000,
NodeDegradedMs: 3600000,
NodeSilentMs: 86400000,
}
result, err := db.GetNetworkStatus(ht)
if err != nil {
@@ -1050,10 +1050,10 @@ func TestGetNetworkStatusDateFormats(t *testing.T) {
VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`)
ht := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
InfraDegradedMs: 86400000,
InfraSilentMs: 259200000,
NodeDegradedMs: 3600000,
NodeSilentMs: 86400000,
}
result, err := db.GetNetworkStatus(ht)
if err != nil {

View File

@@ -213,7 +213,7 @@ func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
writeJSON(w, ClientConfigResponse{
Roles: s.cfg.Roles,
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
HealthThresholds: s.cfg.HealthThresholds,
Tiles: s.cfg.Tiles,
SnrThresholds: s.cfg.SnrThresholds,
DistThresholds: s.cfg.DistThresholds,

View File

@@ -98,13 +98,6 @@
"#bookclub",
"#shtf"
],
"healthThresholds": {
"infraDegradedHours": 24,
"infraSilentHours": 72,
"nodeDegradedHours": 1,
"nodeSilentHours": 24,
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others."
},
"defaultRegion": "SJC",
"mapDefaults": {
"center": [

View File

@@ -1,38 +0,0 @@
# Staging-only compose file. Production is managed by docker-compose.yml.
# Override defaults via .env or environment variables.
services:
staging-go:
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-unknown}
GIT_COMMIT: ${GIT_COMMIT:-unknown}
BUILD_TIME: ${BUILD_TIME:-unknown}
image: corescope-go:latest
container_name: corescope-staging-go
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
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
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
interval: 30s
timeout: 5s
retries: 3
volumes:
# Named volume for Caddy TLS certificates (not user data — managed by Caddy internally)
caddy-data-staging-go:

View File

@@ -1,38 +1,92 @@
# All container config lives here. manage.sh is just a wrapper around docker compose.
# Override defaults via .env or environment variables.
# CRITICAL: All data mounts use bind mounts (~/path), NOT named volumes.
# This ensures the DB and theme are visible on the host filesystem for backup.
services:
prod:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-unknown}
GIT_COMMIT: ${GIT_COMMIT:-unknown}
BUILD_TIME: ${BUILD_TIME:-unknown}
image: corescope:latest
container_name: corescope-prod
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
- "${PROD_MQTT_PORT:-1883}:1883"
volumes:
- ./config.json:/app/config.json:ro
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
- caddy-data:/data/caddy
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
interval: 30s
timeout: 5s
retries: 3
volumes:
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
# All container config lives here. manage.sh is just a wrapper around docker compose.
# Override defaults via .env or environment variables.
# CRITICAL: All data mounts use bind mounts (~/path), NOT named volumes.
# This ensures the DB and theme are visible on the host filesystem for backup.
services:
prod:
build: .
image: corescope:latest
container_name: corescope-prod
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
- "${PROD_MQTT_PORT:-1883}:1883"
volumes:
- ./config.json:/app/config.json:ro
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
- caddy-data:/data/caddy
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
interval: 30s
timeout: 5s
retries: 3
staging:
build: .
image: corescope:latest
container_name: corescope-staging
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${STAGING_HTTP_PORT:-81}:${STAGING_HTTP_PORT:-81}"
- "${STAGING_MQTT_PORT:-1884}:1883"
volumes:
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/config.json:/app/config.json:ro
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/Caddyfile:/etc/caddy/Caddyfile:ro
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
- caddy-data-staging:/data/caddy
environment:
- NODE_ENV=staging
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
interval: 30s
timeout: 5s
retries: 3
profiles:
- staging
staging-go:
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-unknown}
GIT_COMMIT: ${GIT_COMMIT:-unknown}
image: corescope-go:latest
container_name: corescope-staging-go
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
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
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
interval: 30s
timeout: 5s
retries: 3
profiles:
- staging-go
volumes:
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
caddy-data:
caddy-data-staging:
caddy-data-staging-go:

View File

@@ -18,12 +18,6 @@ STATE_FILE=".setup-state"
# Resolved paths for prod/staging data (must match docker-compose.yml)
PROD_DATA="${PROD_DATA_DIR:-$HOME/meshcore-data}"
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
STAGING_COMPOSE_FILE="docker-compose.staging.yml"
# Build metadata — exported so docker compose build picks them up via args
export APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Colors
RED='\033[0;31m'
@@ -392,7 +386,7 @@ prepare_staging_db() {
# Copy config.prod.json → config.staging.json with siteName change
prepare_staging_config() {
local prod_config="./config.json"
local prod_config="$PROD_DATA/config.json"
local staging_config="$STAGING_DATA/config.json"
if [ ! -f "$prod_config" ]; then
warn "No config.json found at ${prod_config} — staging may not start correctly."
@@ -441,11 +435,10 @@ cmd_start() {
prepare_staging_config
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
info "Starting staging container (corescope-staging-go) on port ${STAGING_GO_HTTP_PORT:-82}..."
docker compose up -d prod
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
info "Starting staging container (corescope-staging) on port ${STAGING_HTTP_PORT:-81}..."
docker compose --profile staging up -d
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
log "Staging started on port ${STAGING_GO_HTTP_PORT:-82} (MQTT: ${STAGING_GO_MQTT_PORT:-1885})"
log "Staging started on port ${STAGING_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
else
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
docker compose up -d prod
@@ -463,16 +456,13 @@ cmd_stop() {
log "Production stopped."
;;
staging)
info "Stopping staging container (corescope-staging-go)..."
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging rm -sf staging-go 2>/dev/null || true
docker rm -f corescope-staging-go meshcore-staging-go corescope-staging meshcore-staging 2>/dev/null || true
log "Staging stopped and cleaned up."
info "Stopping staging container (corescope-staging)..."
docker compose --profile staging stop staging
log "Staging stopped."
;;
all)
info "Stopping all containers..."
docker compose stop prod
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging rm -sf staging-go 2>/dev/null || true
docker rm -f corescope-staging-go meshcore-staging-go corescope-staging meshcore-staging 2>/dev/null || true
docker compose --profile staging --profile staging-go down
log "All containers stopped."
;;
*)
@@ -491,18 +481,13 @@ cmd_restart() {
log "Production restarted."
;;
staging)
info "Restarting staging container (corescope-staging-go)..."
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging rm -sf staging-go 2>/dev/null || true
docker rm -f corescope-staging-go 2>/dev/null || true
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
info "Restarting staging container (corescope-staging)..."
docker compose --profile staging up -d --force-recreate staging
log "Staging restarted."
;;
all)
info "Restarting all containers..."
docker compose up -d --force-recreate prod
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging rm -sf staging-go 2>/dev/null || true
docker rm -f corescope-staging-go 2>/dev/null || true
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
docker compose --profile staging up -d --force-recreate
log "All containers restarted."
;;
*)
@@ -554,10 +539,10 @@ cmd_status() {
echo ""
# Staging
if container_running "corescope-staging-go"; then
show_container_status "corescope-staging-go" "Staging"
if container_running "corescope-staging"; then
show_container_status "corescope-staging" "Staging"
else
info "Staging (corescope-staging-go): Not running (use --with-staging to start both)"
info "Staging (corescope-staging): Not running (use --with-staging to start both)"
fi
echo ""
@@ -589,7 +574,7 @@ cmd_logs() {
staging)
if container_running "corescope-staging"; then
info "Tailing staging logs..."
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging logs -f --tail="$LINES" staging-go
docker compose logs -f --tail="$LINES" staging
else
err "Staging container is not running."
info "Start with: ./manage.sh start --with-staging"
@@ -617,7 +602,7 @@ cmd_promote() {
# Show what's currently running
local staging_image staging_created prod_image prod_created
staging_image=$(docker inspect corescope-staging-go --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
staging_image=$(docker inspect corescope-staging --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
staging_created=$(docker inspect corescope-staging --format '{{.Created}}' 2>/dev/null || echo "N/A")
prod_image=$(docker inspect corescope-prod --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
prod_created=$(docker inspect corescope-prod --format '{{.Created}}' 2>/dev/null || echo "N/A")
@@ -863,8 +848,7 @@ cmd_reset() {
exit 0
fi
docker compose down --rmi local 2>/dev/null || true
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --rmi local 2>/dev/null || true
docker compose --profile staging --profile staging-go down --rmi local 2>/dev/null || true
rm -f "$STATE_FILE"
log "Reset complete. Run './manage.sh setup' to start over."
@@ -885,7 +869,7 @@ cmd_help() {
echo ""
printf '%b\n' " ${BOLD}Run${NC}"
echo " start Start production container"
echo " start --with-staging Start production + staging-go (copies prod DB + config)"
echo " start --with-staging Start production + staging (copies prod DB + config)"
echo " stop [prod|staging|all] Stop specific or all containers (default: all)"
echo " restart [prod|staging|all] Restart specific or all containers"
echo " status Show health, stats, and service status"
@@ -898,7 +882,7 @@ cmd_help() {
echo " restore <d> Restore from backup dir or .db file"
echo " mqtt-test Check if MQTT data is flowing"
echo ""
echo "Prod uses docker-compose.yml; staging uses ${STAGING_COMPOSE_FILE}."
echo "All commands use docker compose with docker-compose.yml."
echo ""
}

View File

@@ -876,26 +876,6 @@
</div>`;
}).join('')}
</div>
${data.distributionByRepeaters ? (() => {
const dr = data.distributionByRepeaters;
const totalRepeaters = (dr[1] || 0) + (dr[2] || 0) + (dr[3] || 0);
const rpct = (n) => totalRepeaters ? (n / totalRepeaters * 100).toFixed(1) : '0';
const maxRepeaters = Math.max(dr[1] || 0, dr[2] || 0, dr[3] || 0, 1);
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
return `<h4 style="margin:16px 0 4px">By Repeaters</h4>
<p class="text-muted">${totalRepeaters.toLocaleString()} unique repeaters</p>
<div class="hash-bars">
${[1, 2, 3].map(size => {
const count = dr[size] || 0;
const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0);
return `<div class="hash-bar-row">
<div class="hash-bar-label"><strong>${size}-byte</strong></div>
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${width}%;background:${colors[size]};opacity:0.7"></div></div>
<div class="hash-bar-value">${count.toLocaleString()} <span class="text-muted">(${rpct(count)}%)</span></div>
</div>`;
}).join('')}
</div>`;
})() : ''}
</div>
<div class="analytics-card flex-1">
<h3>📈 Hash Size Over Time</h3>

View File

@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774821783">
<link rel="stylesheet" href="home.css?v=1774821783">
<link rel="stylesheet" href="live.css?v=1774821783">
<link rel="stylesheet" href="style.css?v=1774786038">
<link rel="stylesheet" href="home.css?v=1774786038">
<link rel="stylesheet" href="live.css?v=1774786038">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,29 +81,29 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774821783"></script>
<script src="customize.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774821783"></script>
<script src="hop-resolver.js?v=1774821783"></script>
<script src="hop-display.js?v=1774821783"></script>
<script src="app.js?v=1774821783"></script>
<script src="home.js?v=1774821783"></script>
<script src="packet-filter.js?v=1774821783"></script>
<script src="packets.js?v=1774821783"></script>
<script src="map.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774821783" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774786038"></script>
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774786038"></script>
<script src="hop-resolver.js?v=1774786038"></script>
<script src="hop-display.js?v=1774786038"></script>
<script src="app.js?v=1774786038"></script>
<script src="home.js?v=1774786038"></script>
<script src="packet-filter.js?v=1774786038"></script>
<script src="packets.js?v=1774786038"></script>
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>

View File

@@ -89,8 +89,7 @@
function getStatusTooltip(role, status) {
const isInfra = role === 'repeater' || role === 'room';
const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm';
const threshold = isInfra ? '72h' : '24h';
if (status === 'active') {
return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
}

View File

@@ -1,142 +0,0 @@
#!/bin/bash
# Capture a fixture DB from staging for E2E tests
# Usage: ./scripts/capture-fixture.sh [source_url]
#
# Downloads nodes, observers, and recent packets from the staging API
# and creates a SQLite database suitable for E2E testing.
set -e
SOURCE_URL="${1:-https://analyzer.00id.net}"
DB_PATH="test-fixtures/e2e-fixture.db"
echo "Capturing fixture from $SOURCE_URL..."
mkdir -p test-fixtures
rm -f "$DB_PATH"
# Create schema
sqlite3 "$DB_PATH" <<'SQL'
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,
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 REAL
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
);
SQL
# Fetch nodes
echo "Fetching nodes..."
curl -sf "$SOURCE_URL/api/nodes?limit=200" | python3 -c "
import json, sys, sqlite3
data = json.load(sys.stdin)
nodes = data.get('nodes', data) if isinstance(data, dict) else data
db = sqlite3.connect('$DB_PATH')
for n in nodes[:200]:
db.execute('INSERT OR IGNORE INTO nodes VALUES (?,?,?,?,?,?,?,?,?,?)',
(n.get('public_key',''), n.get('name',''), n.get('role',''),
n.get('lat'), n.get('lon'), n.get('last_seen',''), n.get('first_seen',''),
n.get('advert_count',0), n.get('battery_mv'), n.get('temperature_c')))
db.commit()
print(f' Inserted {min(len(nodes), 200)} nodes')
db.close()
"
# Fetch observers
echo "Fetching observers..."
curl -sf "$SOURCE_URL/api/observers" | python3 -c "
import json, sys, sqlite3
data = json.load(sys.stdin)
observers = data.get('observers', data) if isinstance(data, dict) else data
db = sqlite3.connect('$DB_PATH')
for o in observers:
db.execute('INSERT OR IGNORE INTO observers VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
(o.get('id',''), o.get('name',''), o.get('iata',''),
o.get('last_seen',''), o.get('first_seen',''),
o.get('packet_count',0), o.get('model',''), o.get('firmware',''),
o.get('client_version',''), o.get('radio',''),
o.get('battery_mv'), o.get('uptime_secs'), o.get('noise_floor')))
db.commit()
print(f' Inserted {len(observers)} observers')
db.close()
"
# Fetch recent packets
echo "Fetching recent packets..."
curl -sf "$SOURCE_URL/api/packets?limit=500" | python3 -c "
import json, sys, sqlite3
data = json.load(sys.stdin)
packets = data.get('packets', data) if isinstance(data, dict) else data
db = sqlite3.connect('$DB_PATH')
for p in packets:
try:
cur = db.execute('INSERT OR IGNORE INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?,?,?,?,?,?,?)',
(p.get('raw_hex',''), p.get('hash',''), p.get('first_seen',''),
p.get('route_type'), p.get('payload_type'), p.get('payload_version'),
p.get('decoded_json')))
tid = cur.lastrowid
if tid and p.get('observer_id'):
db.execute('INSERT INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp) VALUES (?,?,?,?,?,?,?,?)',
(tid, p.get('observer_id'), p.get('direction'),
p.get('snr'), p.get('rssi'), None,
p.get('path_json'),
int(p.get('timestamp','0')) if p.get('timestamp','').isdigit() else 0))
except Exception as e:
pass # Skip duplicates
db.commit()
print(f' Inserted {len(packets)} transmissions')
db.close()
"
SIZE=$(du -h "$DB_PATH" | cut -f1)
echo "✅ Fixture DB created: $DB_PATH ($SIZE)"
echo " Nodes: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM nodes')"
echo " Observers: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM observers')"
echo " Transmissions: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM transmissions')"
echo " Observations: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM observations')"

View File

@@ -18,16 +18,10 @@ async function collectCoverage() {
page.setDefaultTimeout(10000);
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Helper: navigate via hash (SPA — no full page reload needed after initial load)
async function navHash(hash, wait = 150) {
await page.evaluate((h) => { location.hash = h; }, hash);
await new Promise(r => setTimeout(r, wait));
}
// Helper: safe click — 500ms timeout (elements exist immediately or not at all)
// Helper: safe click
async function safeClick(selector, timeout) {
try {
await page.click(selector, { timeout: timeout || 500 });
await page.click(selector, { timeout: timeout || 3000 });
} catch {}
}
@@ -126,7 +120,7 @@ async function collectCoverage() {
// NODES PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Nodes page...');
await navHash('#/nodes');
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Sort by EVERY column
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
@@ -162,7 +156,7 @@ async function collectCoverage() {
}
// In side pane — click detail/analytics links
await safeClick('a[href*="/nodes/"]');
await safeClick('a[href*="/nodes/"]', 2000);
// Click fav star
await clickAll('.fav-star', 2);
@@ -174,7 +168,7 @@ async function collectCoverage() {
try {
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
if (firstNodeKey) {
await navHash('#/nodes/' + firstNodeKey);
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click tabs on detail page
await clickAll('.tab-btn, [data-tab]', 10);
@@ -197,7 +191,7 @@ async function collectCoverage() {
try {
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
if (firstKey) {
await navHash('#/nodes/' + firstKey + '?scroll=paths');
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
}
} catch {}
@@ -205,7 +199,7 @@ async function collectCoverage() {
// PACKETS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Packets page...');
await navHash('#/packets');
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Open filter bar
await safeClick('#filterToggleBtn');
@@ -291,13 +285,13 @@ async function collectCoverage() {
} catch {}
// Navigate to specific packet by hash
await navHash('#/packets/deadbeef');
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// ══════════════════════════════════════════════
// MAP PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Map page...');
await navHash('#/map');
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Toggle controls panel
await safeClick('#mapControlsToggle');
@@ -351,7 +345,7 @@ async function collectCoverage() {
// ANALYTICS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Analytics page...');
await navHash('#/analytics');
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click EVERY analytics tab
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
@@ -387,12 +381,9 @@ async function collectCoverage() {
await clickAll('.analytics-table th', 8);
} catch {}
// Deep-link to each analytics tab via hash (avoid full page.goto)
// Deep-link to each analytics tab via URL
for (const tab of analyticsTabs) {
try {
await page.evaluate((t) => { location.hash = '#/analytics?tab=' + t; }, tab);
await new Promise(r => setTimeout(r, 100));
} catch {}
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
}
// Region filter on analytics
@@ -405,7 +396,7 @@ async function collectCoverage() {
// CUSTOMIZE
// ══════════════════════════════════════════════
console.log(' [coverage] Customizer...');
await navHash('#/home');
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#customizeToggle');
// Click EVERY customizer tab
@@ -512,7 +503,7 @@ async function collectCoverage() {
// CHANNELS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Channels page...');
await navHash('#/channels');
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click channel rows/items
await clickAll('.channel-item, .channel-row, .channel-card', 3);
await clickAll('table tbody tr', 3);
@@ -521,7 +512,7 @@ async function collectCoverage() {
try {
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
if (channelHash) {
await navHash('#/channels/' + channelHash);
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
}
} catch {}
@@ -529,7 +520,7 @@ async function collectCoverage() {
// LIVE PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Live page...');
await navHash('#/live');
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// VCR controls
await safeClick('#vcrPauseBtn');
@@ -612,14 +603,14 @@ async function collectCoverage() {
// TRACES PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Traces page...');
await navHash('#/traces');
await page.goto(`${BASE}/#/traces`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await clickAll('table tbody tr', 3);
// ══════════════════════════════════════════════
// OBSERVERS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Observers page...');
await navHash('#/observers');
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click observer rows
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
@@ -640,7 +631,7 @@ async function collectCoverage() {
// PERF PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Perf page...');
await navHash('#/perf');
await page.goto(`${BASE}/#/perf`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#perfRefresh');
await safeClick('#perfReset');
@@ -650,14 +641,14 @@ async function collectCoverage() {
console.log(' [coverage] App.js — router + global...');
// Navigate to bad route to trigger error/404
await navHash('#/nonexistent-route');
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Navigate to every route via hash (50ms is enough for SPA hash routing)
// Navigate to every route via hash
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
for (const route of allRoutes) {
try {
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
await new Promise(r => setTimeout(r, 50));
await new Promise(r => setTimeout(r, 200));
} catch {}
}
@@ -723,11 +714,10 @@ async function collectCoverage() {
await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); });
} catch {}
// Exercise utility functions + packet filter parser in one evaluate call
console.log(' [coverage] Utility functions + packet filter...');
// Exercise utility functions
try {
await page.evaluate(() => {
// Utility functions
// timeAgo with various inputs
if (typeof timeAgo === 'function') {
timeAgo(null);
timeAgo(new Date().toISOString());
@@ -735,11 +725,13 @@ async function collectCoverage() {
timeAgo(new Date(Date.now() - 3600000).toISOString());
timeAgo(new Date(Date.now() - 86400000 * 2).toISOString());
}
// truncate
if (typeof truncate === 'function') {
truncate('hello world', 5);
truncate(null, 5);
truncate('hi', 10);
}
// routeTypeName, payloadTypeName, payloadTypeColor
if (typeof routeTypeName === 'function') {
for (let i = 0; i <= 4; i++) routeTypeName(i);
}
@@ -749,14 +741,23 @@ async function collectCoverage() {
if (typeof payloadTypeColor === 'function') {
for (let i = 0; i <= 15; i++) payloadTypeColor(i);
}
// invalidateApiCache
if (typeof invalidateApiCache === 'function') {
invalidateApiCache();
invalidateApiCache('/test');
}
});
} catch {}
// Packet filter parser
// ══════════════════════════════════════════════
// PACKET FILTER — exercise the filter parser
// ══════════════════════════════════════════════
console.log(' [coverage] Packet filter parser...');
try {
await page.evaluate(() => {
if (window.PacketFilter && window.PacketFilter.compile) {
const PF = window.PacketFilter;
// Valid expressions
const exprs = [
'type == ADVERT', 'type == GRP_TXT', 'type != ACK',
'snr > 0', 'snr < -5', 'snr >= 10', 'snr <= 3',
@@ -772,6 +773,7 @@ async function collectCoverage() {
for (const e of exprs) {
try { PF.compile(e); } catch {}
}
// Bad expressions
const bad = ['@@@', '== ==', '(((', 'type ==', ''];
for (const e of bad) {
try { PF.compile(e); } catch {}
@@ -785,24 +787,29 @@ async function collectCoverage() {
// ══════════════════════════════════════════════
console.log(' [coverage] Region filter...');
try {
// Open region filter on nodes page (use hash nav, already visited)
await page.evaluate(() => { location.hash = '#/nodes'; });
await new Promise(r => setTimeout(r, 100));
// Open region filter on nodes page
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#nodesRegionFilter');
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
} catch {}
// Region filter on packets
try {
await page.evaluate(() => { location.hash = '#/packets'; });
await new Promise(r => setTimeout(r, 100));
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#packetsRegionFilter');
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
} catch {}
// ══════════════════════════════════════════════
// FINAL — extract coverage (all routes already visited above)
// FINAL — navigate through all routes once more
// ══════════════════════════════════════════════
console.log(' [coverage] Final route sweep...');
for (const route of allRoutes) {
try {
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
await new Promise(r => setTimeout(r, 200));
} catch {}
}
// Extract coverage
const coverage = await page.evaluate(() => window.__coverage__);

View File

@@ -36,19 +36,18 @@ function loadThemeFile(themePaths) {
function buildHealthConfig(config) {
const _ht = (config && config.healthThresholds) || {};
return {
infraDegraded: _ht.infraDegradedHours || 24,
infraSilent: _ht.infraSilentHours || 72,
nodeDegraded: _ht.nodeDegradedHours || 1,
nodeSilent: _ht.nodeSilentHours || 24
infraDegradedMs: _ht.infraDegradedMs || 86400000,
infraSilentMs: _ht.infraSilentMs || 259200000,
nodeDegradedMs: _ht.nodeDegradedMs || 3600000,
nodeSilentMs: _ht.nodeSilentMs || 86400000
};
}
function getHealthMs(role, HEALTH) {
const H = 3600000;
const isInfra = role === 'repeater' || role === 'room';
return {
degradedMs: (isInfra ? HEALTH.infraDegraded : HEALTH.nodeDegraded) * H,
silentMs: (isInfra ? HEALTH.infraSilent : HEALTH.nodeSilent) * H
degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs,
silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs
};
}

View File

@@ -307,12 +307,7 @@ app.get('/api/config/cache', (req, res) => {
app.get('/api/config/client', (req, res) => {
res.json({
roles: config.roles || null,
healthThresholds: {
infraDegradedMs: HEALTH.infraDegraded * 3600000,
infraSilentMs: HEALTH.infraSilent * 3600000,
nodeDegradedMs: HEALTH.nodeDegraded * 3600000,
nodeSilentMs: HEALTH.nodeSilent * 3600000
},
healthThresholds: config.healthThresholds || null,
tiles: config.tiles || null,
snrThresholds: config.snrThresholds || null,
distThresholds: config.distThresholds || null,
@@ -1987,17 +1982,9 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
.sort(([, a], [, b]) => b.packets - a.packets)
.map(([name, data]) => ({ name, ...data }));
// Distribution by number of repeaters advertising each hash size
const distributionByRepeaters = { 1: 0, 2: 0, 3: 0 };
for (const [, v] of Object.entries(byNode)) {
const s = v.hashSize;
if (s >= 1 && s <= 3) distributionByRepeaters[s]++;
}
const _hsResult = {
total: packets.length,
distribution,
distributionByRepeaters,
hourly,
topHops,
multiByteNodes

View File

@@ -10,15 +10,21 @@ const GO_BASE = process.env.GO_BASE_URL || ''; // e.g. https://analyzer.00id.ne
const results = [];
async function test(name, fn) {
try {
await fn();
results.push({ name, pass: true });
console.log(` \u2705 ${name}`);
} catch (err) {
results.push({ name, pass: false, error: err.message });
console.log(` \u274c ${name}: ${err.message}`);
console.log(`\nFail-fast: stopping after first failure.`);
process.exit(1);
const MAX_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await fn();
results.push({ name, pass: true });
console.log(` \u2705 ${name}${attempt > 1 ? ` (retry ${attempt - 1})` : ''}`);
return;
} catch (err) {
if (attempt < MAX_RETRIES) {
console.log(` \u26a0\ufe0f ${name}: ${err.message} (retrying...)`);
continue;
}
results.push({ name, pass: false, error: err.message });
console.log(` \u274c ${name}: ${err.message}`);
}
}
}
@@ -326,7 +332,9 @@ async function run() {
// Test: Packets groupByHash toggle changes view
await test('Packets groupByHash toggle works', async () => {
await page.waitForSelector('table tbody tr');
// Fresh navigation to ensure clean state
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const groupBtn = await page.$('#fGroup');
assert(groupBtn, 'Group by hash button (#fGroup) not found');
// Check initial state (default is grouped/active)
@@ -353,9 +361,7 @@ async function run() {
});
// Test: Clicking a packet row opens detail pane
// SKIPPED: flaky test — see https://github.com/Kpa-clawbot/CoreScope/issues/257
console.log(' ⏭️ Packets clicking row shows detail pane (SKIPPED — flaky)');
/*await test('Packets clicking row shows detail pane', async () => {
await test('Packets clicking row shows detail pane', async () => {
// Fresh navigation to avoid stale row references from previous test
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
// Wait for table rows AND initial API data to settle
@@ -410,8 +416,7 @@ async function run() {
}, { timeout: 3000 });
const panelHidden = await page.$eval('#pktRight', el => el.classList.contains('empty'));
assert(panelHidden, 'Detail pane should be hidden after clicking ✕');
});*/
console.log(' ⏭️ Packet detail pane closes on ✕ click (SKIPPED — depends on flaky test above)');
});
// Test: GRP_TXT packet detail shows Channel Hash (#123)
await test('GRP_TXT packet detail shows Channel Hash', async () => {
@@ -539,6 +544,11 @@ async function run() {
});
await test('Compare page runs comparison', async () => {
// Wait for dropdowns to be populated (may still be loading from previous test)
await page.waitForFunction(() => {
const selA = document.getElementById('compareObsA');
return selA && selA.options.length > 2;
}, { timeout: 10000 });
const options = await page.$$eval('#compareObsA option', opts =>
opts.filter(o => o.value).map(o => o.value)
);
@@ -560,6 +570,12 @@ async function run() {
// Test: Compare results show shared/unique breakdown (#129)
await test('Compare results show shared/unique cards', async () => {
// Wait for comparison results to fully render (depends on previous test)
await page.waitForFunction(() => {
return document.querySelector('.compare-card-both') &&
document.querySelector('.compare-card-a') &&
document.querySelector('.compare-card-b');
}, { timeout: 10000 });
// Results should be visible from previous test
const cardBoth = await page.$('.compare-card-both');
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
@@ -582,6 +598,11 @@ async function run() {
// Test: Compare "both" tab shows table with shared packets
await test('Compare both tab shows shared packets table', async () => {
// Ensure compare results are present
await page.waitForFunction(() => {
const c = document.getElementById('compareContent');
return c && c.textContent.trim().length > 20;
}, { timeout: 10000 });
const bothTab = await page.$('[data-cview="both"]');
assert(bothTab, '"both" tab button not found');
await bothTab.click();
@@ -805,7 +826,11 @@ async function run() {
// Check for summary stats
const summary = await page.$('.obs-summary');
assert(summary, 'Observer summary stats not found');
// Verify table has rows
// Wait for table rows to populate
await page.waitForFunction(() => {
const rows = document.querySelectorAll('#obsTable tbody tr');
return rows.length > 0;
}, { timeout: 10000 });
const rows = await page.$$('#obsTable tbody tr');
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
});
@@ -845,7 +870,17 @@ async function run() {
assert(content.length > 10, 'Perf content should still be present after refresh');
});
// Test: Node.js perf page shows Event Loop metrics (not Go Runtime)
await test('Perf page shows Event Loop on Node server', async () => {
const perfText = await page.$eval('#perfContent', el => el.textContent);
// Node.js server should show Event Loop metrics
const hasEventLoop = perfText.includes('Event Loop') || perfText.includes('event loop');
const hasMemory = perfText.includes('Memory') || perfText.includes('RSS');
assert(hasEventLoop || hasMemory, 'Node perf page should show Event Loop or Memory metrics');
// Should NOT show Go Runtime section on Node.js server
const hasGoRuntime = perfText.includes('Go Runtime');
assert(!hasGoRuntime, 'Node perf page should NOT show Go Runtime section');
});
// Test: Go perf page shows Go Runtime section (goroutines, GC)
// NOTE: This test requires GO_BASE_URL pointing to Go staging (port 82)

Binary file not shown.

View File

@@ -59,17 +59,17 @@ console.log('\nloadThemeFile:');
console.log('\nbuildHealthConfig:');
{
const h = helpers.buildHealthConfig({});
assert(h.infraDegraded === 24, 'default infraDegraded');
assert(h.infraSilent === 72, 'default infraSilent');
assert(h.nodeDegraded === 1, 'default nodeDegraded');
assert(h.nodeSilent === 24, 'default nodeSilent');
assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs');
assert(h.infraSilentMs === 259200000, 'default infraSilentMs');
assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs');
assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs');
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedHours: 2 } });
assert(h2.infraDegraded === 2, 'custom infraDegraded');
assert(h2.nodeDegraded === 1, 'other defaults preserved');
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } });
assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs');
assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved');
const h3 = helpers.buildHealthConfig(null);
assert(h3.infraDegraded === 24, 'handles null config');
assert(h3.infraDegradedMs === 86400000, 'handles null config');
}
// --- getHealthMs ---
@@ -78,21 +78,21 @@ console.log('\ngetHealthMs:');
const HEALTH = helpers.buildHealthConfig({});
const rep = helpers.getHealthMs('repeater', HEALTH);
assert(rep.degradedMs === 24 * 3600000, 'repeater uses infra degraded');
assert(rep.silentMs === 72 * 3600000, 'repeater uses infra silent');
assert(rep.degradedMs === 86400000, 'repeater uses infra degraded');
assert(rep.silentMs === 259200000, 'repeater uses infra silent');
const room = helpers.getHealthMs('room', HEALTH);
assert(room.degradedMs === 24 * 3600000, 'room uses infra degraded');
assert(room.degradedMs === 86400000, 'room uses infra degraded');
const comp = helpers.getHealthMs('companion', HEALTH);
assert(comp.degradedMs === 1 * 3600000, 'companion uses node degraded');
assert(comp.silentMs === 24 * 3600000, 'companion uses node silent');
assert(comp.degradedMs === 3600000, 'companion uses node degraded');
assert(comp.silentMs === 86400000, 'companion uses node silent');
const sensor = helpers.getHealthMs('sensor', HEALTH);
assert(sensor.degradedMs === 1 * 3600000, 'sensor uses node degraded');
assert(sensor.degradedMs === 3600000, 'sensor uses node degraded');
const undef = helpers.getHealthMs(undefined, HEALTH);
assert(undef.degradedMs === 1 * 3600000, 'undefined role uses node degraded');
assert(undef.degradedMs === 3600000, 'undefined role uses node degraded');
}
// --- isHashSizeFlipFlop ---

View File

@@ -583,11 +583,6 @@ seedTestData();
await t('GET /api/analytics/hash-sizes', async () => {
const r = await request(app).get('/api/analytics/hash-sizes').expect(200);
assert(typeof r.body === 'object', 'should return hash sizes');
assert(r.body.distributionByRepeaters, 'should include distributionByRepeaters');
assert(typeof r.body.distributionByRepeaters === 'object', 'distributionByRepeaters should be an object');
for (const s of [1, 2, 3]) {
assert(typeof r.body.distributionByRepeaters[s] === 'number', `distributionByRepeaters[${s}] should be a number`);
}
});
await t('GET /api/analytics/hash-sizes with region', async () => {

228
tools/seed-test-data.js Normal file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env node
'use strict';
/**
* Seed synthetic test data into a running CoreScope server.
* Usage: node tools/seed-test-data.js [baseUrl]
* Default: http://localhost:13581
*/
const crypto = require('crypto');
const BASE = process.argv[2] || process.env.BASE_URL || 'http://localhost:13581';
const OBSERVERS = [
{ id: 'E2E-SJC-1', iata: 'SJC' },
{ id: 'E2E-SFO-2', iata: 'SFO' },
{ id: 'E2E-OAK-3', iata: 'OAK' },
];
const NODE_NAMES = [
'TestNode Alpha', 'TestNode Beta', 'TestNode Gamma', 'TestNode Delta',
'TestNode Epsilon', 'TestNode Zeta', 'TestNode Eta', 'TestNode Theta',
];
function rand(a, b) { return Math.random() * (b - a) + a; }
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
function pick(a) { return a[randInt(0, a.length - 1)]; }
function randomBytes(n) { return crypto.randomBytes(n); }
function pubkeyFor(name) { return crypto.createHash('sha256').update(name).digest(); }
function encodeHeader(routeType, payloadType, ver = 0) {
return (routeType & 0x03) | ((payloadType & 0x0F) << 2) | ((ver & 0x03) << 6);
}
function buildPath(hopCount, hashSize = 2) {
const pathByte = ((hashSize - 1) << 6) | (hopCount & 0x3F);
const hops = crypto.randomBytes(hashSize * hopCount);
return { pathByte, hops };
}
function buildAdvert(name, role) {
const pubKey = pubkeyFor(name);
const ts = Buffer.alloc(4); ts.writeUInt32LE(Math.floor(Date.now() / 1000));
const sig = randomBytes(64);
let flags = 0x80 | 0x10; // hasName + hasLocation
if (role === 'repeater') flags |= 0x02;
else if (role === 'room') flags |= 0x04;
else if (role === 'sensor') flags |= 0x08;
else flags |= 0x01;
const nameBuf = Buffer.from(name, 'utf8');
const appdata = Buffer.alloc(9 + nameBuf.length);
appdata[0] = flags;
appdata.writeInt32LE(Math.round(37.34 * 1e6), 1);
appdata.writeInt32LE(Math.round(-121.89 * 1e6), 5);
nameBuf.copy(appdata, 9);
const payload = Buffer.concat([pubKey, ts, sig, appdata]);
const header = encodeHeader(1, 0x04, 0); // FLOOD + ADVERT
const { pathByte, hops } = buildPath(randInt(0, 3));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
function buildGrpTxt(channelHash = 0) {
const mac = randomBytes(2);
const enc = randomBytes(randInt(10, 40));
const payload = Buffer.concat([Buffer.from([channelHash]), mac, enc]);
const header = encodeHeader(1, 0x05, 0); // FLOOD + GRP_TXT
const { pathByte, hops } = buildPath(randInt(0, 3));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
/**
* Build a properly encrypted GRP_TXT packet that decrypts to a CHAN message.
* Uses #LongFast channel key from channel-rainbow.json.
*/
function buildEncryptedGrpTxt(sender, message) {
try {
const CryptoJS = require('crypto-js');
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
const channelKey = '2cc3d22840e086105ad73443da2cacb8'; // #LongFast
const text = `${sender}: ${message}`;
const buf = Buffer.alloc(5 + text.length + 1);
buf.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
buf[4] = 0;
buf.write(text + '\0', 5, 'utf8');
const padded = Buffer.alloc(Math.ceil(buf.length / 16) * 16);
buf.copy(padded);
const keyWords = CryptoJS.enc.Hex.parse(channelKey);
const plaintextWords = CryptoJS.enc.Hex.parse(padded.toString('hex'));
const encrypted = CryptoJS.AES.encrypt(plaintextWords, keyWords, {
mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding
});
const cipherHex = encrypted.ciphertext.toString(CryptoJS.enc.Hex);
const channelSecret = Buffer.alloc(32);
Buffer.from(channelKey, 'hex').copy(channelSecret);
const mac = CryptoJS.HmacSHA256(
CryptoJS.enc.Hex.parse(cipherHex),
CryptoJS.enc.Hex.parse(channelSecret.toString('hex'))
);
const macHex = mac.toString(CryptoJS.enc.Hex).substring(0, 4);
const chHash = ChannelCrypto.calculateChannelHash('#LongFast');
const grpPayload = Buffer.from(
chHash.toString(16).padStart(2, '0') + macHex + cipherHex, 'hex'
);
const header = encodeHeader(1, 0x05, 0);
const { pathByte, hops } = buildPath(randInt(0, 2));
return Buffer.concat([Buffer.from([header, pathByte]), hops, grpPayload]);
} catch (e) {
// Fallback to unencrypted if crypto libs unavailable
return buildGrpTxt(0);
}
}
function buildAck() {
const payload = randomBytes(18);
const header = encodeHeader(2, 0x03, 0);
const { pathByte, hops } = buildPath(randInt(0, 2));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
function buildTxtMsg() {
const payload = Buffer.concat([randomBytes(6), randomBytes(6), randomBytes(4), randomBytes(20)]);
const header = encodeHeader(2, 0x02, 0);
const { pathByte, hops } = buildPath(randInt(0, 2));
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
}
function computeContentHash(hex) {
return crypto.createHash('sha256').update(hex.toUpperCase()).digest('hex').substring(0, 16);
}
async function post(path, body) {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: r.status, data: await r.json() };
}
async function main() {
console.log(`Seeding test data into ${BASE}...`);
const packets = [];
// 1. ADVERTs for each node (creates nodes with location for map)
const roles = ['repeater', 'repeater', 'room', 'companion', 'repeater', 'companion', 'sensor', 'repeater'];
for (let i = 0; i < NODE_NAMES.length; i++) {
const obs = pick(OBSERVERS);
const hex = buildAdvert(NODE_NAMES[i], roles[i]).toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 5.0, rssi: -80 });
// Send same advert from multiple observers for compare page
for (const otherObs of OBSERVERS) {
if (otherObs.id !== obs.id) {
packets.push({ hex, observer: otherObs.id, region: otherObs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
}
}
// 2. Encrypted GRP_TXT packets (creates channel messages for channels page)
const chatMessages = [
['Alice', 'Hello everyone!'], ['Bob', 'Hey Alice!'], ['Charlie', 'Good morning'],
['Alice', 'How is the mesh today?'], ['Bob', 'Looking great, 8 nodes online'],
['Charlie', 'I just set up a new repeater'], ['Alice', 'Nice! Where is it?'],
['Bob', 'Signal looks strong from here'], ['Charlie', 'On top of the hill'],
['Alice', 'Perfect location!'],
];
for (const [sender, message] of chatMessages) {
const obs = pick(OBSERVERS);
const hex = buildEncryptedGrpTxt(sender, message).toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 3. Unencrypted GRP_TXT packets (won't create channel entries but add packet variety)
for (let i = 0; i < 10; i++) {
const obs = pick(OBSERVERS);
const hex = buildGrpTxt(randInt(0, 3)).toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 3. ACK packets
for (let i = 0; i < 15; i++) {
const obs = pick(OBSERVERS);
const hex = buildAck().toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 4. TXT_MSG packets
for (let i = 0; i < 15; i++) {
const obs = pick(OBSERVERS);
const hex = buildTxtMsg().toString('hex').toUpperCase();
const hash = computeContentHash(hex);
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
}
// 5. Extra packets with shared hashes (for trace/compare)
for (let t = 0; t < 5; t++) {
const hex = buildGrpTxt(0).toString('hex').toUpperCase();
const traceHash = computeContentHash(hex);
for (const obs of OBSERVERS) {
packets.push({ hex, observer: obs.id, region: obs.iata, hash: traceHash, snr: 5, rssi: -80 });
}
}
console.log(`Injecting ${packets.length} packets...`);
let ok = 0, fail = 0;
for (const pkt of packets) {
const r = await post('/api/packets', pkt);
if (r.status === 200) ok++;
else { fail++; if (fail <= 3) console.error(' Inject fail:', r.data); }
}
console.log(`Done: ${ok} ok, ${fail} fail`);
if (fail > 0) {
process.exit(1);
}
}
main().catch(err => { console.error(err); process.exit(1); });