mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 11:11:54 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e5b66f25 | |||
| 45b82ad390 | |||
| 746f7f2733 | |||
| a1a67e89fb | |||
| 91fcbc5adc | |||
| 5f5eae07b0 | |||
| 380b1b1e28 | |||
| 03cfd114da | |||
| df90de77a7 | |||
| 7b97c532a1 | |||
| e0c2d37041 | |||
| f5d0ce066b | |||
| 202d0d87d7 | |||
| 99d2e67eb1 | |||
| a6413fb665 | |||
| 8a458c7c2a | |||
| 66b3c05da3 | |||
| cdcaa476f2 | |||
| 71ec5e6fca | |||
| a94c24c550 | |||
| a1f95fee58 | |||
| 24d76f8373 | |||
| 8e18351c73 | |||
| a827fd3b43 | |||
| 467a307a8d | |||
| 077fca9038 | |||
| b326e3f1a6 | |||
| 54cbc648e0 | |||
| aba4270ceb | |||
| 57b0188158 | |||
| f374a4a775 | |||
| 6d31cb2ad6 | |||
| 1619f4857e | |||
| 58d19ec303 | |||
| 331dc0090e | |||
| cef8156a86 | |||
| 9751141ffc | |||
| 9c5ffbfb0c | |||
| 3361643bc0 | |||
| f04f1b8e77 | |||
| 447c5d7073 | |||
| aa2e8ed420 |
+33
-6
@@ -1,17 +1,44 @@
|
||||
# MeshCore Analyzer — Environment Configuration
|
||||
# Copy to .env and customize. All values have sensible defaults in docker-compose.yml.
|
||||
# Copy to .env and customize. All values have sensible defaults.
|
||||
#
|
||||
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
|
||||
# Each environment keeps config + data together in one directory:
|
||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||
|
||||
# --- Production ---
|
||||
PROD_HTTP_PORT=80
|
||||
PROD_HTTPS_PORT=443
|
||||
PROD_MQTT_PORT=1883
|
||||
# Data directory (database, theme, etc.)
|
||||
# Default: ~/meshcore-data
|
||||
# Used by: docker compose, manage.sh
|
||||
PROD_DATA_DIR=~/meshcore-data
|
||||
|
||||
# HTTP port for web UI
|
||||
# Default: 80
|
||||
# Used by: docker compose
|
||||
PROD_HTTP_PORT=80
|
||||
|
||||
# HTTPS port for web UI (TLS via Caddy)
|
||||
# Default: 443
|
||||
# Used by: docker compose
|
||||
PROD_HTTPS_PORT=443
|
||||
|
||||
# MQTT port for observer connections
|
||||
# Default: 1883
|
||||
# Used by: docker compose
|
||||
PROD_MQTT_PORT=1883
|
||||
|
||||
# --- Staging (HTTP only, no HTTPS) ---
|
||||
STAGING_HTTP_PORT=81
|
||||
STAGING_MQTT_PORT=1884
|
||||
# Data directory
|
||||
# Default: ~/meshcore-staging-data
|
||||
# Used by: docker compose
|
||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
||||
|
||||
# HTTP port
|
||||
# Default: 81
|
||||
# Used by: docker compose
|
||||
STAGING_HTTP_PORT=81
|
||||
|
||||
# MQTT port
|
||||
# Default: 1884
|
||||
# Used by: docker compose
|
||||
STAGING_MQTT_PORT=1884
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: "MeshCore PR Reviewer"
|
||||
description: "A specialized agent for reviewing pull requests in the meshcore-analyzer repository. It focuses on SOLID, DRY, testing, Go best practices, frontend testability, observability, and performance to prevent regressions and maintain high code quality."
|
||||
model: "gpt-5.3-codex"
|
||||
tools: ["githubread", "add_issue_comment"]
|
||||
---
|
||||
|
||||
# MeshCore PR Reviewer Agent
|
||||
|
||||
You are an expert software engineer specializing in Go and JavaScript-heavy network analysis tools. Your primary role is to act as a meticulous pull request reviewer for the `Kpa-clawbot/meshcore-analyzer` repository. You are deeply familiar with its architecture, as outlined in `AGENTS.md`, and you enforce its rules rigorously.
|
||||
|
||||
Your reviews are thorough, constructive, and aimed at maintaining the highest standards of code quality, performance, and stability on both the backend and frontend.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Context is King**: Before any review, consult the `AGENTS.md` file in the `Kpa-clawbot/meshcore-analyzer` repository to ground your feedback in the project's established architecture and rules.
|
||||
2. **Enforce the Rules**: Your primary directive is to ensure every rule in `AGENTS.md` is followed. Call out any deviation.
|
||||
3. **Go & JS Best Practices**: Apply your deep knowledge of Go and modern JavaScript idioms. Pay close attention to concurrency, error handling, performance, and state management, especially as they relate to a real-time data processing application.
|
||||
4. **Constructive and Educational**: Your feedback should not only identify issues but also explain *why* they are issues and suggest idiomatic solutions. Your goal is to mentor and elevate the codebase and its contributors.
|
||||
5. **Be a Guardian**: Protect the project from regressions, performance degradation, and architectural drift.
|
||||
|
||||
## Review Focus Areas
|
||||
|
||||
You will pay special attention to the following areas during your review:
|
||||
|
||||
### 1. Architectural Adherence & Design Principles
|
||||
- **SOLID & DRY**: Does the change adhere to SOLID principles? Is there duplicated logic that could be refactored? Does it respect the existing separation of concerns?
|
||||
- **Project Architecture**: Does the PR respect the single Node.js server + static frontend architecture? Are changes in the right place?
|
||||
|
||||
### 2. Testing and Validation
|
||||
- **No commit without tests**: Is the backend logic change covered by unit tests? Is `test-packet-filter.js` or `test-aging.js` updated if necessary?
|
||||
- **Browser Validation**: Has the contributor confirmed the change works in a browser? Is there a screenshot for visual changes?
|
||||
- **Cache Busters**: If any `public/` assets (`.js`, `.css`) were modified, has the cache buster in `public/index.html` been bumped in the *same commit*? This is critical.
|
||||
|
||||
### 3. Go-Specific Concerns
|
||||
- **Concurrency**: Are goroutines used safely? Are there potential race conditions? Is synchronization used correctly?
|
||||
- **Error Handling**: Is error handling explicit and clear? Are errors wrapped with context where appropriate?
|
||||
- **Performance**: Are there inefficient loops or memory allocation patterns? Scrutinize any new data processing logic.
|
||||
- **Go Idioms**: Does the code follow standard Go idioms and formatting (`gofmt`)?
|
||||
|
||||
### 4. Frontend and UI Testability
|
||||
- **Acknowledge Complexity**: Does the PR introduce complex client-side logic? Recognize that browser-based functionality is difficult to unit test.
|
||||
- **Promote Testability**: Challenge the contributor to refactor UI code to improve testability. Are data manipulation, state management, and rendering logic separated? Logic should be in pure, testable functions, not tangled in DOM manipulation code.
|
||||
- **UI Logic Purity**: Scrutinize client-side JavaScript. Are there large, monolithic functions? Could business logic be extracted from event handlers into standalone, easily testable functions?
|
||||
- **State Management**: How is client-side state managed? Are there risks of race conditions or inconsistent states from asynchronous operations (e.g., API calls)?
|
||||
|
||||
### 5. Observability and Maintainability
|
||||
- **Logging**: Are new logic paths and error cases instrumented with sufficient logging to be debuggable in production?
|
||||
- **Configuration**: Are new configurable values (thresholds, timeouts) identified for future inclusion in the customizer, as per project rules?
|
||||
- **Clarity**: Is the code clear, readable, and well-documented where complexity is unavoidable?
|
||||
|
||||
### 6. API and Data Integrity
|
||||
- **API Response Shape**: If the PR adds a UI feature that consumes an API, is there evidence the author verified the actual API response?
|
||||
- **Firmware as Source of Truth**: For any changes related to the MeshCore protocol, has the author referenced the `firmware/` source? Challenge any "magic numbers" or assumptions about packet structure.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **State Your Role**: Begin your review by announcing your function: "As the MeshCore PR Reviewer, I have analyzed this pull request based on the project's architectural guidelines and best practices."
|
||||
2. **Provide a Summary**: Give a high-level summary of your findings (e.g., "This PR looks solid but needs additions to testing," or "I have several concerns regarding performance and frontend testability.").
|
||||
3. **Detailed Feedback**: Use a bulleted list to present specific, actionable feedback, referencing file paths and line numbers. For each point, cite the relevant principle or project rule (e.g., "Missing Test Coverage (Rule #1)", "UI Logic Purity (Focus Area #4)").
|
||||
4. **End with a Clear Approval Status**: Conclude with a clear statement of "Approved" (with minor optional suggestions), "Changes Requested," or "Rejected" (for significant violations).
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: "MeshCore PR Reviewer"
|
||||
description: "A specialized agent for reviewing pull requests in the meshcore-analyzer repository. It focuses on SOLID, DRY, testing, Go best practices, frontend testability, observability, and performance to prevent regressions and maintain high code quality."
|
||||
model: "gpt-5.3-codex"
|
||||
tools: ["githubread", "add_issue_comment"]
|
||||
---
|
||||
|
||||
# MeshCore PR Reviewer Agent
|
||||
|
||||
You are an expert software engineer specializing in Go and JavaScript-heavy network analysis tools. Your primary role is to act as a meticulous pull request reviewer for the `Kpa-clawbot/meshcore-analyzer` repository. You are deeply familiar with its architecture, as outlined in `AGENTS.md`, and you enforce its rules rigorously.
|
||||
|
||||
Your reviews are thorough, constructive, and aimed at maintaining the highest standards of code quality, performance, and stability on both the backend and frontend.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Context is King**: Before any review, consult the `AGENTS.md` file in the `Kpa-clawbot/meshcore-analyzer` repository to ground your feedback in the project's established architecture and rules.
|
||||
2. **Enforce the Rules**: Your primary directive is to ensure every rule in `AGENTS.md` is followed. Call out any deviation.
|
||||
3. **Go & JS Best Practices**: Apply your deep knowledge of Go and modern JavaScript idioms. Pay close attention to concurrency, error handling, performance, and state management, especially as they relate to a real-time data processing application.
|
||||
4. **Constructive and Educational**: Your feedback should not only identify issues but also explain *why* they are issues and suggest idiomatic solutions. Your goal is to mentor and elevate the codebase and its contributors.
|
||||
5. **Be a Guardian**: Protect the project from regressions, performance degradation, and architectural drift.
|
||||
|
||||
## Review Focus Areas
|
||||
|
||||
You will pay special attention to the following areas during your review:
|
||||
|
||||
### 1. Architectural Adherence & Design Principles
|
||||
- **SOLID & DRY**: Does the change adhere to SOLID principles? Is there duplicated logic that could be refactored? Does it respect the existing separation of concerns?
|
||||
- **Project Architecture**: Does the PR respect the single Node.js server + static frontend architecture? Are changes in the right place?
|
||||
|
||||
### 2. Testing and Validation
|
||||
- **No commit without tests**: Is the backend logic change covered by unit tests? Is `test-packet-filter.js` or `test-aging.js` updated if necessary?
|
||||
- **Browser Validation**: Has the contributor confirmed the change works in a browser? Is there a screenshot for visual changes?
|
||||
- **Cache Busters**: If any `public/` assets (`.js`, `.css`) were modified, has the cache buster in `public/index.html` been bumped in the *same commit*? This is critical.
|
||||
|
||||
### 3. Go-Specific Concerns
|
||||
- **Concurrency**: Are goroutines used safely? Are there potential race conditions? Is synchronization used correctly?
|
||||
- **Error Handling**: Is error handling explicit and clear? Are errors wrapped with context where appropriate?
|
||||
- **Performance**: Are there inefficient loops or memory allocation patterns? Scrutinize any new data processing logic.
|
||||
- **Go Idioms**: Does the code follow standard Go idioms and formatting (`gofmt`)?
|
||||
|
||||
### 4. Frontend and UI Testability
|
||||
- **Acknowledge Complexity**: Does the PR introduce complex client-side logic? Recognize that browser-based functionality is difficult to unit test.
|
||||
- **Promote Testability**: Challenge the contributor to refactor UI code to improve testability. Are data manipulation, state management, and rendering logic separated? Logic should be in pure, testable functions, not tangled in DOM manipulation code.
|
||||
- **UI Logic Purity**: Scrutinize client-side JavaScript. Are there large, monolithic functions? Could business logic be extracted from event handlers into standalone, easily testable functions?
|
||||
- **State Management**: How is client-side state managed? Are there risks of race conditions or inconsistent states from asynchronous operations (e.g., API calls)?
|
||||
|
||||
### 5. Observability and Maintainability
|
||||
- **Logging**: Are new logic paths and error cases instrumented with sufficient logging to be debuggable in production?
|
||||
- **Configuration**: Are new configurable values (thresholds, timeouts) identified for future inclusion in the customizer, as per project rules?
|
||||
- **Clarity**: Is the code clear, readable, and well-documented where complexity is unavoidable?
|
||||
|
||||
### 6. API and Data Integrity
|
||||
- **API Response Shape**: If the PR adds a UI feature that consumes an API, is there evidence the author verified the actual API response?
|
||||
- **Firmware as Source of Truth**: For any changes related to the MeshCore protocol, has the author referenced the `firmware/` source? Challenge any "magic numbers" or assumptions about packet structure.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **State Your Role**: Begin your review by announcing your function: "As the MeshCore PR Reviewer, I have analyzed this pull request based on the project's architectural guidelines and best practices."
|
||||
2. **Provide a Summary**: Give a high-level summary of your findings (e.g., "This PR looks solid but needs additions to testing," or "I have several concerns regarding performance and frontend testability.").
|
||||
3. **Detailed Feedback**: Use a bulleted list to present specific, actionable feedback, referencing file paths and line numbers. For each point, cite the relevant principle or project rule (e.g., "Missing Test Coverage (Rule #1)", "UI Logic Purity (Focus Area #4)").
|
||||
4. **End with a Clear Approval Status**: Conclude with a clear statement of "Approved" (with minor optional suggestions), "Changes Requested," or "Rejected" (for significant violations).
|
||||
+36
-302
@@ -8,6 +8,13 @@ on:
|
||||
- 'LICENSE'
|
||||
- '.gitignore'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- '.gitignore'
|
||||
- 'docs/**'
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
@@ -16,14 +23,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 +273,11 @@ 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"
|
||||
if: github.event_name == 'push'
|
||||
needs: [go-test]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
@@ -315,305 +298,57 @@ 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"
|
||||
if: github.event_name == 'push'
|
||||
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
|
||||
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 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")
|
||||
HEALTH=$(docker inspect corescope-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"
|
||||
docker logs meshcore-staging-go --tail 50
|
||||
echo "Staging failed health check after 120s"
|
||||
docker logs corescope-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]
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -648,12 +383,11 @@ 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
|
||||
echo "ssh deploy@\$VM_HOST" >> $GITHUB_STEP_SUMMARY
|
||||
echo "cd /opt/meshcore-deploy" >> $GITHUB_STEP_SUMMARY
|
||||
echo "cd /opt/corescope-deploy" >> $GITHUB_STEP_SUMMARY
|
||||
echo "./manage.sh promote" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Bishop — Tester
|
||||
|
||||
Unit tests, Playwright E2E, coverage gates, and quality assurance for MeshCore Analyzer.
|
||||
Unit tests, Playwright E2E, coverage gates, and quality assurance for CoreScope.
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**Stack:** Node.js native test runner, Playwright, c8 + nyc (coverage), supertest
|
||||
**User:** User
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Context
|
||||
|
||||
MeshCore Analyzer has 14 test files, 4,290 lines of test code. Backend coverage 85%+, frontend 42%+. Tests use Node.js native runner, Playwright for E2E, c8/nyc for coverage, supertest for API routes. vm.createContext pattern used for testing frontend helpers in Node.js.
|
||||
CoreScope has 14 test files, 4,290 lines of test code. Backend coverage 85%+, frontend 42%+. Tests use Node.js native runner, Playwright for E2E, c8/nyc for coverage, supertest for API routes. vm.createContext pattern used for testing frontend helpers in Node.js.
|
||||
|
||||
User: User
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Hicks — Backend Dev
|
||||
|
||||
Server, decoder, packet-store, SQLite, API, MQTT, WebSocket, and performance for MeshCore Analyzer.
|
||||
Server, decoder, packet-store, SQLite, API, MQTT, WebSocket, and performance for CoreScope.
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**Stack:** Node.js 18+, Express 5, SQLite (better-sqlite3), MQTT (mqtt), WebSocket (ws)
|
||||
**User:** User
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Context
|
||||
|
||||
MeshCore Analyzer is a real-time LoRa mesh packet analyzer. Node.js + Express + SQLite backend, vanilla JS SPA frontend. Custom decoder.js fixes path_length bug from upstream library. In-memory packet store provides O(1) lookups for 30K+ packets. TTL response cache achieves 7,000× speedup on bulk health endpoint.
|
||||
CoreScope is a real-time LoRa mesh packet analyzer. Node.js + Express + SQLite backend, vanilla JS SPA frontend. Custom decoder.js fixes path_length bug from upstream library. In-memory packet store provides O(1) lookups for 30K+ packets. TTL response cache achieves 7,000× speedup on bulk health endpoint.
|
||||
|
||||
User: User
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Kobayashi — Lead
|
||||
|
||||
Architecture, code review, and decision-making for MeshCore Analyzer.
|
||||
Architecture, code review, and decision-making for CoreScope.
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**Stack:** Node.js 18+, Express 5, SQLite, vanilla JS frontend, Leaflet, WebSocket, MQTT
|
||||
**User:** User
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Context
|
||||
|
||||
MeshCore Analyzer is a real-time LoRa mesh packet analyzer. Node.js + Express + SQLite backend, vanilla JS SPA frontend with Leaflet maps, WebSocket live feed, MQTT ingestion. Production at v2.6.0, ~18K lines, 85%+ backend test coverage.
|
||||
CoreScope is a real-time LoRa mesh packet analyzer. Node.js + Express + SQLite backend, vanilla JS SPA frontend with Leaflet maps, WebSocket live feed, MQTT ingestion. Production at v2.6.0, ~18K lines, 85%+ backend test coverage.
|
||||
|
||||
User: User
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Newt — Frontend Dev
|
||||
|
||||
Vanilla JS UI, Leaflet maps, live visualization, theming, and all public/ modules for MeshCore Analyzer.
|
||||
Vanilla JS UI, Leaflet maps, live visualization, theming, and all public/ modules for CoreScope.
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**Stack:** Vanilla HTML/CSS/JavaScript (ES5/6), Leaflet maps, WebSocket, Canvas animations
|
||||
**User:** User
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Context
|
||||
|
||||
MeshCore Analyzer is a real-time LoRa mesh packet analyzer with a vanilla JS SPA frontend. 22 frontend modules, Leaflet maps, WebSocket live feed, VCR playback, Canvas animations, theme customizer with CSS variables. No build step, no framework. ES5/6 for broad browser support.
|
||||
CoreScope is a real-time LoRa mesh packet analyzer with a vanilla JS SPA frontend. 22 frontend modules, Leaflet maps, WebSocket live feed, VCR playback, Canvas animations, theme customizer with CSS variables. No build step, no framework. ES5/6 for broad browser support.
|
||||
|
||||
User: User
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Tracks the work queue and keeps the team moving. Always on the roster.
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**User:** User
|
||||
|
||||
## Responsibilities
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Ripley — Support Engineer
|
||||
|
||||
Deep knowledge of every frontend behavior, API response, and user-facing feature in MeshCore Analyzer. Fields community questions, triages bug reports, and explains "why does X look like Y."
|
||||
Deep knowledge of every frontend behavior, API response, and user-facing feature in CoreScope. Fields community questions, triages bug reports, and explains "why does X look like Y."
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**Stack:** Vanilla JS frontend (public/*.js), Node.js backend, SQLite, WebSocket, MQTT
|
||||
**User:** Kpa-clawbot
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Ripley — Support Engineer History
|
||||
|
||||
## Core Context
|
||||
- Project: MeshCore Analyzer — real-time LoRa mesh packet analyzer
|
||||
- Project: CoreScope — real-time LoRa mesh packet analyzer
|
||||
- User: Kpa-clawbot
|
||||
- Joined the team 2026-03-27 to handle community support and triage
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Scribe — Session Logger
|
||||
|
||||
Silent agent that maintains decisions, logs, and cross-agent context for MeshCore Analyzer.
|
||||
Silent agent that maintains decisions, logs, and cross-agent context for CoreScope.
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**User:** User
|
||||
|
||||
## Responsibilities
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"universe": "aliens",
|
||||
"created_at": "2026-03-26T04:22:08Z",
|
||||
"agents": ["Kobayashi", "Hicks", "Newt", "Bishop"],
|
||||
"reason": "Initial team casting for MeshCore Analyzer project"
|
||||
"reason": "Initial team casting for CoreScope project"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Squad — MeshCore Analyzer
|
||||
# Squad — CoreScope
|
||||
|
||||
## Project Context
|
||||
|
||||
**Project:** MeshCore Analyzer — Real-time LoRa mesh packet analyzer
|
||||
**Project:** CoreScope — Real-time LoRa mesh packet analyzer
|
||||
**Stack:** Node.js 18+, Express 5, SQLite (better-sqlite3), vanilla JS frontend, Leaflet maps, WebSocket (ws), MQTT (mqtt)
|
||||
**User:** User
|
||||
**Description:** Self-hosted alternative to analyzer.letsmesh.net. Ingests MeshCore mesh network packets via MQTT, decodes with custom parser (decoder.js), stores in SQLite with in-memory indexing (packet-store.js), and serves a rich SPA with live visualization, packet analysis, node analytics, channel chat, observer health, and theme customizer. ~18K lines, 14 test files, 85%+ backend coverage. Production at v2.6.0.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md — MeshCore Analyzer
|
||||
# AGENTS.md — CoreScope
|
||||
|
||||
Guide for AI agents working on this codebase. Read this before writing any code.
|
||||
|
||||
|
||||
+6
-6
@@ -11,14 +11,14 @@ WORKDIR /build/server
|
||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /meshcore-server .
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||
|
||||
# Build ingestor
|
||||
WORKDIR /build/ingestor
|
||||
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN go build -o /meshcore-ingestor .
|
||||
RUN go build -o /corescope-ingestor .
|
||||
|
||||
# Runtime image
|
||||
FROM alpine:3.20
|
||||
@@ -28,15 +28,15 @@ RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy wget
|
||||
WORKDIR /app
|
||||
|
||||
# Go binaries
|
||||
COPY --from=builder /meshcore-server /meshcore-ingestor /app/
|
||||
COPY --from=builder /corescope-server /corescope-ingestor /app/
|
||||
|
||||
# Frontend assets + config
|
||||
COPY public/ ./public/
|
||||
COPY config.example.json channel-rainbow.json ./
|
||||
|
||||
# Bake git commit SHA (CI writes .git-commit before build; fallback for non-ldflags usage)
|
||||
COPY .git-commi[t] ./
|
||||
RUN if [ ! -f .git-commit ]; then echo "unknown" > .git-commit; fi
|
||||
# Bake git commit SHA — manage.sh and CI write .git-commit before build
|
||||
# Default to "unknown" if not provided
|
||||
RUN echo "unknown" > .git-commit
|
||||
|
||||
# Supervisor + Mosquitto + Caddy config
|
||||
COPY docker/supervisord-go.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
+3
-3
@@ -11,14 +11,14 @@ WORKDIR /build/server
|
||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /meshcore-server .
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||
|
||||
# Build ingestor
|
||||
WORKDIR /build/ingestor
|
||||
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN go build -o /meshcore-ingestor .
|
||||
RUN go build -o /corescope-ingestor .
|
||||
|
||||
# Runtime image
|
||||
FROM alpine:3.20
|
||||
@@ -28,7 +28,7 @@ RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy wget
|
||||
WORKDIR /app
|
||||
|
||||
# Go binaries
|
||||
COPY --from=builder /meshcore-server /meshcore-ingestor /app/
|
||||
COPY --from=builder /corescope-server /corescope-ingestor /app/
|
||||
|
||||
# Frontend assets + config
|
||||
COPY public/ ./public/
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# MeshCore Analyzer
|
||||
# CoreScope
|
||||
|
||||
[](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/corescope/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Kpa-clawbot/corescope/actions/workflows/deploy.yml)
|
||||
[](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,8 +79,8 @@ 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/meshcore-analyzer.git
|
||||
cd meshcore-analyzer
|
||||
git clone https://github.com/Kpa-clawbot/corescope.git
|
||||
cd corescope
|
||||
./manage.sh setup
|
||||
```
|
||||
|
||||
@@ -171,7 +171,7 @@ Or POST raw hex packets to `POST /api/packets` for manual injection.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
meshcore-analyzer/
|
||||
corescope/
|
||||
├── cmd/
|
||||
│ ├── server/ # Go HTTP server + WebSocket + REST API
|
||||
│ │ ├── main.go # Entry point
|
||||
|
||||
+4
-4
@@ -73,8 +73,8 @@ Advert counts now reflect unique transmissions, not total observations. A packet
|
||||
|
||||
The Go backend is two binaries managed by supervisord inside Docker:
|
||||
|
||||
- **`meshcore-ingestor`** — connects to MQTT brokers, decodes packets, writes to SQLite, maintains the in-memory store
|
||||
- **`meshcore-server`** — HTTP API, WebSocket broadcast, static file serving, analytics computation
|
||||
- **`corescope-ingestor`** — connects to MQTT brokers, decodes packets, writes to SQLite, maintains the in-memory store
|
||||
- **`corescope-server`** — HTTP API, WebSocket broadcast, static file serving, analytics computation
|
||||
|
||||
Both share the same SQLite database (WAL mode). The frontend is unchanged — same vanilla JS, same `public/` directory, served by the Go HTTP server through Caddy.
|
||||
|
||||
@@ -120,7 +120,7 @@ curl -s http://localhost/api/health | grep engine
|
||||
The Node.js Dockerfile is preserved as `Dockerfile.node`:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.node -t meshcore-analyzer:latest .
|
||||
docker build -f Dockerfile.node -t corescope:latest .
|
||||
docker compose up -d --force-recreate prod
|
||||
```
|
||||
|
||||
@@ -152,7 +152,7 @@ This release wouldn't exist without the community:
|
||||
- **LitBomb** — issue reports from production deployments
|
||||
- **mibzzer15** — issue reports and edge case discovery
|
||||
|
||||
And to everyone running MeshCore Analyzer in the wild — your packet data, bug reports, and feature requests are what drive this project forward. The Go rewrite happened because the community outgrew what Node.js could handle. 56K packets, dozens of observers, sub-second queries. This is your tool. We just rewrote the engine.
|
||||
And to everyone running CoreScope in the wild — your packet data, bug reports, and feature requests are what drive this project forward. The Go rewrite happened because the community outgrew what Node.js could handle. 56K packets, dozens of observers, sub-second queries. This is your tool. We just rewrote the engine.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ async function benchmarkEndpoints(port, endpoints, nocache = false) {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`\nMeshCore Analyzer Benchmark — ${RUNS} runs per endpoint`);
|
||||
console.log(`\nCoreScope Benchmark — ${RUNS} runs per endpoint`);
|
||||
console.log('Launching servers...\n');
|
||||
|
||||
// Launch both servers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MeshCore MQTT Ingestor (Go)
|
||||
|
||||
Standalone MQTT ingestion service for MeshCore Analyzer. Connects to MQTT brokers, decodes raw MeshCore packets, and writes to the same SQLite database used by the Node.js web server.
|
||||
Standalone MQTT ingestion service for CoreScope. Connects to MQTT brokers, decodes raw MeshCore packets, and writes to the same SQLite database used by the Node.js web server.
|
||||
|
||||
This is the first step of a larger Go rewrite — separating MQTT ingestion from the web server.
|
||||
|
||||
@@ -23,19 +23,19 @@ Requires Go 1.22+.
|
||||
|
||||
```bash
|
||||
cd cmd/ingestor
|
||||
go build -o meshcore-ingestor .
|
||||
go build -o corescope-ingestor .
|
||||
```
|
||||
|
||||
Cross-compile for Linux (e.g., for the production VM):
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o meshcore-ingestor .
|
||||
GOOS=linux GOARCH=amd64 go build -o corescope-ingestor .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./meshcore-ingestor -config /path/to/config.json
|
||||
./corescope-ingestor -config /path/to/config.json
|
||||
```
|
||||
|
||||
The config file uses the same format as the Node.js `config.json`. The ingestor reads the `mqttSources` array (or legacy `mqtt` object) and `dbPath` fields.
|
||||
|
||||
@@ -26,13 +26,14 @@ type MQTTLegacy struct {
|
||||
|
||||
// Config holds the ingestor configuration, compatible with the Node.js config.json format.
|
||||
type Config struct {
|
||||
DBPath string `json:"dbPath"`
|
||||
MQTT *MQTTLegacy `json:"mqtt,omitempty"`
|
||||
MQTTSources []MQTTSource `json:"mqttSources,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
|
||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
DBPath string `json:"dbPath"`
|
||||
MQTT *MQTTLegacy `json:"mqtt,omitempty"`
|
||||
MQTTSources []MQTTSource `json:"mqttSources,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
|
||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||
HashChannels []string `json:"hashChannels,omitempty"`
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
}
|
||||
|
||||
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
module github.com/meshcore-analyzer/ingestor
|
||||
module github.com/corescope/ingestor
|
||||
|
||||
go 1.22
|
||||
|
||||
|
||||
+588
-491
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -492,3 +494,132 @@ func TestAdvertRole(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveHashtagChannelKey(t *testing.T) {
|
||||
// Test vectors validated against Node.js server-helpers.js
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"#General", "649af2cab73ed5a890890a5485a0c004"},
|
||||
{"#test", "9cd8fcf22a47333b591d96a2b848b73f"},
|
||||
{"#MeshCore", "dcf73f393fa217f6b28fcec6ffc411ad"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := deriveHashtagChannelKey(tt.name)
|
||||
if got != tt.want {
|
||||
t.Errorf("deriveHashtagChannelKey(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deterministic
|
||||
k1 := deriveHashtagChannelKey("#foo")
|
||||
k2 := deriveHashtagChannelKey("#foo")
|
||||
if k1 != k2 {
|
||||
t.Error("deriveHashtagChannelKey should be deterministic")
|
||||
}
|
||||
|
||||
// Returns 32-char hex string (16 bytes)
|
||||
if len(k1) != 32 {
|
||||
t.Errorf("key length = %d, want 32", len(k1))
|
||||
}
|
||||
|
||||
// Different inputs → different keys
|
||||
k3 := deriveHashtagChannelKey("#bar")
|
||||
if k1 == k3 {
|
||||
t.Error("different inputs should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelKeysMergePriority(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
// Create a rainbow file with two keys: #rainbow (unique) and #override (to be overridden)
|
||||
rainbowPath := filepath.Join(dir, "channel-rainbow.json")
|
||||
t.Setenv("CHANNEL_KEYS_PATH", rainbowPath)
|
||||
rainbow := map[string]string{
|
||||
"#rainbow": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"#override": "rainbow_value_should_be_overridden",
|
||||
}
|
||||
rainbowJSON, err := json.Marshal(rainbow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(rainbowPath, rainbowJSON, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
HashChannels: []string{"General", "#override"},
|
||||
ChannelKeys: map[string]string{"#override": "explicit_wins"},
|
||||
}
|
||||
|
||||
keys := loadChannelKeys(cfg, cfgPath)
|
||||
|
||||
// Rainbow key loaded
|
||||
if keys["#rainbow"] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
||||
t.Errorf("rainbow key missing or wrong: %q", keys["#rainbow"])
|
||||
}
|
||||
|
||||
// HashChannels derived #General
|
||||
expected := deriveHashtagChannelKey("#General")
|
||||
if keys["#General"] != expected {
|
||||
t.Errorf("#General = %q, want %q (derived)", keys["#General"], expected)
|
||||
}
|
||||
|
||||
// Explicit config wins over both rainbow and derived
|
||||
if keys["#override"] != "explicit_wins" {
|
||||
t.Errorf("#override = %q, want explicit_wins", keys["#override"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelKeysHashChannelsNormalization(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KEYS_PATH", "")
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
HashChannels: []string{
|
||||
"NoPound", // should become #NoPound
|
||||
"#HasPound", // stays #HasPound
|
||||
" Spaced ", // trimmed → #Spaced
|
||||
"", // skipped
|
||||
},
|
||||
}
|
||||
|
||||
keys := loadChannelKeys(cfg, cfgPath)
|
||||
|
||||
if _, ok := keys["#NoPound"]; !ok {
|
||||
t.Error("should derive key for #NoPound (auto-prefixed)")
|
||||
}
|
||||
if _, ok := keys["#HasPound"]; !ok {
|
||||
t.Error("should derive key for #HasPound")
|
||||
}
|
||||
if _, ok := keys["#Spaced"]; !ok {
|
||||
t.Error("should derive key for #Spaced (trimmed)")
|
||||
}
|
||||
if len(keys) != 3 {
|
||||
t.Errorf("expected 3 keys, got %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelKeysSkipExplicit(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KEYS_PATH", "")
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
HashChannels: []string{"General"},
|
||||
ChannelKeys: map[string]string{"#General": "my_explicit_key"},
|
||||
}
|
||||
|
||||
keys := loadChannelKeys(cfg, cfgPath)
|
||||
|
||||
// Explicit key should win — hashChannels derivation should be skipped
|
||||
if keys["#General"] != "my_explicit_key" {
|
||||
t.Errorf("#General = %q, want my_explicit_key", keys["#General"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -45,14 +46,6 @@ func setupTestDBv2(t *testing.T) *DB {
|
||||
observer_id TEXT, observer_name TEXT, direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE VIEW packets_v AS
|
||||
SELECT o.id, t.raw_hex,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp,
|
||||
o.observer_id, o.observer_name,
|
||||
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
|
||||
t.payload_type, t.payload_version, o.path_json, t.decoded_json, t.created_at
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id;
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -550,8 +543,8 @@ func TestHandlePacketDetailNoStore(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/packets/abc123def4567890", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("expected 404 (no store), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -559,8 +552,8 @@ func TestHandlePacketDetailNoStore(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/packets/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("expected 404 (no store), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1474,8 +1467,8 @@ func TestHandleObserverAnalyticsNoStore(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != 503 {
|
||||
t.Fatalf("expected 503, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3271,20 +3264,6 @@ func TestHandlePacketDetailWithStoreAllPaths(t *testing.T) {
|
||||
|
||||
// --- Additional DB function coverage ---
|
||||
|
||||
func TestDBGetTimestamps(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
ts, err := db.GetTimestamps("2000-01-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ts) < 1 {
|
||||
t.Error("expected >=1 timestamps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBGetNewTransmissionsSince(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
+91
-209
@@ -120,31 +120,33 @@ func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} {
|
||||
|
||||
// Node represents a row from the nodes table.
|
||||
type Node struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
LastSeen *string `json:"last_seen"`
|
||||
FirstSeen *string `json:"first_seen"`
|
||||
AdvertCount int `json:"advert_count"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
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.
|
||||
@@ -160,7 +162,7 @@ type Transmission struct {
|
||||
CreatedAt *string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Observation (from packets_v view).
|
||||
// Observation (observation-level data).
|
||||
type Observation struct {
|
||||
ID int `json:"id"`
|
||||
RawHex *string `json:"raw_hex"`
|
||||
@@ -433,7 +435,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
||||
w = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
// Count total transmissions (fast — queries transmissions directly, not packets_v)
|
||||
// Count total transmissions (fast — queries transmissions directly, not a VIEW)
|
||||
var total int
|
||||
if len(where) == 0 {
|
||||
db.conn.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&total)
|
||||
@@ -626,18 +628,6 @@ func (db *DB) resolveNodePubkey(nodeIDOrName string) string {
|
||||
return pk
|
||||
}
|
||||
|
||||
// GetPacketByID fetches a single packet/observation.
|
||||
func (db *DB) GetPacketByID(id int) (map[string]interface{}, error) {
|
||||
rows, err := db.conn.Query("SELECT id, raw_hex, timestamp, observer_id, observer_name, direction, snr, rssi, score, hash, route_type, payload_type, payload_version, path_json, decoded_json, created_at FROM packets_v WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
return scanPacketRow(rows), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetTransmissionByID fetches from transmissions table with observer data.
|
||||
func (db *DB) GetTransmissionByID(id int) (map[string]interface{}, error) {
|
||||
@@ -671,24 +661,6 @@ func (db *DB) GetPacketByHash(hash string) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetObservationsForHash returns all observations for a given hash.
|
||||
func (db *DB) GetObservationsForHash(hash string) ([]map[string]interface{}, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, raw_hex, timestamp, observer_id, observer_name, direction, snr, rssi, score, hash, route_type, payload_type, payload_version, path_json, decoded_json, created_at
|
||||
FROM packets_v WHERE hash = ? ORDER BY timestamp DESC`, strings.ToLower(hash))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
p := scanPacketRow(rows)
|
||||
if p != nil {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNodes returns filtered, paginated node list.
|
||||
func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortBy, region string) ([]map[string]interface{}, int, map[string]int, error) {
|
||||
@@ -739,7 +711,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 +737,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 +757,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
|
||||
}
|
||||
@@ -796,30 +768,6 @@ func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetRecentPacketsForNode returns recent packets referencing a node.
|
||||
func (db *DB) GetRecentPacketsForNode(pubkey string, name string, limit int) ([]map[string]interface{}, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
pk := "%" + pubkey + "%"
|
||||
np := "%" + name + "%"
|
||||
rows, err := db.conn.Query(`SELECT id, raw_hex, timestamp, observer_id, observer_name, direction, snr, rssi, score, hash, route_type, payload_type, payload_version, path_json, decoded_json, created_at
|
||||
FROM packets_v WHERE decoded_json LIKE ? OR decoded_json LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT ?`, pk, np, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
packets := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
p := scanPacketRow(rows)
|
||||
if p != nil {
|
||||
packets = append(packets, p)
|
||||
}
|
||||
}
|
||||
return packets, nil
|
||||
}
|
||||
|
||||
// GetRecentTransmissionsForNode returns recent transmissions referencing a node (Node.js-compatible shape).
|
||||
func (db *DB) GetRecentTransmissionsForNode(pubkey string, name string, limit int) ([]map[string]interface{}, error) {
|
||||
@@ -958,9 +906,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 +929,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
|
||||
}
|
||||
|
||||
@@ -1019,103 +991,6 @@ func (db *DB) GetDistinctIATAs() ([]string, error) {
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// GetNodeHealth returns health info for a node (observers, stats, recent packets).
|
||||
func (db *DB) GetNodeHealth(pubkey string) (map[string]interface{}, error) {
|
||||
node, err := db.GetNodeByPubkey(pubkey)
|
||||
if err != nil || node == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := ""
|
||||
if n, ok := node["name"]; ok && n != nil {
|
||||
name = fmt.Sprintf("%v", n)
|
||||
}
|
||||
|
||||
pk := "%" + pubkey + "%"
|
||||
np := "%" + name + "%"
|
||||
whereClause := "decoded_json LIKE ? OR decoded_json LIKE ?"
|
||||
if name == "" {
|
||||
whereClause = "decoded_json LIKE ?"
|
||||
np = pk
|
||||
}
|
||||
|
||||
todayStart := time.Now().UTC().Truncate(24 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
// Observers
|
||||
observerSQL := fmt.Sprintf(`SELECT observer_id, observer_name, AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount
|
||||
FROM packets_v WHERE (%s) AND observer_id IS NOT NULL GROUP BY observer_id ORDER BY packetCount DESC`, whereClause)
|
||||
oRows, err := db.conn.Query(observerSQL, pk, np)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer oRows.Close()
|
||||
|
||||
observers := make([]map[string]interface{}, 0)
|
||||
for oRows.Next() {
|
||||
var obsID, obsName sql.NullString
|
||||
var avgSnr, avgRssi sql.NullFloat64
|
||||
var pktCount int
|
||||
oRows.Scan(&obsID, &obsName, &avgSnr, &avgRssi, &pktCount)
|
||||
observers = append(observers, map[string]interface{}{
|
||||
"observer_id": nullStr(obsID),
|
||||
"observer_name": nullStr(obsName),
|
||||
"avgSnr": nullFloat(avgSnr),
|
||||
"avgRssi": nullFloat(avgRssi),
|
||||
"packetCount": pktCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Stats
|
||||
var packetsToday, totalPackets int
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM packets_v WHERE (%s) AND timestamp > ?", whereClause), pk, np, todayStart).Scan(&packetsToday)
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM packets_v WHERE (%s)", whereClause), pk, np).Scan(&totalPackets)
|
||||
|
||||
var avgSnr sql.NullFloat64
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT AVG(snr) FROM packets_v WHERE (%s)", whereClause), pk, np).Scan(&avgSnr)
|
||||
|
||||
var lastHeard sql.NullString
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT MAX(timestamp) FROM packets_v WHERE (%s)", whereClause), pk, np).Scan(&lastHeard)
|
||||
|
||||
// Avg hops
|
||||
hRows, _ := db.conn.Query(fmt.Sprintf("SELECT path_json FROM packets_v WHERE (%s) AND path_json IS NOT NULL", whereClause), pk, np)
|
||||
totalHops, hopCount := 0, 0
|
||||
if hRows != nil {
|
||||
defer hRows.Close()
|
||||
for hRows.Next() {
|
||||
var pj sql.NullString
|
||||
hRows.Scan(&pj)
|
||||
if pj.Valid {
|
||||
var hops []interface{}
|
||||
if json.Unmarshal([]byte(pj.String), &hops) == nil {
|
||||
totalHops += len(hops)
|
||||
hopCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
avgHops := 0
|
||||
if hopCount > 0 {
|
||||
avgHops = int(math.Round(float64(totalHops) / float64(hopCount)))
|
||||
}
|
||||
|
||||
// Recent packets
|
||||
recentPackets, _ := db.GetRecentTransmissionsForNode(pubkey, name, 20)
|
||||
|
||||
return map[string]interface{}{
|
||||
"node": node,
|
||||
"observers": observers,
|
||||
"stats": map[string]interface{}{
|
||||
"totalTransmissions": totalPackets,
|
||||
"totalObservations": totalPackets,
|
||||
"totalPackets": totalPackets,
|
||||
"packetsToday": packetsToday,
|
||||
"avgSnr": nullFloat(avgSnr),
|
||||
"avgHops": avgHops,
|
||||
"lastHeard": nullStr(lastHeard),
|
||||
},
|
||||
"recentPackets": recentPackets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetNetworkStatus returns overall network health status.
|
||||
func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]interface{}, error) {
|
||||
@@ -1164,10 +1039,28 @@ func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]in
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTraces returns observations for a hash.
|
||||
// GetTraces returns observations for a hash using direct table queries.
|
||||
func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) {
|
||||
rows, err := db.conn.Query(`SELECT observer_id, observer_name, timestamp, snr, rssi, path_json
|
||||
FROM packets_v WHERE hash = ? ORDER BY timestamp ASC`, strings.ToLower(hash))
|
||||
var querySQL string
|
||||
if db.isV3 {
|
||||
querySQL = `SELECT obs.id AS observer_id, obs.name AS observer_name,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp,
|
||||
o.snr, o.rssi, o.path_json
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
WHERE t.hash = ?
|
||||
ORDER BY o.timestamp ASC`
|
||||
} else {
|
||||
querySQL = `SELECT o.observer_id, o.observer_name,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp,
|
||||
o.snr, o.rssi, o.path_json
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
WHERE t.hash = ?
|
||||
ORDER BY o.timestamp ASC`
|
||||
}
|
||||
rows, err := db.conn.Query(querySQL, strings.ToLower(hash))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1193,7 +1086,7 @@ func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
// GetChannels returns channel list from GRP_TXT packets.
|
||||
// Queries transmissions directly (not packets_v) to avoid observation-level
|
||||
// Queries transmissions directly (not a VIEW) to avoid observation-level
|
||||
// duplicates that could cause stale lastMessage when an older message has
|
||||
// a later re-observation timestamp.
|
||||
func (db *DB) GetChannels() ([]map[string]interface{}, error) {
|
||||
@@ -1409,31 +1302,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int) ([]map[s
|
||||
return messages, total, nil
|
||||
}
|
||||
|
||||
// GetTimestamps returns packet timestamps since a given time.
|
||||
func (db *DB) GetTimestamps(since string) ([]string, error) {
|
||||
rows, err := db.conn.Query("SELECT timestamp FROM packets_v WHERE timestamp > ? ORDER BY timestamp ASC", since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var timestamps []string
|
||||
for rows.Next() {
|
||||
var ts string
|
||||
rows.Scan(&ts)
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
if timestamps == nil {
|
||||
timestamps = []string{}
|
||||
}
|
||||
return timestamps, nil
|
||||
}
|
||||
|
||||
// GetNodeCountsForPacket returns observation count for a hash.
|
||||
func (db *DB) GetObservationCount(hash string) int {
|
||||
var count int
|
||||
db.conn.QueryRow("SELECT COUNT(*) FROM packets_v WHERE hash = ?", strings.ToLower(hash)).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// GetNewTransmissionsSince returns new transmissions after a given ID for WebSocket polling.
|
||||
func (db *DB) GetNewTransmissionsSince(lastID int, limit int) ([]map[string]interface{}, error) {
|
||||
@@ -1634,11 +1503,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 +1522,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{} {
|
||||
|
||||
+133
-204
@@ -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 (
|
||||
@@ -71,16 +73,6 @@ func setupTestDB(t *testing.T) *DB {
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIEW packets_v AS
|
||||
SELECT o.id, t.raw_hex,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp,
|
||||
obs.id AS observer_id, obs.name AS observer_name,
|
||||
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
|
||||
t.payload_type, t.payload_version, o.path_json, t.decoded_json,
|
||||
t.created_at
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx;
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -369,6 +361,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()
|
||||
@@ -485,51 +559,6 @@ func TestGetNewTransmissionsSince(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObservationsForHash(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
obs, err := db.GetObservationsForHash("abc123def4567890")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(obs) != 2 {
|
||||
t.Errorf("expected 2 observations, got %d", len(obs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPacketByIDFound(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
pkt, err := db.GetPacketByID(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkt == nil {
|
||||
t.Fatal("expected packet, got nil")
|
||||
}
|
||||
if pkt["hash"] != "abc123def4567890" {
|
||||
t.Errorf("expected hash abc123def4567890, got %v", pkt["hash"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPacketByIDNotFound(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
pkt, err := db.GetPacketByID(9999)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkt != nil {
|
||||
t.Error("expected nil for nonexistent packet ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTransmissionByIDFound(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -572,34 +601,6 @@ func TestGetPacketByHashNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentPacketsForNode(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(packets) == 0 {
|
||||
t.Error("expected packets for TestRepeater")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentPacketsForNodeDefaultLimit(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if packets == nil {
|
||||
t.Error("expected non-nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObserverIdsForRegion(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -649,46 +650,6 @@ func TestGetObserverIdsForRegion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetNodeHealth(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
t.Run("found", func(t *testing.T) {
|
||||
result, err := db.GetNodeHealth("aabbccdd11223344")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
node, ok := result["node"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected node object")
|
||||
}
|
||||
if node["name"] != "TestRepeater" {
|
||||
t.Errorf("expected TestRepeater, got %v", node["name"])
|
||||
}
|
||||
stats, ok := result["stats"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected stats object")
|
||||
}
|
||||
if stats["totalPackets"] == nil {
|
||||
t.Error("expected totalPackets in stats")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
result, err := db.GetNodeHealth("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil for nonexistent node")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChannelMessages(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -731,48 +692,6 @@ func TestGetChannelMessages(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTimestamps(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
t.Run("with results", func(t *testing.T) {
|
||||
ts, err := db.GetTimestamps("2020-01-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ts) == 0 {
|
||||
t.Error("expected timestamps")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
ts, err := db.GetTimestamps("2099-01-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ts) != 0 {
|
||||
t.Errorf("expected 0 timestamps, got %d", len(ts))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetObservationCount(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
count := db.GetObservationCount("abc123def4567890")
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
|
||||
count = db.GetObservationCount("nonexistent")
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 for nonexistent, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketWhereFilters(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -1196,29 +1115,6 @@ func TestOpenDBInvalidPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHealthNoName(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Insert a node without a name
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, role, last_seen, first_seen, advert_count)
|
||||
VALUES ('deadbeef12345678', 'repeater', '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 5)`)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('DDEE', 'deadbeefhash1234', '2026-01-15T10:05:00Z', 1, 4,
|
||||
'{"pubKey":"deadbeef12345678","type":"ADVERT"}')`)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 11.0, -91, '["dd"]', 1736935500)`)
|
||||
|
||||
result, err := db.GetNodeHealth("deadbeef12345678")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannelMessagesObserverFallback(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -1299,20 +1195,6 @@ func TestQueryGroupedPacketsWithFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTracesEmpty(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
traces, err := db.GetTraces("nonexistenthash1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(traces) != 0 {
|
||||
t.Errorf("expected 0 traces, got %d", len(traces))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullHelpers(t *testing.T) {
|
||||
// nullStr
|
||||
if nullStr(sql.NullString{Valid: false}) != nil {
|
||||
@@ -1386,6 +1268,53 @@ func TestGetChannelsStaleMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeTelemetryFields(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Insert node with telemetry data
|
||||
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)`)
|
||||
|
||||
// Test via GetNodeByPubkey
|
||||
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"])
|
||||
}
|
||||
|
||||
// Test via GetNodes
|
||||
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"])
|
||||
}
|
||||
|
||||
// Test node without telemetry — fields should be nil
|
||||
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())
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
module github.com/meshcore-analyzer/server
|
||||
module github.com/corescope/server
|
||||
|
||||
go 1.22
|
||||
|
||||
|
||||
+18
-3
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@@ -54,6 +55,20 @@ 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)
|
||||
if err := http.ListenAndServe(":"+pprofPort, nil); err != nil {
|
||||
log.Printf("[pprof] failed to start: %v (non-fatal)", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var (
|
||||
configDir string
|
||||
port int
|
||||
@@ -101,7 +116,7 @@ func main() {
|
||||
var tableName string
|
||||
err = database.conn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='transmissions'").Scan(&tableName)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Fatalf("[db] table 'transmissions' not found — is this a MeshCore Analyzer database?")
|
||||
log.Fatalf("[db] table 'transmissions' not found — is this a CoreScope database?")
|
||||
}
|
||||
|
||||
stats, err := database.GetStats()
|
||||
@@ -140,7 +155,7 @@ func main() {
|
||||
log.Printf("[static] directory %s not found — API-only mode", absPublic)
|
||||
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><h1>MeshCore Analyzer</h1><p>Frontend not found. API available at /api/</p></body></html>`))
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body><h1>CoreScope</h1><p>Frontend not found. API available at /api/</p></body></html>`))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,7 +182,7 @@ func main() {
|
||||
httpServer.Close()
|
||||
}()
|
||||
|
||||
log.Printf("[server] MeshCore Analyzer (Go) listening on http://localhost:%d", cfg.Port)
|
||||
log.Printf("[server] CoreScope (Go) listening on http://localhost:%d", cfg.Port)
|
||||
if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||
log.Fatalf("[server] %v", err)
|
||||
}
|
||||
|
||||
+274
-797
File diff suppressed because it is too large
Load Diff
+38
-11
@@ -18,7 +18,9 @@ func setupTestServer(t *testing.T) (*Server, *mux.Router) {
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db)
|
||||
store.Load()
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
@@ -722,6 +724,9 @@ func TestNodePathsFound(t *testing.T) {
|
||||
if body["paths"] == nil {
|
||||
t.Error("expected paths in response")
|
||||
}
|
||||
if got, ok := body["totalTransmissions"].(float64); !ok || got < 1 {
|
||||
t.Errorf("expected totalTransmissions >= 1, got %v", body["totalTransmissions"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodePathsNotFound(t *testing.T) {
|
||||
@@ -832,6 +837,9 @@ func TestObserverAnalytics(t *testing.T) {
|
||||
if body["recentPackets"] == nil {
|
||||
t.Error("expected recentPackets")
|
||||
}
|
||||
if recent, ok := body["recentPackets"].([]interface{}); !ok || len(recent) == 0 {
|
||||
t.Errorf("expected non-empty recentPackets, got %v", body["recentPackets"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom days", func(t *testing.T) {
|
||||
@@ -1251,6 +1259,11 @@ func TestNodeAnalyticsNoNameNode(t *testing.T) {
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
@@ -1282,6 +1295,11 @@ func TestNodeHealthForNoNameNode(t *testing.T) {
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
@@ -1521,8 +1539,6 @@ func TestHandlerErrorPaths(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
// Drop the view to force query errors
|
||||
db.conn.Exec("DROP VIEW IF EXISTS packets_v")
|
||||
|
||||
t.Run("stats error", func(t *testing.T) {
|
||||
db.conn.Exec("DROP TABLE IF EXISTS transmissions")
|
||||
@@ -1563,7 +1579,7 @@ func TestHandlerErrorTraces(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
db.conn.Exec("DROP VIEW IF EXISTS packets_v")
|
||||
db.conn.Exec("DROP TABLE IF EXISTS observations")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/traces/abc123def4567890", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1697,13 +1713,12 @@ func TestHandlerErrorTimestamps(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
db.conn.Exec("DROP VIEW IF EXISTS packets_v")
|
||||
|
||||
// Without a store, timestamps returns empty 200
|
||||
req := httptest.NewRequest("GET", "/api/packets/timestamps?since=2020-01-01", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 500 {
|
||||
t.Errorf("expected 500 for timestamps error, got %d", w.Code)
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200 for timestamps without store, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1740,8 +1755,8 @@ func TestHandlerErrorBulkHealth(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/nodes/bulk-health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 500 {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1876,7 +1891,9 @@ func TestGetNodeHashSizeInfoFlipFlop(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db)
|
||||
store.Load()
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
|
||||
@@ -1934,7 +1951,17 @@ for _, field := range arrayFields {
|
||||
if body[field] == nil {
|
||||
t.Errorf("field %q is null, expected []", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestObserverAnalyticsNoStore(t *testing.T) {
|
||||
_, router := setupNoStoreServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 503 {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
|
||||
+55
-48
@@ -62,7 +62,7 @@ type StoreObs struct {
|
||||
type PacketStore struct {
|
||||
mu sync.RWMutex
|
||||
db *DB
|
||||
packets []*StoreTx // sorted by first_seen DESC
|
||||
packets []*StoreTx // sorted by first_seen ASC (oldest first; newest at tail)
|
||||
byHash map[string]*StoreTx // hash → *StoreTx
|
||||
byTxID map[int]*StoreTx // transmission_id → *StoreTx
|
||||
byObsID map[int]*StoreObs // observation_id → *StoreObs
|
||||
@@ -176,7 +176,7 @@ func (s *PacketStore) Load() error {
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC`
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
} else {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
@@ -184,7 +184,7 @@ func (s *PacketStore) Load() error {
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC`
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
}
|
||||
|
||||
rows, err := s.db.conn.Query(loadSQL)
|
||||
@@ -368,28 +368,32 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||||
results := s.filterPackets(q)
|
||||
total := len(results)
|
||||
|
||||
if q.Order == "ASC" {
|
||||
sorted := make([]*StoreTx, len(results))
|
||||
copy(sorted, results)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].FirstSeen < sorted[j].FirstSeen
|
||||
})
|
||||
results = sorted
|
||||
}
|
||||
|
||||
// Paginate
|
||||
// results is oldest-first (ASC). For DESC (default) read backwards from the tail;
|
||||
// for ASC read forwards. Both are O(page_size) — no sort copy needed.
|
||||
start := q.Offset
|
||||
if start >= len(results) {
|
||||
if start >= total {
|
||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||
}
|
||||
end := start + q.Limit
|
||||
if end > len(results) {
|
||||
end = len(results)
|
||||
pageSize := q.Limit
|
||||
if start+pageSize > total {
|
||||
pageSize = total - start
|
||||
}
|
||||
|
||||
packets := make([]map[string]interface{}, 0, end-start)
|
||||
for _, tx := range results[start:end] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
packets := make([]map[string]interface{}, 0, pageSize)
|
||||
if q.Order == "ASC" {
|
||||
for _, tx := range results[start : start+pageSize] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
}
|
||||
} else {
|
||||
// DESC: newest items are at the tail; page 0 = last pageSize items reversed
|
||||
endIdx := total - start
|
||||
startIdx := endIdx - pageSize
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
for i := endIdx - 1; i >= startIdx; i-- {
|
||||
packets = append(packets, txToMap(results[i]))
|
||||
}
|
||||
}
|
||||
return &PacketResult{Packets: packets, Total: total}
|
||||
}
|
||||
@@ -719,15 +723,16 @@ func (s *PacketStore) GetTimestamps(since string) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// packets sorted newest first — scan from start until older than since
|
||||
// packets sorted oldest-first — scan from tail until we reach items older than since
|
||||
var result []string
|
||||
for _, tx := range s.packets {
|
||||
for i := len(s.packets) - 1; i >= 0; i-- {
|
||||
tx := s.packets[i]
|
||||
if tx.FirstSeen <= since {
|
||||
break
|
||||
}
|
||||
result = append(result, tx.FirstSeen)
|
||||
}
|
||||
// Reverse to get ASC order
|
||||
// result is currently newest-first; reverse to return ASC order
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
@@ -777,23 +782,30 @@ func (s *PacketStore) QueryMultiNodePackets(pubkeys []string, limit, offset int,
|
||||
|
||||
total := len(filtered)
|
||||
|
||||
if order == "ASC" {
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].FirstSeen < filtered[j].FirstSeen
|
||||
})
|
||||
}
|
||||
|
||||
// filtered is oldest-first (built by iterating s.packets forward).
|
||||
// Apply same DESC/ASC pagination logic as QueryPackets.
|
||||
if offset >= total {
|
||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
pageSize := limit
|
||||
if offset+pageSize > total {
|
||||
pageSize = total - offset
|
||||
}
|
||||
|
||||
packets := make([]map[string]interface{}, 0, end-offset)
|
||||
for _, tx := range filtered[offset:end] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
packets := make([]map[string]interface{}, 0, pageSize)
|
||||
if order == "ASC" {
|
||||
for _, tx := range filtered[offset : offset+pageSize] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
}
|
||||
} else {
|
||||
endIdx := total - offset
|
||||
startIdx := endIdx - pageSize
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
for i := endIdx - 1; i >= startIdx; i-- {
|
||||
packets = append(packets, txToMap(filtered[i]))
|
||||
}
|
||||
}
|
||||
return &PacketResult{Packets: packets, Total: total}
|
||||
}
|
||||
@@ -926,15 +938,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
DecodedJSON: r.decodedJSON,
|
||||
}
|
||||
s.byHash[r.hash] = tx
|
||||
// Prepend (newest first)
|
||||
s.packets = append([]*StoreTx{tx}, s.packets...)
|
||||
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
||||
s.byTxID[r.txID] = tx
|
||||
s.indexByNode(tx)
|
||||
if tx.PayloadType != nil {
|
||||
pt := *tx.PayloadType
|
||||
// Prepend to maintain newest-first order (matches Load ordering)
|
||||
// Append to maintain oldest-first order (matches Load ordering)
|
||||
// so GetChannelMessages reverse iteration stays correct
|
||||
s.byPayloadType[pt] = append([]*StoreTx{tx}, s.byPayloadType[pt]...)
|
||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||
}
|
||||
|
||||
if _, exists := broadcastTxs[r.txID]; !exists {
|
||||
@@ -1079,8 +1090,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
log.Printf("[poller] IngestNewFromDB: found %d new txs, maxID %d->%d", len(result), sinceID, newMaxID)
|
||||
|
||||
return result, newMaxID
|
||||
}
|
||||
|
||||
@@ -1263,8 +1272,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
log.Printf("[poller] IngestNewObservations: updated %d existing txs, maxObsID %d->%d",
|
||||
len(updatedTxs), sinceObsID, newMaxObsID)
|
||||
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
||||
}
|
||||
|
||||
return newMaxObsID
|
||||
@@ -1888,7 +1896,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int)
|
||||
msgMap := map[string]*msgEntry{}
|
||||
var msgOrder []string
|
||||
|
||||
// Iterate type-5 packets oldest-first (byPayloadType is in load order = newest first)
|
||||
// Iterate type-5 packets oldest-first (byPayloadType is ASC = oldest first)
|
||||
type decodedMsg struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
@@ -1899,8 +1907,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int)
|
||||
}
|
||||
|
||||
grpTxts := s.byPayloadType[5]
|
||||
for i := len(grpTxts) - 1; i >= 0; i-- {
|
||||
tx := grpTxts[i]
|
||||
for _, tx := range grpTxts {
|
||||
if tx.DecodedJSON == "" {
|
||||
continue
|
||||
}
|
||||
@@ -4069,13 +4076,13 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
|
||||
lhVal = lastHeard
|
||||
}
|
||||
|
||||
// Recent packets (up to 20, newest first — packets are already sorted DESC)
|
||||
// Recent packets (up to 20, newest first — read from tail of oldest-first slice)
|
||||
recentLimit := 20
|
||||
if len(packets) < recentLimit {
|
||||
recentLimit = len(packets)
|
||||
}
|
||||
recentPackets := make([]map[string]interface{}, 0, recentLimit)
|
||||
for i := 0; i < recentLimit; i++ {
|
||||
for i := len(packets) - 1; i >= len(packets)-recentLimit; i-- {
|
||||
p := txToMap(packets[i])
|
||||
delete(p, "observations")
|
||||
recentPackets = append(recentPackets, p)
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"key": "/path/to/key.pem"
|
||||
},
|
||||
"branding": {
|
||||
"siteName": "MeshCore Analyzer",
|
||||
"siteName": "CoreScope",
|
||||
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
|
||||
"logoUrl": null,
|
||||
"faviconUrl": null
|
||||
@@ -32,7 +32,7 @@
|
||||
"observer": "#8b5cf6"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "MeshCore Analyzer",
|
||||
"heroTitle": "CoreScope",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-7
@@ -1,10 +1,12 @@
|
||||
# Volume paths unified with manage.sh — see manage.sh lines 9-12, 56-68, 98-113
|
||||
# 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:
|
||||
image: meshcore-analyzer:latest
|
||||
container_name: meshcore-prod
|
||||
image: corescope:latest
|
||||
container_name: corescope-prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
|
||||
@@ -24,8 +26,8 @@ services:
|
||||
retries: 3
|
||||
|
||||
staging:
|
||||
image: meshcore-analyzer:latest
|
||||
container_name: meshcore-staging
|
||||
image: corescope:latest
|
||||
container_name: corescope-staging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STAGING_HTTP_PORT:-81}:${STAGING_HTTP_PORT:-81}"
|
||||
@@ -52,18 +54,21 @@ services:
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
image: meshcore-go:latest
|
||||
container_name: meshcore-staging-go
|
||||
image: corescope-go:latest
|
||||
container_name: corescope-staging-go
|
||||
restart: unless-stopped
|
||||
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
|
||||
@@ -73,6 +78,7 @@ services:
|
||||
- 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:
|
||||
|
||||
@@ -14,8 +14,8 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:meshcore-ingestor]
|
||||
command=/app/meshcore-ingestor -config /app/config.json
|
||||
[program:corescope-ingestor]
|
||||
command=/app/corescope-ingestor -config /app/config.json
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=true
|
||||
@@ -24,8 +24,8 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:meshcore-server]
|
||||
command=/app/meshcore-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000
|
||||
[program:corescope-server]
|
||||
command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=true
|
||||
|
||||
@@ -14,7 +14,7 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:meshcore-analyzer]
|
||||
[program:corescope]
|
||||
command=node /app/server.js
|
||||
directory=/app
|
||||
autostart=true
|
||||
|
||||
@@ -27,7 +27,7 @@ No restart needed. The server picks up changes to `theme.json` on every page loa
|
||||
**Bare metal / PM2 / systemd:**
|
||||
```bash
|
||||
# Same directory as server.js and config.json
|
||||
cp theme.json /path/to/meshcore-analyzer/
|
||||
cp theme.json /path/to/corescope/
|
||||
```
|
||||
|
||||
Check the server logs on startup — it tells you where it's looking:
|
||||
|
||||
+22
-22
@@ -1,6 +1,6 @@
|
||||
# Deploying MeshCore Analyzer
|
||||
# Deploying CoreScope
|
||||
|
||||
Get MeshCore Analyzer running with automatic HTTPS on your own server.
|
||||
Get CoreScope running with automatic HTTPS on your own server.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -19,7 +19,7 @@ Get MeshCore Analyzer running with automatic HTTPS on your own server.
|
||||
|
||||
## What You'll End Up With
|
||||
|
||||
- MeshCore Analyzer running at `https://your-domain.com`
|
||||
- CoreScope running at `https://your-domain.com`
|
||||
- Automatic HTTPS certificates (via Let's Encrypt + Caddy)
|
||||
- Built-in MQTT broker for receiving packets from observers
|
||||
- SQLite database for packet storage (auto-created)
|
||||
@@ -83,8 +83,8 @@ docker --version
|
||||
The easiest way — use the management script:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
|
||||
cd meshcore-analyzer
|
||||
git clone https://github.com/Kpa-clawbot/corescope.git
|
||||
cd corescope
|
||||
./manage.sh setup
|
||||
```
|
||||
|
||||
@@ -111,8 +111,8 @@ flowchart LR
|
||||
### 1. Download the code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
|
||||
cd meshcore-analyzer
|
||||
git clone https://github.com/Kpa-clawbot/corescope.git
|
||||
cd corescope
|
||||
```
|
||||
|
||||
### 2. Create your config
|
||||
@@ -153,10 +153,10 @@ Save and close. Caddy handles certificates, renewals, and HTTP→HTTPS redirects
|
||||
### 4. Build and run
|
||||
|
||||
```bash
|
||||
docker build -t meshcore-analyzer .
|
||||
docker build -t corescope .
|
||||
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
--name corescope \
|
||||
--restart unless-stopped \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
@@ -164,7 +164,7 @@ docker run -d \
|
||||
-v $(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
-v meshcore-data:/app/data \
|
||||
-v caddy-data:/data/caddy \
|
||||
meshcore-analyzer
|
||||
corescope
|
||||
```
|
||||
|
||||
What each flag does:
|
||||
@@ -184,12 +184,12 @@ Open `https://your-domain.com`. You should see the analyzer home page.
|
||||
|
||||
Check the logs:
|
||||
```bash
|
||||
docker logs meshcore-analyzer
|
||||
docker logs corescope
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
MeshCore Analyzer running on http://localhost:3000
|
||||
CoreScope running on http://localhost:3000
|
||||
MQTT [local] connected to mqtt://localhost:1883
|
||||
[pre-warm] 12 endpoints in XXXms
|
||||
```
|
||||
@@ -215,7 +215,7 @@ Add a remote broker to `mqttSources` in your `config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart: `docker restart meshcore-analyzer`
|
||||
Restart: `docker restart corescope`
|
||||
|
||||
### Option B: Run your own observer
|
||||
|
||||
@@ -271,12 +271,12 @@ If you already run a reverse proxy, skip Caddy entirely and proxy directly to th
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
--name corescope \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
-v meshcore-data:/app/data \
|
||||
meshcore-analyzer
|
||||
corescope
|
||||
```
|
||||
|
||||
Then configure your existing proxy to forward traffic to `localhost:3000`.
|
||||
@@ -287,12 +287,12 @@ For local testing or a LAN-only setup, use the default Caddyfile that ships in t
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
--name corescope \
|
||||
--restart unless-stopped \
|
||||
-p 80:80 \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
-v meshcore-data:/app/data \
|
||||
meshcore-analyzer
|
||||
corescope
|
||||
```
|
||||
|
||||
## MQTT Security
|
||||
@@ -315,7 +315,7 @@ password_file /etc/mosquitto/passwd
|
||||
```
|
||||
After starting the container, create users:
|
||||
```bash
|
||||
docker exec -it meshcore-analyzer mosquitto_passwd -c /etc/mosquitto/passwd myuser
|
||||
docker exec -it corescope mosquitto_passwd -c /etc/mosquitto/passwd myuser
|
||||
```
|
||||
|
||||
**Option 3: Use TLS** — For production, configure Mosquitto with TLS certificates. See the [Mosquitto docs](https://mosquitto.org/man/mosquitto-conf-5.html).
|
||||
@@ -331,7 +331,7 @@ Packet data is stored in `meshcore.db` inside the data volume.
|
||||
**Using manage.sh (easiest):**
|
||||
|
||||
```bash
|
||||
./manage.sh backup # Saves to ./backups/meshcore-TIMESTAMP.db
|
||||
./manage.sh backup # Saves to ./backups/corescope-TIMESTAMP/
|
||||
./manage.sh backup ~/my-backup.db # Custom path
|
||||
./manage.sh restore ./backups/some-file.db # Restore (backs up current DB first)
|
||||
```
|
||||
@@ -345,7 +345,7 @@ If you used `-v ./analyzer-data:/app/data` instead of a Docker volume, the datab
|
||||
```bash
|
||||
crontab -e
|
||||
# Add:
|
||||
0 3 * * * cd /path/to/meshcore-analyzer && ./manage.sh backup
|
||||
0 3 * * * cd /path/to/corescope && ./manage.sh backup
|
||||
```
|
||||
|
||||
## Updating
|
||||
@@ -398,11 +398,11 @@ Center the map on your area in `config.json`:
|
||||
|
||||
| Problem | Likely cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Site shows "connection refused" | Container not running | `docker ps` to check, `docker logs meshcore-analyzer` for errors |
|
||||
| Site shows "connection refused" | Container not running | `docker ps` to check, `docker logs corescope` for errors |
|
||||
| HTTPS not working | Port 80 blocked | Open port 80 — Caddy needs it for ACME challenges |
|
||||
| "too many certificates" error | Let's Encrypt rate limit (5/domain/week) | Use a different subdomain, bring your own cert, or wait a week |
|
||||
| Certificate won't provision | DNS not pointed at server | `dig your-domain` must show your server IP before starting |
|
||||
| No packets appearing | No observer connected | `docker exec meshcore-analyzer mosquitto_sub -t 'meshcore/#' -C 1 -W 10` — if silent, no data is coming in |
|
||||
| No packets appearing | No observer connected | `docker exec corescope mosquitto_sub -t 'meshcore/#' -C 1 -W 10` — if silent, no data is coming in |
|
||||
| Container crashes on startup | Bad JSON in config | `python3 -c "import json; json.load(open('config.json'))"` to validate |
|
||||
| "address already in use" | Another web server on 80/443 | Stop it: `sudo systemctl stop nginx apache2` |
|
||||
| Slow on Raspberry Pi | First build is slow | Normal — subsequent builds use cache. Runtime performance is fine. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hash Prefix Disambiguation in MeshCore Analyzer
|
||||
# Hash Prefix Disambiguation in CoreScope
|
||||
|
||||
## Section 1: Executive Summary
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# MeshCore Analyzer — API Contract Specification
|
||||
# CoreScope — API Contract Specification
|
||||
|
||||
> **Authoritative contract.** Both the Node.js and Go backends MUST conform to this spec.
|
||||
> The frontend relies on these exact shapes. Breaking changes require a spec update first.
|
||||
@@ -1547,7 +1547,7 @@ Theme and branding configuration (merged from config.json + theme.json).
|
||||
```jsonc
|
||||
{
|
||||
"branding": {
|
||||
"siteName": string, // default: "MeshCore Analyzer"
|
||||
"siteName": string, // default: "CoreScope"
|
||||
"tagline": string // default: "Real-time MeshCore LoRa mesh network analyzer"
|
||||
// ... additional branding keys from config/theme files
|
||||
},
|
||||
|
||||
+17
-17
@@ -1,6 +1,6 @@
|
||||
# Migrating from Node.js to Go Engine
|
||||
|
||||
Guide for existing MeshCore Analyzer users switching from the Node.js Docker image to the Go version.
|
||||
Guide for existing CoreScope users switching from the Node.js Docker image to the Go version.
|
||||
|
||||
> **Status (July 2025):** The Go engine is fully functional for production use.
|
||||
> Go images are **not yet published to Docker Hub** — you build locally from source.
|
||||
@@ -24,11 +24,11 @@ Guide for existing MeshCore Analyzer users switching from the Node.js Docker ima
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** 20.10+ and **Docker Compose** v2 (verify: `docker compose version`)
|
||||
- An existing MeshCore Analyzer deployment running the Node.js image
|
||||
- An existing CoreScope deployment running the Node.js image
|
||||
- The repository cloned locally (needed to build the Go image):
|
||||
```bash
|
||||
git clone https://github.com/meshcore-dev/meshcore-analyzer.git
|
||||
cd meshcore-analyzer
|
||||
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
|
||||
cd corescope
|
||||
git pull # get latest
|
||||
```
|
||||
- Your `config.json` and `caddy-config/Caddyfile` in place (the same ones you use now)
|
||||
@@ -122,7 +122,7 @@ docker compose --profile staging-go build staging-go
|
||||
Or build directly:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.go -t meshcore-go:latest \
|
||||
docker build -f Dockerfile.go -t corescope-go:latest \
|
||||
--build-arg APP_VERSION=$(git describe --tags 2>/dev/null || echo unknown) \
|
||||
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) \
|
||||
.
|
||||
@@ -151,7 +151,7 @@ Once satisfied, update `docker-compose.yml` to use the Go image for prod:
|
||||
```yaml
|
||||
services:
|
||||
prod:
|
||||
image: meshcore-go:latest # was: meshcore-analyzer:latest
|
||||
image: corescope-go:latest # was: corescope:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.go # add this
|
||||
@@ -174,9 +174,9 @@ docker compose up -d prod
|
||||
./manage.sh stop
|
||||
|
||||
# Build the Go image
|
||||
docker build -f Dockerfile.go -t meshcore-analyzer:latest .
|
||||
docker build -f Dockerfile.go -t corescope:latest .
|
||||
|
||||
# Start (manage.sh uses the meshcore-analyzer:latest image)
|
||||
# Start (manage.sh uses the corescope:latest image)
|
||||
./manage.sh start
|
||||
```
|
||||
|
||||
@@ -248,7 +248,7 @@ These should match (or be close to) your pre-migration numbers.
|
||||
|
||||
```bash
|
||||
# Watch container logs for MQTT messages
|
||||
docker logs -f meshcore-prod --tail 20
|
||||
docker logs -f corescope-prod --tail 20
|
||||
|
||||
# Or use manage.sh
|
||||
./manage.sh mqtt-test
|
||||
@@ -279,13 +279,13 @@ If something goes wrong, switching back is straightforward:
|
||||
```yaml
|
||||
services:
|
||||
prod:
|
||||
image: meshcore-analyzer:latest # back to Node.js
|
||||
image: corescope:latest # back to Node.js
|
||||
# Remove the build.dockerfile line if you added it
|
||||
```
|
||||
|
||||
```bash
|
||||
# Rebuild Node.js image if needed
|
||||
docker build -t meshcore-analyzer:latest .
|
||||
docker build -t corescope:latest .
|
||||
|
||||
docker compose up -d --force-recreate prod
|
||||
```
|
||||
@@ -295,8 +295,8 @@ docker compose up -d --force-recreate prod
|
||||
```bash
|
||||
./manage.sh stop
|
||||
|
||||
# Rebuild Node.js image (overwrites the meshcore-analyzer:latest tag)
|
||||
docker build -t meshcore-analyzer:latest .
|
||||
# Rebuild Node.js image (overwrites the corescope:latest tag)
|
||||
docker build -t corescope:latest .
|
||||
|
||||
./manage.sh start
|
||||
```
|
||||
@@ -310,9 +310,9 @@ docker build -t meshcore-analyzer:latest .
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
docker stop meshcore-prod
|
||||
docker stop corescope-prod
|
||||
cp backups/pre-go-migration/meshcore.db ~/meshcore-data/meshcore.db
|
||||
docker start meshcore-prod
|
||||
docker start corescope-prod
|
||||
```
|
||||
|
||||
---
|
||||
@@ -348,7 +348,7 @@ docker start meshcore-prod
|
||||
|------|---------|-----|
|
||||
| `engine` field in `/api/health` | Not present or `"node"` | Always `"go"` |
|
||||
| MQTT URL scheme | Uses `mqtt://` / `mqtts://` natively | Auto-converts to `tcp://` / `ssl://` (transparent) |
|
||||
| Process model | Single Node.js process (server + ingestor) | Two binaries: `meshcore-ingestor` + `meshcore-server` (managed by supervisord) |
|
||||
| Process model | Single Node.js process (server + ingestor) | Two binaries: `corescope-ingestor` + `corescope-server` (managed by supervisord) |
|
||||
| Memory management | Configurable via `packetStore.maxMemoryMB` | Loads all packets; no configurable limit |
|
||||
| Startup time | Faster (no compilation) | Slightly slower (loads all packets from DB into memory) |
|
||||
|
||||
@@ -393,4 +393,4 @@ The following gaps have been identified. Check the GitHub issue tracker for curr
|
||||
|
||||
3. **Go ingestor missing `meshcore/self_info` handling** — The local node identity topic is not processed. Low impact but breaks parity.
|
||||
|
||||
4. **No Docker Hub publishing for Go images** — Users must build locally. CI/CD pipeline should publish `meshcore-go:latest` alongside the Node.js image.
|
||||
4. **No Docker Hub publishing for Go images** — Users must build locally. CI/CD pipeline should publish `corescope-go:latest` alongside the Node.js image.
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
#!/bin/bash
|
||||
# MeshCore Analyzer — Setup & Management Helper
|
||||
# CoreScope — Setup & Management Helper
|
||||
# Usage: ./manage.sh [command]
|
||||
#
|
||||
# All container management goes through docker compose.
|
||||
# Container config lives in docker-compose.yml — this script is just a wrapper.
|
||||
#
|
||||
# Idempotent: safe to cancel and re-run at any point.
|
||||
# Each step checks what's already done and skips it.
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="meshcore-analyzer"
|
||||
IMAGE_NAME="meshcore-analyzer"
|
||||
DATA_VOLUME="meshcore-data"
|
||||
CADDY_VOLUME="caddy-data"
|
||||
IMAGE_NAME="corescope"
|
||||
STATE_FILE=".setup-state"
|
||||
|
||||
# Source .env for port/path overrides (if present)
|
||||
# Source .env for port/path overrides (same file docker compose reads)
|
||||
[ -f .env ] && set -a && . ./.env && set +a
|
||||
|
||||
# Docker Compose mode detection
|
||||
COMPOSE_MODE=false
|
||||
if [ -f docker-compose.yml ]; then
|
||||
COMPOSE_MODE=true
|
||||
fi
|
||||
|
||||
# Resolved paths for prod/staging data
|
||||
# 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}"
|
||||
|
||||
@@ -51,83 +45,6 @@ is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null;
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Determine the correct data volume/mount args for docker run.
|
||||
# Detects existing host data directories and uses bind mounts if found.
|
||||
get_data_mount_args() {
|
||||
# Check for existing host data directories with a DB file
|
||||
if [ -d "$HOME/meshcore-data" ] && [ -f "$HOME/meshcore-data/meshcore.db" ]; then
|
||||
echo "-v $HOME/meshcore-data:/app/data"
|
||||
return
|
||||
fi
|
||||
if [ -d "$(pwd)/data" ] && [ -f "$(pwd)/data/meshcore.db" ]; then
|
||||
echo "-v $(pwd)/data:/app/data"
|
||||
return
|
||||
fi
|
||||
# Default: Docker named volume
|
||||
echo "-v ${DATA_VOLUME}:/app/data"
|
||||
}
|
||||
|
||||
# Determine the required port mappings from Caddyfile
|
||||
get_required_ports() {
|
||||
local caddyfile_domain
|
||||
caddyfile_domain=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
||||
if echo "$caddyfile_domain" | grep -qE '^:[0-9]+$'; then
|
||||
# HTTP-only on a specific port (e.g., :80, :8080)
|
||||
echo "${caddyfile_domain#:}"
|
||||
else
|
||||
# Domain name — needs 80 + 443 for Caddy auto-TLS
|
||||
echo "80 443"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get current container port mappings (just the host ports)
|
||||
get_current_ports() {
|
||||
docker inspect "$CONTAINER_NAME" 2>/dev/null | \
|
||||
grep -oP '"HostPort":\s*"\K[0-9]+' | sort -u | tr '\n' ' ' | sed 's/ $//'
|
||||
}
|
||||
|
||||
# Check if container port mappings match what's needed.
|
||||
# Returns 0 if they match, 1 if mismatch.
|
||||
check_port_match() {
|
||||
local required current
|
||||
required=$(get_required_ports | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
||||
current=$(get_current_ports | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
||||
[ "$required" = "$current" ]
|
||||
}
|
||||
|
||||
# Build the docker run command args (ports + volumes)
|
||||
get_docker_run_args() {
|
||||
local ports_arg=""
|
||||
for port in $(get_required_ports); do
|
||||
ports_arg="$ports_arg -p ${port}:${port}"
|
||||
done
|
||||
|
||||
local data_mount
|
||||
data_mount=$(get_data_mount_args)
|
||||
|
||||
echo "$ports_arg \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
-v $(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
$data_mount \
|
||||
-v ${CADDY_VOLUME}:/data/caddy"
|
||||
}
|
||||
|
||||
# Recreate the container with current settings
|
||||
recreate_container() {
|
||||
info "Stopping and removing old container..."
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
local run_args
|
||||
run_args=$(get_docker_run_args)
|
||||
|
||||
eval docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
$run_args \
|
||||
"$IMAGE_NAME"
|
||||
}
|
||||
|
||||
# Check config.json for placeholder values
|
||||
check_config_placeholders() {
|
||||
if [ -f config.json ]; then
|
||||
@@ -140,7 +57,7 @@ check_config_placeholders() {
|
||||
|
||||
# Verify the running container is actually healthy
|
||||
verify_health() {
|
||||
local base_url="http://localhost:3000"
|
||||
local container="corescope-prod"
|
||||
local use_https=false
|
||||
|
||||
# Check if Caddyfile has a real domain (not :80)
|
||||
@@ -156,7 +73,7 @@ verify_health() {
|
||||
info "Waiting for server to respond..."
|
||||
local healthy=false
|
||||
for i in $(seq 1 45); do
|
||||
if docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
||||
if docker exec "$container" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
@@ -172,7 +89,7 @@ verify_health() {
|
||||
|
||||
# Check for MQTT errors in recent logs
|
||||
local mqtt_errors
|
||||
mqtt_errors=$(docker logs "$CONTAINER_NAME" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||
mqtt_errors=$(docker logs "$container" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||
if [ -n "$mqtt_errors" ]; then
|
||||
warn "MQTT errors detected in logs:"
|
||||
echo "$mqtt_errors" | head -5 | sed 's/^/ /'
|
||||
@@ -201,7 +118,7 @@ TOTAL_STEPS=6
|
||||
cmd_setup() {
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " MeshCore Analyzer Setup"
|
||||
echo " CoreScope Setup"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
@@ -234,6 +151,13 @@ cmd_setup() {
|
||||
fi
|
||||
|
||||
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
|
||||
|
||||
# Check docker compose (separate check since it's a plugin/separate binary)
|
||||
if ! docker compose version &>/dev/null; then
|
||||
err "docker compose is required. Install Docker Desktop or docker-compose-plugin."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mark_done "docker"
|
||||
|
||||
# ── Step 2: Config ──
|
||||
@@ -371,12 +295,12 @@ cmd_setup() {
|
||||
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
|
||||
log "Image already built."
|
||||
if confirm "Rebuild? (only needed if you updated the code)"; then
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
||||
docker compose build prod
|
||||
log "Image rebuilt."
|
||||
fi
|
||||
else
|
||||
info "This takes 1-2 minutes the first time..."
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
||||
docker compose build prod
|
||||
log "Image built."
|
||||
fi
|
||||
mark_done "build"
|
||||
@@ -385,45 +309,15 @@ cmd_setup() {
|
||||
step 5 "Starting container"
|
||||
|
||||
# Detect existing data directories
|
||||
if [ -d "$HOME/meshcore-data" ] && [ -f "$HOME/meshcore-data/meshcore.db" ]; then
|
||||
info "Found existing data at \$HOME/meshcore-data/ — will use bind mount."
|
||||
elif [ -d "$(pwd)/data" ] && [ -f "$(pwd)/data/meshcore.db" ]; then
|
||||
info "Found existing data at ./data/ — will use bind mount."
|
||||
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||
info "Found existing data at $PROD_DATA/ — will use bind mount."
|
||||
fi
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
||||
log "Container already running."
|
||||
# Check port mappings match
|
||||
if ! check_port_match; then
|
||||
warn "Container port mappings don't match Caddyfile configuration."
|
||||
warn "Current ports: $(get_current_ports)"
|
||||
warn "Required ports: $(get_required_ports)"
|
||||
if confirm "Recreate container with correct ports?"; then
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
fi
|
||||
fi
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
# Exists but stopped — check ports before starting
|
||||
if ! check_port_match; then
|
||||
warn "Stopped container has wrong port mappings."
|
||||
warn "Current ports: $(get_current_ports)"
|
||||
warn "Required ports: $(get_required_ports)"
|
||||
if confirm "Recreate container with correct ports?"; then
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
else
|
||||
info "Starting existing container (ports unchanged)..."
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started (with old port mappings)."
|
||||
fi
|
||||
else
|
||||
info "Container exists but is stopped. Starting..."
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started."
|
||||
fi
|
||||
else
|
||||
recreate_container
|
||||
mkdir -p "$PROD_DATA"
|
||||
docker compose up -d prod
|
||||
log "Container started."
|
||||
fi
|
||||
mark_done "container"
|
||||
@@ -431,7 +325,7 @@ cmd_setup() {
|
||||
# ── Step 6: Verify ──
|
||||
step 6 "Verifying"
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
||||
verify_health
|
||||
|
||||
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
||||
@@ -463,7 +357,7 @@ cmd_setup() {
|
||||
err "Container failed to start."
|
||||
echo ""
|
||||
echo " Check what went wrong:"
|
||||
echo " docker logs ${CONTAINER_NAME}"
|
||||
echo " docker compose logs prod"
|
||||
echo ""
|
||||
echo " Common fixes:"
|
||||
echo " • Invalid config.json — check JSON syntax"
|
||||
@@ -501,7 +395,7 @@ prepare_staging_config() {
|
||||
if [ ! -f "$staging_config" ] || [ "$prod_config" -nt "$staging_config" ]; then
|
||||
info "Copying production config to staging..."
|
||||
cp "$prod_config" "$staging_config"
|
||||
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "MeshCore Analyzer — STAGING"/' "$staging_config"
|
||||
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
|
||||
log "Staging config created at ${staging_config} with STAGING site name."
|
||||
else
|
||||
log "Staging config is up to date."
|
||||
@@ -535,132 +429,72 @@ cmd_start() {
|
||||
WITH_STAGING=true
|
||||
fi
|
||||
|
||||
if $COMPOSE_MODE; then
|
||||
if $WITH_STAGING; then
|
||||
# Prepare staging data and config
|
||||
prepare_staging_db
|
||||
prepare_staging_config
|
||||
if $WITH_STAGING; then
|
||||
# Prepare staging data and config
|
||||
prepare_staging_db
|
||||
prepare_staging_config
|
||||
|
||||
info "Starting production container (meshcore-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
info "Starting staging container (meshcore-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_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
|
||||
else
|
||||
info "Starting production container (meshcore-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
docker compose up -d prod
|
||||
log "Production started. Staging NOT running (use --with-staging to start both)."
|
||||
fi
|
||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
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_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
|
||||
else
|
||||
# Legacy single-container mode
|
||||
if $WITH_STAGING; then
|
||||
err "--with-staging requires docker-compose.yml. Run setup or add docker-compose.yml first."
|
||||
exit 1
|
||||
fi
|
||||
warn "No docker-compose.yml found — using legacy single-container mode."
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
warn "Already running."
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! check_port_match; then
|
||||
warn "Container port mappings don't match Caddyfile configuration."
|
||||
warn "Current ports: $(get_current_ports)"
|
||||
warn "Required ports: $(get_required_ports)"
|
||||
if confirm "Recreate container with correct ports?"; then
|
||||
recreate_container
|
||||
log "Container recreated and started with correct ports."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started."
|
||||
else
|
||||
err "Container doesn't exist. Run './manage.sh setup' first."
|
||||
exit 1
|
||||
fi
|
||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
docker compose up -d prod
|
||||
log "Production started. Staging NOT running (use --with-staging to start both)."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local TARGET="${1:-all}"
|
||||
|
||||
if $COMPOSE_MODE; then
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Stopping production container (meshcore-prod)..."
|
||||
docker compose stop prod
|
||||
log "Production stopped."
|
||||
;;
|
||||
staging)
|
||||
info "Stopping staging container (meshcore-staging)..."
|
||||
docker compose stop staging
|
||||
log "Staging stopped."
|
||||
;;
|
||||
all)
|
||||
info "Stopping all containers..."
|
||||
docker compose --profile staging --profile staging-go down 2>/dev/null
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null
|
||||
log "All containers stopped."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh stop [prod|staging|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Legacy mode
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null && log "Stopped." || warn "Not running."
|
||||
fi
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Stopping production container (corescope-prod)..."
|
||||
docker compose stop prod
|
||||
log "Production stopped."
|
||||
;;
|
||||
staging)
|
||||
info "Stopping staging container (corescope-staging)..."
|
||||
docker compose --profile staging stop staging
|
||||
log "Staging stopped."
|
||||
;;
|
||||
all)
|
||||
info "Stopping all containers..."
|
||||
docker compose --profile staging --profile staging-go down
|
||||
log "All containers stopped."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh stop [prod|staging|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
if $COMPOSE_MODE; then
|
||||
local TARGET="${1:-prod}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Restarting production container (meshcore-prod)..."
|
||||
docker compose up -d --force-recreate prod
|
||||
log "Production restarted."
|
||||
;;
|
||||
staging)
|
||||
info "Restarting staging container (meshcore-staging)..."
|
||||
docker compose --profile staging up -d --force-recreate staging
|
||||
log "Staging restarted."
|
||||
;;
|
||||
all)
|
||||
info "Restarting all containers..."
|
||||
docker compose --profile staging up -d --force-recreate
|
||||
log "All containers restarted."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh restart [prod|staging|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Legacy mode
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! check_port_match; then
|
||||
warn "Port mappings have changed. Recreating container..."
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
else
|
||||
docker restart "$CONTAINER_NAME"
|
||||
log "Restarted."
|
||||
fi
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! check_port_match; then
|
||||
warn "Port mappings have changed. Recreating container..."
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
else
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started."
|
||||
fi
|
||||
else
|
||||
err "Not running. Use './manage.sh setup'."
|
||||
local TARGET="${1:-prod}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Restarting production container (corescope-prod)..."
|
||||
docker compose up -d --force-recreate prod
|
||||
log "Production restarted."
|
||||
;;
|
||||
staging)
|
||||
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 --profile staging up -d --force-recreate
|
||||
log "All containers restarted."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh restart [prod|staging|all]"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Status ───────────────────────────────────────────────────────────────
|
||||
@@ -695,143 +529,68 @@ show_container_status() {
|
||||
|
||||
cmd_status() {
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " CoreScope Status"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
if $COMPOSE_MODE; then
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " MeshCore Analyzer Status (Compose)"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Production
|
||||
show_container_status "meshcore-prod" "Production"
|
||||
echo ""
|
||||
|
||||
# Staging
|
||||
if container_running "meshcore-staging"; then
|
||||
show_container_status "meshcore-staging" "Staging"
|
||||
else
|
||||
info "Staging (meshcore-staging): Not running (use --with-staging to start both)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Disk usage
|
||||
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||
local db_size
|
||||
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||
info "Production DB: ${db_size}"
|
||||
fi
|
||||
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
|
||||
local staging_db_size
|
||||
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||
info "Staging DB: ${staging_db_size}"
|
||||
fi
|
||||
# Production
|
||||
show_container_status "corescope-prod" "Production"
|
||||
echo ""
|
||||
|
||||
# Staging
|
||||
if container_running "corescope-staging"; then
|
||||
show_container_status "corescope-staging" "Staging"
|
||||
else
|
||||
# Legacy single-container status
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
log "Container is running."
|
||||
echo ""
|
||||
docker ps --filter "name=${CONTAINER_NAME}" --format " Status: {{.Status}}"
|
||||
docker ps --filter "name=${CONTAINER_NAME}" --format " Ports: {{.Ports}}"
|
||||
echo ""
|
||||
|
||||
info "Service health:"
|
||||
# Server
|
||||
if docker exec "$CONTAINER_NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then
|
||||
STATS=$(docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null)
|
||||
PACKETS=$(echo "$STATS" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?")
|
||||
NODES=$(echo "$STATS" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?")
|
||||
log " Server — ${PACKETS} packets, ${NODES} nodes"
|
||||
else
|
||||
err " Server — not responding"
|
||||
fi
|
||||
|
||||
# Mosquitto
|
||||
if docker exec "$CONTAINER_NAME" pgrep mosquitto &>/dev/null; then
|
||||
log " Mosquitto — running"
|
||||
else
|
||||
err " Mosquitto — not running"
|
||||
fi
|
||||
|
||||
# Caddy
|
||||
if docker exec "$CONTAINER_NAME" pgrep caddy &>/dev/null; then
|
||||
log " Caddy — running"
|
||||
else
|
||||
err " Caddy — not running"
|
||||
fi
|
||||
|
||||
# Check for MQTT errors in recent logs
|
||||
MQTT_ERRORS=$(docker logs "$CONTAINER_NAME" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||
if [ -n "$MQTT_ERRORS" ]; then
|
||||
echo ""
|
||||
warn "MQTT errors in recent logs:"
|
||||
echo "$MQTT_ERRORS" | head -3 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# Port mapping check
|
||||
if ! check_port_match; then
|
||||
echo ""
|
||||
warn "Port mappings don't match Caddyfile. Run './manage.sh restart' to fix."
|
||||
fi
|
||||
|
||||
# Disk usage
|
||||
DB_SIZE=$(docker exec "$CONTAINER_NAME" du -h /app/data/meshcore.db 2>/dev/null | cut -f1)
|
||||
if [ -n "$DB_SIZE" ]; then
|
||||
echo ""
|
||||
info "Database size: ${DB_SIZE}"
|
||||
fi
|
||||
else
|
||||
err "Container is not running."
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo " Start with: ./manage.sh start"
|
||||
else
|
||||
echo " Set up with: ./manage.sh setup"
|
||||
fi
|
||||
fi
|
||||
info "Staging (corescope-staging): Not running (use --with-staging to start both)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Disk usage
|
||||
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||
local db_size
|
||||
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||
info "Production DB: ${db_size}"
|
||||
fi
|
||||
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
|
||||
local staging_db_size
|
||||
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||
info "Staging DB: ${staging_db_size}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_logs() {
|
||||
if $COMPOSE_MODE; then
|
||||
local TARGET="${1:-prod}"
|
||||
local LINES="${2:-100}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Tailing production logs..."
|
||||
docker compose logs -f --tail="$LINES" prod
|
||||
;;
|
||||
staging)
|
||||
if container_running "meshcore-staging"; then
|
||||
info "Tailing staging logs..."
|
||||
docker compose logs -f --tail="$LINES" staging
|
||||
else
|
||||
err "Staging container is not running."
|
||||
info "Start with: ./manage.sh start --with-staging"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
||||
local TARGET="${1:-prod}"
|
||||
local LINES="${2:-100}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Tailing production logs..."
|
||||
docker compose logs -f --tail="$LINES" prod
|
||||
;;
|
||||
staging)
|
||||
if container_running "corescope-staging"; then
|
||||
info "Tailing staging logs..."
|
||||
docker compose logs -f --tail="$LINES" staging
|
||||
else
|
||||
err "Staging container is not running."
|
||||
info "Start with: ./manage.sh start --with-staging"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Legacy mode
|
||||
docker logs -f "$CONTAINER_NAME" --tail "${1:-100}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Promote ──────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_promote() {
|
||||
if ! $COMPOSE_MODE; then
|
||||
err "Promotion requires Docker Compose setup (docker-compose.yml)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Promotion Flow: Staging → Production"
|
||||
echo ""
|
||||
@@ -843,10 +602,10 @@ cmd_promote() {
|
||||
|
||||
# Show what's currently running
|
||||
local staging_image staging_created prod_image prod_created
|
||||
staging_image=$(docker inspect meshcore-staging --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
|
||||
staging_created=$(docker inspect meshcore-staging --format '{{.Created}}' 2>/dev/null || echo "N/A")
|
||||
prod_image=$(docker inspect meshcore-prod --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
|
||||
prod_created=$(docker inspect meshcore-prod --format '{{.Created}}' 2>/dev/null || echo "N/A")
|
||||
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")
|
||||
|
||||
echo " Staging: ${staging_image} (created ${staging_created})"
|
||||
echo " Prod: ${prod_image} (created ${prod_created})"
|
||||
@@ -863,8 +622,8 @@ cmd_promote() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
if [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||
cp "$PROD_DATA/meshcore.db" "$BACKUP_DIR/"
|
||||
elif container_running "meshcore-prod"; then
|
||||
docker cp meshcore-prod:/app/data/meshcore.db "$BACKUP_DIR/"
|
||||
elif container_running "corescope-prod"; then
|
||||
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/"
|
||||
else
|
||||
warn "Could not backup production database."
|
||||
fi
|
||||
@@ -878,7 +637,7 @@ cmd_promote() {
|
||||
info "Waiting for production health check..."
|
||||
local i health
|
||||
for i in $(seq 1 30); do
|
||||
health=$(container_health "meshcore-prod")
|
||||
health=$(container_health "corescope-prod")
|
||||
if [ "$health" = "healthy" ]; then
|
||||
log "Production healthy after ${i}s"
|
||||
break
|
||||
@@ -906,10 +665,10 @@ cmd_update() {
|
||||
git pull
|
||||
|
||||
info "Rebuilding image..."
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
||||
docker compose build prod
|
||||
|
||||
info "Restarting with new image..."
|
||||
recreate_container
|
||||
docker compose up -d --force-recreate prod
|
||||
|
||||
log "Updated and restarted. Data preserved."
|
||||
}
|
||||
@@ -918,18 +677,19 @@ cmd_update() {
|
||||
|
||||
cmd_backup() {
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_DIR="${1:-./backups/meshcore-${TIMESTAMP}}"
|
||||
BACKUP_DIR="${1:-./backups/corescope-${TIMESTAMP}}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
info "Backing up to ${BACKUP_DIR}/"
|
||||
|
||||
# Database
|
||||
DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
|
||||
# Always use bind mount path (from .env or default)
|
||||
DB_PATH="$PROD_DATA/meshcore.db"
|
||||
if [ -f "$DB_PATH" ]; then
|
||||
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
|
||||
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
|
||||
elif docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
docker cp "${CONTAINER_NAME}:/app/data/meshcore.db" "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
||||
elif container_running "corescope-prod"; then
|
||||
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
||||
log "Database (via docker cp)" || warn "Could not backup database"
|
||||
else
|
||||
warn "Database not found (container not running?)"
|
||||
@@ -948,7 +708,8 @@ cmd_backup() {
|
||||
fi
|
||||
|
||||
# Theme
|
||||
THEME_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
|
||||
# Always use bind mount path (from .env or default)
|
||||
THEME_PATH="$PROD_DATA/theme.json"
|
||||
if [ -f "$THEME_PATH" ]; then
|
||||
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
|
||||
log "theme.json"
|
||||
@@ -972,7 +733,7 @@ cmd_restore() {
|
||||
if [ -d "./backups" ]; then
|
||||
echo ""
|
||||
echo " Available backups:"
|
||||
ls -dt ./backups/meshcore-* 2>/dev/null | head -10 | while read d; do
|
||||
ls -dt ./backups/meshcore-* ./backups/corescope-* 2>/dev/null | head -10 | while read d; do
|
||||
if [ -d "$d" ]; then
|
||||
echo " $d/ ($(ls "$d" | wc -l) files)"
|
||||
elif [ -f "$d" ]; then
|
||||
@@ -1019,17 +780,14 @@ cmd_restore() {
|
||||
|
||||
# Backup current state first
|
||||
info "Backing up current state..."
|
||||
cmd_backup "./backups/meshcore-pre-restore-$(date +%Y%m%d-%H%M%S)"
|
||||
cmd_backup "./backups/corescope-pre-restore-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker compose stop prod 2>/dev/null || true
|
||||
|
||||
# Restore database
|
||||
DEST_DB=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
|
||||
if [ -d "$(dirname "$DEST_DB")" ]; then
|
||||
cp "$DB_FILE" "$DEST_DB"
|
||||
else
|
||||
docker cp "$DB_FILE" "${CONTAINER_NAME}:/app/data/meshcore.db"
|
||||
fi
|
||||
mkdir -p "$PROD_DATA"
|
||||
DEST_DB="$PROD_DATA/meshcore.db"
|
||||
cp "$DB_FILE" "$DEST_DB"
|
||||
log "Database restored"
|
||||
|
||||
# Restore config if present
|
||||
@@ -1047,27 +805,25 @@ cmd_restore() {
|
||||
|
||||
# Restore theme if present
|
||||
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
|
||||
DEST_THEME=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
|
||||
if [ -d "$(dirname "$DEST_THEME")" ]; then
|
||||
cp "$THEME_FILE" "$DEST_THEME"
|
||||
fi
|
||||
DEST_THEME="$PROD_DATA/theme.json"
|
||||
cp "$THEME_FILE" "$DEST_THEME"
|
||||
log "theme.json restored"
|
||||
fi
|
||||
|
||||
docker start "$CONTAINER_NAME"
|
||||
docker compose up -d prod
|
||||
log "Restored and restarted."
|
||||
}
|
||||
|
||||
# ─── MQTT Test ────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_mqtt_test() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! container_running "corescope-prod"; then
|
||||
err "Container not running. Start with: ./manage.sh start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Listening for MQTT messages (10 second timeout)..."
|
||||
MSG=$(docker exec "$CONTAINER_NAME" mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
||||
MSG=$(docker exec corescope-prod mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
||||
if [ -n "$MSG" ]; then
|
||||
log "Received MQTT message:"
|
||||
echo " $MSG" | head -c 200
|
||||
@@ -1084,28 +840,26 @@ cmd_mqtt_test() {
|
||||
|
||||
cmd_reset() {
|
||||
echo ""
|
||||
warn "This will remove the container, image, and setup state."
|
||||
warn "Your config.json, Caddyfile, and data volume are NOT deleted."
|
||||
warn "This will remove all containers, images, and setup state."
|
||||
warn "Your config.json, Caddyfile, and data directory are NOT deleted."
|
||||
echo ""
|
||||
if ! confirm "Continue?"; then
|
||||
echo " Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rmi "$IMAGE_NAME" 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."
|
||||
echo " Data volume preserved. To delete it: docker volume rm ${DATA_VOLUME}"
|
||||
echo " Data directory: $PROD_DATA (not removed)"
|
||||
}
|
||||
|
||||
# ─── Help ─────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_help() {
|
||||
echo ""
|
||||
echo "MeshCore Analyzer — Management Script"
|
||||
echo "CoreScope — Management Script"
|
||||
echo ""
|
||||
echo "Usage: ./manage.sh <command>"
|
||||
echo ""
|
||||
@@ -1128,11 +882,7 @@ cmd_help() {
|
||||
echo " restore <d> Restore from backup dir or .db file"
|
||||
echo " mqtt-test Check if MQTT data is flowing"
|
||||
echo ""
|
||||
if $COMPOSE_MODE; then
|
||||
info "Docker Compose mode detected (docker-compose.yml present)."
|
||||
else
|
||||
warn "Legacy mode (no docker-compose.yml). Some commands unavailable."
|
||||
fi
|
||||
echo "All commands use docker compose with docker-compose.yml."
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
// ─── Core Channel Type ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
// ─── Pagination ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/config/theme — Theme and branding configuration
|
||||
@@ -10,7 +10,7 @@ option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
|
||||
// Site branding configuration.
|
||||
message Branding {
|
||||
// Site name (default: "MeshCore Analyzer").
|
||||
// Site name (default: "CoreScope").
|
||||
string site_name = 1 [json_name = "siteName"];
|
||||
// Site tagline.
|
||||
string tagline = 2;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
// ─── Decoded Packet Structure ──────────────────────────────────────────────────
|
||||
// Returned by POST /api/decode, POST /api/packets, and WS broadcast.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
import "common.proto";
|
||||
import "packet.proto";
|
||||
|
||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
import "common.proto";
|
||||
import "packet.proto";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
import "common.proto";
|
||||
import "decoded.proto";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
import "common.proto";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package meshcore.v1;
|
||||
|
||||
option go_package = "github.com/meshcore-analyzer/proto/v1";
|
||||
option go_package = "github.com/corescope/proto/v1";
|
||||
|
||||
import "decoded.proto";
|
||||
import "packet.proto";
|
||||
|
||||
+27
-18
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — analytics.js (v2 — full nerd mode) === */
|
||||
/* === CoreScope — analytics.js (v2 — full nerd mode) === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
@@ -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>` : '—';
|
||||
|
||||
+39
-5
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — app.js === */
|
||||
/* === CoreScope — app.js === */
|
||||
'use strict';
|
||||
|
||||
// --- Route/Payload name maps ---
|
||||
@@ -109,7 +109,7 @@ function formatVersionBadge(version, commit, engine) {
|
||||
if (!version && !commit && !engine) return '';
|
||||
var port = (typeof location !== 'undefined' && location.port) || '';
|
||||
var isProd = !port || port === '80' || port === '443';
|
||||
var GH = 'https://github.com/Kpa-clawbot/meshcore-analyzer';
|
||||
var GH = 'https://github.com/Kpa-clawbot/corescope';
|
||||
var parts = [];
|
||||
if (version && isProd) {
|
||||
var vTag = version.charAt(0) === 'v' ? version : 'v' + version;
|
||||
@@ -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)
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — audio-lab.js === */
|
||||
/* === CoreScope — audio-lab.js === */
|
||||
/* Audio Lab: Packet Jukebox for sound debugging & understanding */
|
||||
'use strict';
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — channels.js === */
|
||||
/* === CoreScope — channels.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
|
||||
+14
-5
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — compare.js === */
|
||||
/* === CoreScope — compare.js === */
|
||||
/* Observer packet comparison — Fixes #129 */
|
||||
'use strict';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+27
-27
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — customize.js === */
|
||||
/* === CoreScope — customize.js === */
|
||||
/* Tools → Customization: visual config builder with live preview & JSON export */
|
||||
'use strict';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const DEFAULTS = {
|
||||
branding: {
|
||||
siteName: 'MeshCore Analyzer',
|
||||
siteName: 'CoreScope',
|
||||
tagline: 'Real-time MeshCore LoRa mesh network analyzer',
|
||||
logoUrl: '',
|
||||
faviconUrl: ''
|
||||
@@ -45,7 +45,7 @@
|
||||
ANON_REQ: '#f43f5e'
|
||||
},
|
||||
home: {
|
||||
heroTitle: 'MeshCore Analyzer',
|
||||
heroTitle: 'CoreScope',
|
||||
heroSubtitle: 'Find your nodes to start monitoring them.',
|
||||
steps: [
|
||||
{ emoji: '💬', title: 'Join the Bay Area MeshCore Discord', description: 'The community Discord is the best place to get help and find local mesh enthusiasts.' },
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — home.css === */
|
||||
/* === CoreScope — home.css === */
|
||||
|
||||
/* Override #app overflow:hidden for home page scrolling */
|
||||
#app:has(.home-hero), #app:has(.home-chooser) { overflow-y: auto; }
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — home.js (My Mesh Dashboard) === */
|
||||
/* === CoreScope — home.js (My Mesh Dashboard) === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
@@ -39,7 +39,7 @@
|
||||
function showChooser(container) {
|
||||
container.innerHTML = `
|
||||
<section class="home-chooser">
|
||||
<h1>Welcome to ${escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer')}</h1>
|
||||
<h1>Welcome to ${escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'CoreScope')}</h1>
|
||||
<p>How familiar are you with MeshCore?</p>
|
||||
<div class="chooser-options">
|
||||
<button class="chooser-btn new" id="chooseNew">
|
||||
@@ -63,7 +63,7 @@
|
||||
const myNodes = getMyNodes();
|
||||
const hasNodes = myNodes.length > 0;
|
||||
const homeCfg = window.SITE_CONFIG?.home || null;
|
||||
const siteName = window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer';
|
||||
const siteName = window.SITE_CONFIG?.branding?.siteName || 'CoreScope';
|
||||
|
||||
container.innerHTML = `
|
||||
<section class="home-hero">
|
||||
@@ -324,7 +324,7 @@
|
||||
loadMyNodes();
|
||||
// Update title if no nodes left
|
||||
const h1 = document.querySelector('.home-hero h1');
|
||||
if (h1 && !getMyNodes().length) h1.textContent = 'MeshCore Analyzer';
|
||||
if (h1 && !getMyNodes().length) h1.textContent = 'CoreScope';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — hop-display.js === */
|
||||
/* === CoreScope — hop-display.js === */
|
||||
/* Shared hop rendering with conflict info for all pages */
|
||||
'use strict';
|
||||
|
||||
|
||||
+34
-34
@@ -5,12 +5,12 @@
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>MeshCore Analyzer</title>
|
||||
<title>CoreScope</title>
|
||||
|
||||
<!-- Open Graph / Discord embed -->
|
||||
<meta property="og:title" content="MeshCore Analyzer">
|
||||
<meta property="og:title" content="CoreScope">
|
||||
<meta property="og:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, route analysis, and deep mesh analytics.">
|
||||
<meta property="og:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
|
||||
<meta property="og:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:url" content="https://analyzer.00id.net">
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<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/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">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774731523">
|
||||
<link rel="stylesheet" href="home.css?v=1774731523">
|
||||
<link rel="stylesheet" href="live.css?v=1774731523">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="nav-left">
|
||||
<a href="#/" class="nav-brand">
|
||||
<span class="brand-icon">🍄</span>
|
||||
<span class="brand-text">MeshCore Analyzer</span>
|
||||
<span class="brand-text">CoreScope</span>
|
||||
<span class="live-dot" id="liveDot" title="WebSocket connected" aria-label="WebSocket connected"></span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
@@ -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=1774731523"></script>
|
||||
<script src="customize.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774731523"></script>
|
||||
<script src="hop-resolver.js?v=1774731523"></script>
|
||||
<script src="hop-display.js?v=1774731523"></script>
|
||||
<script src="app.js?v=1774731523"></script>
|
||||
<script src="home.js?v=1774731523"></script>
|
||||
<script src="packet-filter.js?v=1774731523"></script>
|
||||
<script src="packets.js?v=1774731523"></script>
|
||||
<script src="map.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774731523" 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; }
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — map.js === */
|
||||
/* === CoreScope — map.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — node-analytics.js === */
|
||||
/* === CoreScope — node-analytics.js === */
|
||||
'use strict';
|
||||
(function () {
|
||||
const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
|
||||
@@ -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>`;
|
||||
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — nodes.js === */
|
||||
/* === CoreScope — nodes.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — observer-detail.js === */
|
||||
/* === CoreScope — observer-detail.js === */
|
||||
'use strict';
|
||||
(function () {
|
||||
const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
|
||||
@@ -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 });
|
||||
|
||||
+14
-4
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — observers.js === */
|
||||
/* === CoreScope — observers.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
@@ -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>
|
||||
|
||||
+6
-5
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — packets.js === */
|
||||
/* === CoreScope — packets.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
/* === MeshCore Analyzer — perf.js === */
|
||||
/* === CoreScope — perf.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — region-filter.js (shared region filter component) === */
|
||||
/* === CoreScope — region-filter.js (shared region filter component) === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — roles.js (shared config module) === */
|
||||
/* === CoreScope — roles.js (shared config module) === */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
|
||||
+16
-2
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — style.css === */
|
||||
/* === CoreScope — style.css === */
|
||||
|
||||
:root {
|
||||
--nav-bg: #0f0f23;
|
||||
@@ -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; }
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — traces.js === */
|
||||
/* === CoreScope — traces.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
|
||||
@@ -344,7 +344,7 @@ app.get('/api/config/theme', (req, res) => {
|
||||
const theme = loadThemeFile();
|
||||
res.json({
|
||||
branding: {
|
||||
siteName: 'MeshCore Analyzer',
|
||||
siteName: 'CoreScope',
|
||||
tagline: 'Real-time MeshCore LoRa mesh network analyzer',
|
||||
...(cfg.branding || {}),
|
||||
...(theme.branding || {})
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -2948,7 +2956,7 @@ app.get('/{*splat}', (req, res) => {
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
res.status(200).send('<!DOCTYPE html><html><body><h1>MeshCore Analyzer</h1><p>Frontend not yet built.</p></body></html>');
|
||||
res.status(200).send('<!DOCTYPE html><html><body><h1>CoreScope</h1><p>Frontend not yet built.</p></body></html>');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2959,7 +2967,7 @@ if (require.main === module) {
|
||||
db.removePhantomNodes();
|
||||
server.listen(listenPort, () => {
|
||||
const protocol = isHttps ? 'https' : 'http';
|
||||
console.log(`MeshCore Analyzer running on ${protocol}://localhost:${listenPort}`);
|
||||
console.log(`CoreScope running on ${protocol}://localhost:${listenPort}`);
|
||||
// Log theme file location
|
||||
let themeFound = false;
|
||||
for (const p of THEME_PATHS) {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
set -e
|
||||
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " MeshCore Analyzer — Test Suite"
|
||||
echo " CoreScope — Test Suite"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ async function run() {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const title = await page.title();
|
||||
assert(title.toLowerCase().includes('meshcore'), `Title "${title}" doesn't contain MeshCore`);
|
||||
assert(title.toLowerCase().includes('corescope'), `Title "${title}" doesn't contain CoreScope`);
|
||||
const nav = await page.$('nav, .navbar, .nav, [class*="nav"]');
|
||||
assert(nav, 'Nav bar not found');
|
||||
});
|
||||
|
||||
@@ -1304,7 +1304,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
return ctx;
|
||||
}
|
||||
const GH = 'https://github.com/Kpa-clawbot/meshcore-analyzer';
|
||||
const GH = 'https://github.com/Kpa-clawbot/corescope';
|
||||
|
||||
test('returns empty string when all args missing', () => {
|
||||
const { formatVersionBadge } = makeBadgeSandbox('');
|
||||
|
||||
Reference in New Issue
Block a user