mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-04 09:31:38 +00:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f4beda43 | |||
| 0b82ca791e | |||
| 6ea3e419e3 | |||
| 928a3d995a | |||
| b654ac6c9f | |||
| 4ef69f5092 | |||
| 1e1fb298c2 | |||
| 4c371e3231 | |||
| 6a3b8967b4 | |||
| 568e3904ba | |||
| 999436d714 | |||
| 16a99159cc | |||
| 93f85dee6e | |||
| 61ff72fc80 | |||
| 57ebd76070 | |||
| 86b5d4e175 | |||
| faca80e626 | |||
| 8f833f64ae | |||
| 726b041740 | |||
| 1193351fc5 | |||
| 3531d51fc8 | |||
| 77d8f35a04 | |||
| a555b68915 | |||
| a6364c92f4 | |||
| 4cbb66d8e9 | |||
| 5c6bebc135 | |||
| 72bc90069f | |||
| 329b5cf516 | |||
| 8afff22b4c | |||
| 5777780fc8 | |||
| ada53ff899 | |||
| 54e39c241d | |||
| 3dd68d4418 | |||
| 5bf2cdd812 | |||
| f438411a27 | |||
| 8c63200679 | |||
| 21fc478e83 | |||
| 900cbf6392 | |||
| efc2d875c5 | |||
| 067b101e14 | |||
| 8e5eedaebd | |||
| fba941af1b | |||
| c271093795 | |||
| 424e4675ae | |||
| c81744fed7 | |||
| fd162a9354 | |||
| e41aba705e | |||
| 075dcaed4d | |||
| 2817877380 | |||
| ab140ab851 | |||
| b51d8c9701 | |||
| 251b7fa5c2 | |||
| f31e0b42a0 | |||
| 78e0347055 | |||
| 8ab195b45f | |||
| 6c7a3c1614 | |||
| a5a3a85fc0 | |||
| ec7ae19bb5 | |||
| 75637afcc8 | |||
| 78c5b911e3 | |||
| 13cab9bede | |||
| 97486cfa21 | |||
| d8ba887514 | |||
| bb43b5696c | |||
| 0f70cd1ac0 | |||
| 5bb9bc146e | |||
| 12d1174e39 | |||
| 3bbd986d41 | |||
| 712fa15a8c | |||
| ab03b142f5 | |||
| def95aae64 | |||
| 1b09c733f5 | |||
| 553c0e4963 | |||
| 8ede8427c8 | |||
| 8e66c68d6f | |||
| 37396823ad | |||
| 074f3d3760 | |||
| 206d9bd64a | |||
| 3f54632b07 | |||
| 609b12541e | |||
| 4369e58a3c | |||
| 8ef321bf70 | |||
| bee705d5d8 | |||
| 9b2ad91512 | |||
| 6740e53c18 | |||
| b2e5b66f25 | |||
| 45b82ad390 | |||
| d538d2f3e7 | |||
| 746f7f2733 | |||
| a1a67e89fb | |||
| 91fcbc5adc | |||
| 5f5eae07b0 | |||
| 380b1b1e28 | |||
| 03cfd114da | |||
| df90de77a7 | |||
| 7b97c532a1 | |||
| e0c2d37041 | |||
| f5d0ce066b | |||
| 202d0d87d7 | |||
| 99d2e67eb1 | |||
| a6413fb665 | |||
| 8a458c7c2a | |||
| 66b3c05da3 | |||
| cdcaa476f2 | |||
| 71ec5e6fca | |||
| a94c24c550 | |||
| a1f95fee58 | |||
| 24d76f8373 | |||
| 1453fb6492 | |||
| 8e18351c73 | |||
| 5cc6064e11 | |||
| a827fd3b43 | |||
| 467a307a8d | |||
| 077fca9038 | |||
| b326e3f1a6 | |||
| 54cbc648e0 | |||
| aba4270ceb | |||
| 57b0188158 | |||
| f374a4a775 | |||
| 6d31cb2ad6 | |||
| 1619f4857e | |||
| 58d19ec303 | |||
| 331dc0090e | |||
| cef8156a86 | |||
| 9751141ffc | |||
| 9c5ffbfb0c | |||
| 3361643bc0 | |||
| f04f1b8e77 | |||
| 447c5d7073 | |||
| aa2e8ed420 |
@@ -1 +0,0 @@
|
||||
{"schemaVersion":1,"label":"backend coverage","message":"87.79%","color":"brightgreen"}
|
||||
@@ -1 +0,0 @@
|
||||
{"schemaVersion":1,"label":"backend tests","message":"998 passed","color":"brightgreen"}
|
||||
@@ -1 +0,0 @@
|
||||
{"schemaVersion":1,"label":"coverage","message":"76%","color":"yellow"}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"45 passed","color":"brightgreen"}
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"31.35%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"39.68%","color":"red"}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"label":"go ingestor coverage","message":"70.2%","color":"yellow"}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"label":"go server coverage","message":"85.4%","color":"green"}
|
||||
@@ -1 +0,0 @@
|
||||
{"schemaVersion":1,"label":"tests","message":"844/844 passed","color":"brightgreen"}
|
||||
+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,14 @@
|
||||
# Force LF line endings for all text files (prevents CRLF churn from Windows agents)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly mark binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.db binary
|
||||
|
||||
# Squad: union merge for append-only team state files
|
||||
.squad/decisions.md merge=union
|
||||
.squad/agents/*/history.md merge=union
|
||||
.squad/log/** merge=union
|
||||
.squad/orchestration-log/** merge=union
|
||||
@@ -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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
+159
-441
@@ -1,43 +1,43 @@
|
||||
name: Deploy
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- '.gitignore'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
STAGING_COMPOSE_FILE: docker-compose.staging.yml
|
||||
STAGING_SERVICE: staging-go
|
||||
STAGING_CONTAINER: corescope-staging-go
|
||||
|
||||
# Pipeline (TWO INDEPENDENT TRACKS):
|
||||
# Track 1 (Node): node-test → build-node → deploy-node ──┐
|
||||
# Track 2 (Go): go-test → build-go → deploy-go ──┼──→ 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
|
||||
# Pipeline (sequential, fail-fast):
|
||||
# go-test → e2e-test → build → deploy → publish
|
||||
# PRs stop after build. Master continues to deploy + publish.
|
||||
|
||||
jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 1. Go Build & Test — compiles + tests Go modules, coverage badges
|
||||
# 1. Go Build & Test
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
go-test:
|
||||
name: "✅ Go Build & Test"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Clean Go module cache
|
||||
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: |
|
||||
@@ -62,14 +62,11 @@ jobs:
|
||||
echo "--- Go Ingestor Coverage ---"
|
||||
go tool cover -func=ingestor-coverage.out | tail -1
|
||||
|
||||
- name: Verify proto syntax (all .proto files compile)
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
set -e
|
||||
echo "Installing protoc..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
|
||||
echo "Checking proto syntax..."
|
||||
for proto in proto/*.proto; do
|
||||
echo " ✓ $(basename "$proto")"
|
||||
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
|
||||
@@ -77,37 +74,27 @@ jobs:
|
||||
echo "✅ All .proto files are syntactically valid"
|
||||
|
||||
- name: Generate Go coverage badges
|
||||
if: always()
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p .badges
|
||||
|
||||
# Parse server coverage
|
||||
SERVER_COV="0"
|
||||
if [ -f cmd/server/server-coverage.out ]; then
|
||||
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
fi
|
||||
SERVER_COLOR="red"
|
||||
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
|
||||
SERVER_COLOR="green"
|
||||
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
|
||||
SERVER_COLOR="yellow"
|
||||
fi
|
||||
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green"
|
||||
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
|
||||
echo "Go server coverage: ${SERVER_COV}% (${SERVER_COLOR})"
|
||||
|
||||
# Parse ingestor coverage
|
||||
INGESTOR_COV="0"
|
||||
if [ -f cmd/ingestor/ingestor-coverage.out ]; then
|
||||
INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
fi
|
||||
INGESTOR_COLOR="red"
|
||||
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
|
||||
INGESTOR_COLOR="green"
|
||||
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
|
||||
INGESTOR_COLOR="yellow"
|
||||
fi
|
||||
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green"
|
||||
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
|
||||
echo "Go ingestor coverage: ${INGESTOR_COV}% (${INGESTOR_COLOR})"
|
||||
|
||||
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -116,89 +103,68 @@ jobs:
|
||||
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload Go coverage badges
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/go-*.json
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 2. Node.js Tests — backend unit tests + Playwright E2E, coverage
|
||||
# 2. Playwright E2E Tests (against Go server with fixture DB)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
node-test:
|
||||
name: "🧪 Node.js Tests"
|
||||
runs-on: self-hosted
|
||||
e2e-test:
|
||||
name: "🎭 Playwright E2E Tests"
|
||||
needs: [go-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Clean Go module cache
|
||||
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: cmd/server/go.sum
|
||||
|
||||
- name: Build Go server
|
||||
run: |
|
||||
cd cmd/server
|
||||
go build -o ../../corescope-server .
|
||||
echo "Go server built successfully"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --production=false
|
||||
|
||||
- name: Detect changed files
|
||||
id: changes
|
||||
run: |
|
||||
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
|
||||
FRONTEND=$(git diff --name-only HEAD~1 | grep -cE '^public/' || true)
|
||||
TESTS=$(git diff --name-only HEAD~1 | grep -cE '^test-|^tools/' || true)
|
||||
CI=$(git diff --name-only HEAD~1 | grep -cE '\.github/|package\.json|test-all\.sh|scripts/' || true)
|
||||
# If CI/test infra changed, run everything
|
||||
if [ "$CI" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
|
||||
# If test files changed, run everything
|
||||
if [ "$TESTS" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
|
||||
echo "backend=$([[ $BACKEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
|
||||
echo "frontend=$([[ $FRONTEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
|
||||
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
if: steps.changes.outputs.backend == 'true'
|
||||
run: |
|
||||
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
|
||||
|
||||
TOTAL_PASS=$(grep -oP '\d+(?= passed)' test-output.txt | awk '{s+=$1} END {print s}')
|
||||
TOTAL_FAIL=$(grep -oP '\d+(?= failed)' test-output.txt | awk '{s+=$1} END {print s}')
|
||||
BE_COVERAGE=$(grep 'Statements' test-output.txt | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
|
||||
mkdir -p .badges
|
||||
BE_COLOR="red"
|
||||
[ "$(echo "$BE_COVERAGE > 60" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="yellow"
|
||||
[ "$(echo "$BE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="brightgreen"
|
||||
echo "{\"schemaVersion\":1,\"label\":\"backend tests\",\"message\":\"${TOTAL_PASS} passed\",\"color\":\"brightgreen\"}" > .badges/backend-tests.json
|
||||
echo "{\"schemaVersion\":1,\"label\":\"backend coverage\",\"message\":\"${BE_COVERAGE}%\",\"color\":\"${BE_COLOR}\"}" > .badges/backend-coverage.json
|
||||
|
||||
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Run backend tests (quick, no coverage)
|
||||
if: steps.changes.outputs.backend == 'false'
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: npx playwright install chromium --with-deps 2>/dev/null || true
|
||||
run: |
|
||||
npx playwright install chromium 2>/dev/null || true
|
||||
npx playwright install-deps chromium 2>/dev/null || true
|
||||
|
||||
- name: Instrument frontend JS for coverage
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: sh scripts/instrument-frontend.sh
|
||||
|
||||
- name: Start instrumented test server on port 13581
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
- name: Start Go server with fixture DB
|
||||
run: |
|
||||
# Kill any stale server on 13581
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
sleep 2
|
||||
COVERAGE=1 PORT=13581 node server.js &
|
||||
sleep 1
|
||||
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented &
|
||||
echo $! > .server.pid
|
||||
echo "Server PID: $(cat .server.pid)"
|
||||
# Health-check poll loop (up to 30s)
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
@@ -206,26 +172,27 @@ jobs:
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "Server failed to start within 30s"
|
||||
echo "Last few lines from server logs:"
|
||||
ps aux | grep "PORT=13581" || echo "No server process found"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage report
|
||||
if: always() && steps.changes.outputs.frontend == 'true'
|
||||
- name: Run Playwright E2E tests (fail-fast)
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
|
||||
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
||||
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true
|
||||
|
||||
- name: Generate frontend coverage badges
|
||||
if: success()
|
||||
run: |
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
|
||||
|
||||
mkdir -p .badges
|
||||
if [ -f .nyc_output/frontend-coverage.json ]; then
|
||||
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
||||
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
||||
FE_COVERAGE=${FE_COVERAGE:-0}
|
||||
@@ -235,74 +202,39 @@ jobs:
|
||||
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
|
||||
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
|
||||
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
|
||||
|
||||
- name: Stop test server
|
||||
if: always() && steps.changes.outputs.frontend == 'true'
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f .server.pid ]; then
|
||||
kill $(cat .server.pid) 2>/dev/null || true
|
||||
rm -f .server.pid
|
||||
echo "Server stopped"
|
||||
fi
|
||||
|
||||
- name: Run frontend E2E (quick, no coverage)
|
||||
if: steps.changes.outputs.frontend == 'false'
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
PORT=13581 node server.js &
|
||||
SERVER_PID=$!
|
||||
sleep 5
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
|
||||
- name: Upload Node.js test badges
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload E2E badges
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: node-badges
|
||||
name: e2e-badges
|
||||
path: .badges/
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
needs: [e2e-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- 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"
|
||||
needs: [go-test]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -311,349 +243,135 @@ jobs:
|
||||
echo "${GITHUB_SHA::7}" > .git-commit
|
||||
APP_VERSION=$(node -p "require('./package.json').version") \
|
||||
GIT_COMMIT="${GITHUB_SHA::7}" \
|
||||
docker compose --profile staging-go build staging-go
|
||||
echo "Built Go staging image"
|
||||
APP_VERSION=$(grep -oP 'APP_VERSION:-\K[^}]+' docker-compose.yml | head -1 || echo "3.0.0")
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
export APP_VERSION GIT_COMMIT BUILD_TIME
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE"
|
||||
echo "Built Go staging image ✅"
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 5. Deploy Node Staging — start on port 81, healthcheck, smoke test
|
||||
# 4. Deploy Staging (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
deploy-node:
|
||||
name: "🚀 Deploy Node Staging"
|
||||
needs: [build-node]
|
||||
runs-on: self-hosted
|
||||
deploy:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare staging environment
|
||||
- name: Deploy staging
|
||||
run: |
|
||||
set -e
|
||||
# Stop old container and release memory
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --timeout 30 2>/dev/null || true
|
||||
|
||||
# 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"
|
||||
# Wait for container to be fully gone and OS to reclaim memory (3GB limit)
|
||||
for i in $(seq 1 15); do
|
||||
if ! docker ps -a --format '{{.Names}}' | grep -q 'corescope-staging-go'; then
|
||||
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
|
||||
sleep 5 # extra pause for OS memory reclaim
|
||||
|
||||
- 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 ✅"
|
||||
# Ensure staging data dir exists (config.json lives here, no separate file mount)
|
||||
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
mkdir -p "$STAGING_DATA"
|
||||
|
||||
- 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"
|
||||
# If no config exists, copy the example (CI doesn't have a real prod config)
|
||||
if [ ! -f "$STAGING_DATA/config.json" ]; then
|
||||
echo "Staging config missing — copying config.example.json"
|
||||
cp config.example.json "$STAGING_DATA/config.json" 2>/dev/null || true
|
||||
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
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
|
||||
|
||||
- name: Start Go staging on port 82
|
||||
run: |
|
||||
# Force remove stale containers
|
||||
docker rm -f meshcore-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 (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
name: "📝 Publish Badges & Summary"
|
||||
needs: [deploy-node, deploy-go]
|
||||
runs-on: self-hosted
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Go coverage badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Download Node.js test badges
|
||||
- name: Download E2E badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: node-badges
|
||||
name: e2e-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Publish coverage badges to repo
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BADGE_PUSH_TOKEN }}
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "actions@github.com"
|
||||
git remote set-url origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git
|
||||
git add .badges/ -f
|
||||
git diff --cached --quiet || (git commit -m "ci: update test badges [skip ci]" && git push) || echo "Badge push failed"
|
||||
# GITHUB_TOKEN cannot push to protected branches (required status checks).
|
||||
# Use admin PAT (BADGE_PUSH_TOKEN) via GitHub Contents API instead.
|
||||
for badge in .badges/*.json; do
|
||||
FILENAME=$(basename "$badge")
|
||||
FILEPATH=".badges/$FILENAME"
|
||||
CONTENT=$(base64 -w0 "$badge")
|
||||
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$FILEPATH" --jq '.sha' 2>/dev/null || echo "")
|
||||
if [ -n "$CURRENT_SHA" ]; then
|
||||
gh api "repos/${{ github.repository }}/contents/$FILEPATH" \
|
||||
-X PUT \
|
||||
-f message="ci: update $FILENAME [skip ci]" \
|
||||
-f content="$CONTENT" \
|
||||
-f sha="$CURRENT_SHA" \
|
||||
-f branch="master" \
|
||||
--silent 2>&1 || echo "Failed to update $FILENAME"
|
||||
else
|
||||
gh api "repos/${{ github.repository }}/contents/$FILEPATH" \
|
||||
-X PUT \
|
||||
-f message="ci: update $FILENAME [skip ci]" \
|
||||
-f content="$CONTENT" \
|
||||
-f branch="master" \
|
||||
--silent 2>&1 || echo "Failed to create $FILENAME"
|
||||
fi
|
||||
done
|
||||
echo "Badge publish complete"
|
||||
|
||||
- name: Post deployment summary
|
||||
run: |
|
||||
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Node Staging:** http://<VM_HOST>:81" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Go 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 "./manage.sh promote" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
name: Squad Heartbeat (Ralph)
|
||||
# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all:
|
||||
# - templates/workflows/squad-heartbeat.yml (source template)
|
||||
# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package)
|
||||
# - .squad/templates/workflows/squad-heartbeat.yml (installed template)
|
||||
# - .github/workflows/squad-heartbeat.yml (active workflow)
|
||||
# Run 'squad upgrade' to sync installed copies from source templates.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes — adjust via cron expression as needed
|
||||
- cron: '*/30 * * * *'
|
||||
|
||||
# React to completed work or new squad work
|
||||
issues:
|
||||
types: [closed, labeled]
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
heartbeat:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check triage script
|
||||
id: check-script
|
||||
run: |
|
||||
if [ -f ".squad/templates/ralph-triage.js" ]; then
|
||||
echo "has_script=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_script=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install"
|
||||
fi
|
||||
|
||||
- name: Ralph — Smart triage
|
||||
if: steps.check-script.outputs.has_script == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
node .squad/templates/ralph-triage.js \
|
||||
--squad-dir .squad \
|
||||
--output triage-results.json
|
||||
|
||||
- name: Ralph — Apply triage decisions
|
||||
if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = 'triage-results.json';
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('No triage results — board is clear');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
if (results.length === 0) {
|
||||
core.info('📋 Board is clear — Ralph found no untriaged issues');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const decision of results) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: decision.issueNumber,
|
||||
labels: [decision.label]
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: decision.issueNumber,
|
||||
body: [
|
||||
'### 🔄 Ralph — Auto-Triage',
|
||||
'',
|
||||
`**Assigned to:** ${decision.assignTo}`,
|
||||
`**Reason:** ${decision.reason}`,
|
||||
`**Source:** ${decision.source}`,
|
||||
'',
|
||||
'> Ralph auto-triaged this issue using routing rules.',
|
||||
'> To reassign, swap the `squad:*` label.'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
|
||||
|
||||
# Copilot auto-assign step (uses PAT if available)
|
||||
- name: Ralph — Assign @copilot issues
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) return;
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
|
||||
// Check if @copilot is on the team with auto-assign
|
||||
const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
|
||||
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
|
||||
if (!hasCopilot || !autoAssign) return;
|
||||
|
||||
// Find issues labeled squad:copilot with no assignee
|
||||
try {
|
||||
const { data: copilotIssues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'squad:copilot',
|
||||
state: 'open',
|
||||
per_page: 5
|
||||
});
|
||||
|
||||
const unassigned = copilotIssues.filter(i =>
|
||||
!i.assignees || i.assignees.length === 0
|
||||
);
|
||||
|
||||
if (unassigned.length === 0) {
|
||||
core.info('No unassigned squad:copilot issues');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get repo default branch
|
||||
const { data: repoData } = await github.rest.repos.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
|
||||
for (const issue of unassigned) {
|
||||
try {
|
||||
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['copilot-swe-agent[bot]'],
|
||||
agent_assignment: {
|
||||
target_repo: `${context.repo.owner}/${context.repo.repo}`,
|
||||
base_branch: repoData.default_branch,
|
||||
custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
|
||||
}
|
||||
});
|
||||
core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.info(`No squad:copilot label found or error: ${e.message}`);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Squad Issue Assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
assign-work:
|
||||
# Only trigger on squad:{member} labels (not the base "squad" label)
|
||||
if: startsWith(github.event.label.name, 'squad:')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Identify assigned member and trigger work
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issue = context.payload.issue;
|
||||
const label = context.payload.label.name;
|
||||
|
||||
// Extract member name from label (e.g., "squad:ripley" → "ripley")
|
||||
const memberName = label.replace('squad:', '').toLowerCase();
|
||||
|
||||
// Read team roster — check .squad/ first, fall back to .ai-team/
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Check if this is a coding agent assignment
|
||||
const isCopilotAssignment = memberName === 'copilot';
|
||||
|
||||
let assignedMember = null;
|
||||
if (isCopilotAssignment) {
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
} else {
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0].toLowerCase() === memberName) {
|
||||
assignedMember = { name: cells[0], role: cells[1] };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!assignedMember) {
|
||||
core.warning(`No member found matching label "${label}"`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Post assignment acknowledgment
|
||||
let comment;
|
||||
if (isCopilotAssignment) {
|
||||
comment = [
|
||||
`### 🤖 Routed to @copilot (Coding Agent)`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
'',
|
||||
`@copilot has been assigned and will pick this up automatically.`,
|
||||
'',
|
||||
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
|
||||
`> Review the PR as you would any team member's work.`,
|
||||
].join('\n');
|
||||
} else {
|
||||
comment = [
|
||||
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
'',
|
||||
`${assignedMember.name} will pick this up in the next Copilot session.`,
|
||||
'',
|
||||
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
|
||||
`> Otherwise, start a Copilot session and say:`,
|
||||
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
|
||||
|
||||
# Separate step: assign @copilot using PAT (required for coding agent)
|
||||
- name: Assign @copilot coding agent
|
||||
if: github.event.label.name == 'squad:copilot'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
|
||||
// Get the default branch name (main, master, etc.)
|
||||
const { data: repoData } = await github.rest.repos.get({ owner, repo });
|
||||
const baseBranch = repoData.default_branch;
|
||||
|
||||
try {
|
||||
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
assignees: ['copilot-swe-agent[bot]'],
|
||||
agent_assignment: {
|
||||
target_repo: `${owner}/${repo}`,
|
||||
base_branch: baseBranch,
|
||||
custom_instructions: '',
|
||||
custom_agent: '',
|
||||
model: ''
|
||||
},
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
});
|
||||
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
|
||||
} catch (err) {
|
||||
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
|
||||
// Fallback: try without agent_assignment
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner, repo, issue_number,
|
||||
assignees: ['copilot-swe-agent']
|
||||
});
|
||||
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
|
||||
} catch (err2) {
|
||||
core.warning(`Fallback also failed: ${err2.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
name: Squad Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
if: github.event.label.name == 'squad'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Triage issue via Lead agent
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issue = context.payload.issue;
|
||||
|
||||
// Read team roster — check .squad/ first, fall back to .ai-team/
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Check if @copilot is on the team
|
||||
const hasCopilot = content.includes('🤖 Coding Agent');
|
||||
const copilotAutoAssign = content.includes('<!-- copilot-auto-assign: true -->');
|
||||
|
||||
// Parse @copilot capability profile
|
||||
let goodFitKeywords = [];
|
||||
let needsReviewKeywords = [];
|
||||
let notSuitableKeywords = [];
|
||||
|
||||
if (hasCopilot) {
|
||||
// Extract capability tiers from team.md
|
||||
const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i);
|
||||
const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i);
|
||||
const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i);
|
||||
|
||||
if (goodFitMatch) {
|
||||
goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation'];
|
||||
}
|
||||
if (needsReviewMatch) {
|
||||
needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration'];
|
||||
}
|
||||
if (notSuitableMatch) {
|
||||
notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance'];
|
||||
}
|
||||
}
|
||||
|
||||
const members = [];
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0] !== 'Scribe') {
|
||||
members.push({
|
||||
name: cells[0],
|
||||
role: cells[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read routing rules — check .squad/ first, fall back to .ai-team/
|
||||
let routingFile = '.squad/routing.md';
|
||||
if (!fs.existsSync(routingFile)) {
|
||||
routingFile = '.ai-team/routing.md';
|
||||
}
|
||||
let routingContent = '';
|
||||
if (fs.existsSync(routingFile)) {
|
||||
routingContent = fs.readFileSync(routingFile, 'utf8');
|
||||
}
|
||||
|
||||
// Find the Lead
|
||||
const lead = members.find(m =>
|
||||
m.role.toLowerCase().includes('lead') ||
|
||||
m.role.toLowerCase().includes('architect') ||
|
||||
m.role.toLowerCase().includes('coordinator')
|
||||
);
|
||||
|
||||
if (!lead) {
|
||||
core.warning('No Lead role found in team roster — cannot triage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build triage context
|
||||
const memberList = members.map(m =>
|
||||
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
|
||||
).join('\n');
|
||||
|
||||
// Determine best assignee based on issue content and routing
|
||||
const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
|
||||
|
||||
let assignedMember = null;
|
||||
let triageReason = '';
|
||||
let copilotTier = null;
|
||||
|
||||
// First, evaluate @copilot fit if enabled
|
||||
if (hasCopilot) {
|
||||
const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
|
||||
const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
|
||||
const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
|
||||
|
||||
if (isGoodFit) {
|
||||
copilotTier = 'good-fit';
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
triageReason = '🟢 Good fit for @copilot — matches capability profile';
|
||||
} else if (isNeedsReview) {
|
||||
copilotTier = 'needs-review';
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR';
|
||||
} else if (isNotSuitable) {
|
||||
copilotTier = 'not-suitable';
|
||||
// Fall through to normal routing
|
||||
}
|
||||
}
|
||||
|
||||
// If not routed to @copilot, use keyword-based routing
|
||||
if (!assignedMember) {
|
||||
for (const member of members) {
|
||||
const role = member.role.toLowerCase();
|
||||
if ((role.includes('frontend') || role.includes('ui')) &&
|
||||
(issueText.includes('ui') || issueText.includes('frontend') ||
|
||||
issueText.includes('css') || issueText.includes('component') ||
|
||||
issueText.includes('button') || issueText.includes('page') ||
|
||||
issueText.includes('layout') || issueText.includes('design'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to frontend/UI work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
|
||||
(issueText.includes('api') || issueText.includes('backend') ||
|
||||
issueText.includes('database') || issueText.includes('endpoint') ||
|
||||
issueText.includes('server') || issueText.includes('auth'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to backend/API work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
|
||||
(issueText.includes('test') || issueText.includes('bug') ||
|
||||
issueText.includes('fix') || issueText.includes('regression') ||
|
||||
issueText.includes('coverage'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to testing/quality work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
|
||||
(issueText.includes('deploy') || issueText.includes('ci') ||
|
||||
issueText.includes('pipeline') || issueText.includes('docker') ||
|
||||
issueText.includes('infrastructure'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to DevOps/infrastructure work';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Lead if no routing match
|
||||
if (!assignedMember) {
|
||||
assignedMember = lead;
|
||||
triageReason = 'No specific domain match — assigned to Lead for further analysis';
|
||||
}
|
||||
|
||||
const isCopilot = assignedMember.name === '@copilot';
|
||||
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
|
||||
|
||||
// Add the member-specific label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [assignLabel]
|
||||
});
|
||||
|
||||
// Apply default triage verdict
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['go:needs-research']
|
||||
});
|
||||
|
||||
// Auto-assign @copilot if enabled
|
||||
if (isCopilot && copilotAutoAssign) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['copilot']
|
||||
});
|
||||
} catch (err) {
|
||||
core.warning(`Could not auto-assign @copilot: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build copilot evaluation note
|
||||
let copilotNote = '';
|
||||
if (hasCopilot && !isCopilot) {
|
||||
if (copilotTier === 'not-suitable') {
|
||||
copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`;
|
||||
} else {
|
||||
copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Post triage comment
|
||||
const comment = [
|
||||
`### 🏗️ Squad Triage — ${lead.name} (${lead.role})`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
`**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
|
||||
`**Reason:** ${triageReason}`,
|
||||
copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '',
|
||||
copilotNote,
|
||||
'',
|
||||
`---`,
|
||||
'',
|
||||
`**Team roster:**`,
|
||||
memberList,
|
||||
hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '',
|
||||
'',
|
||||
`> To reassign, remove the current \`squad:*\` label and add the correct one.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`);
|
||||
@@ -0,0 +1,169 @@
|
||||
name: Sync Squad Labels
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.squad/team.md'
|
||||
- '.ai-team/team.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Parse roster and sync labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Parse the Members table for agent names
|
||||
const members = [];
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0] !== 'Scribe') {
|
||||
members.push({
|
||||
name: cells[0],
|
||||
role: cells[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`);
|
||||
|
||||
// Check if @copilot is on the team
|
||||
const hasCopilot = content.includes('🤖 Coding Agent');
|
||||
|
||||
// Define label color palette for squad labels
|
||||
const SQUAD_COLOR = '9B8FCC';
|
||||
const MEMBER_COLOR = '9B8FCC';
|
||||
const COPILOT_COLOR = '10b981';
|
||||
|
||||
// Define go: and release: labels (static)
|
||||
const GO_LABELS = [
|
||||
{ name: 'go:yes', color: '0E8A16', description: 'Ready to implement' },
|
||||
{ name: 'go:no', color: 'B60205', description: 'Not pursuing' },
|
||||
{ name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' }
|
||||
];
|
||||
|
||||
const RELEASE_LABELS = [
|
||||
{ name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' },
|
||||
{ name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' },
|
||||
{ name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' },
|
||||
{ name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' },
|
||||
{ name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' }
|
||||
];
|
||||
|
||||
const TYPE_LABELS = [
|
||||
{ name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
|
||||
{ name: 'type:bug', color: 'FF0422', description: 'Something broken' },
|
||||
{ name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' },
|
||||
{ name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
|
||||
{ name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
|
||||
{ name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
|
||||
];
|
||||
|
||||
// High-signal labels — these MUST visually dominate all others
|
||||
const SIGNAL_LABELS = [
|
||||
{ name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
|
||||
{ name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' }
|
||||
];
|
||||
|
||||
const PRIORITY_LABELS = [
|
||||
{ name: 'priority:p0', color: 'B60205', description: 'Blocking release' },
|
||||
{ name: 'priority:p1', color: 'D93F0B', description: 'This sprint' },
|
||||
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
|
||||
];
|
||||
|
||||
// Ensure the base "squad" triage label exists
|
||||
const labels = [
|
||||
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
|
||||
];
|
||||
|
||||
for (const member of members) {
|
||||
labels.push({
|
||||
name: `squad:${member.name.toLowerCase()}`,
|
||||
color: MEMBER_COLOR,
|
||||
description: `Assigned to ${member.name} (${member.role})`
|
||||
});
|
||||
}
|
||||
|
||||
// Add @copilot label if coding agent is on the team
|
||||
if (hasCopilot) {
|
||||
labels.push({
|
||||
name: 'squad:copilot',
|
||||
color: COPILOT_COLOR,
|
||||
description: 'Assigned to @copilot (Coding Agent) for autonomous work'
|
||||
});
|
||||
}
|
||||
|
||||
// Add go:, release:, type:, priority:, and high-signal labels
|
||||
labels.push(...GO_LABELS);
|
||||
labels.push(...RELEASE_LABELS);
|
||||
labels.push(...TYPE_LABELS);
|
||||
labels.push(...PRIORITY_LABELS);
|
||||
labels.push(...SIGNAL_LABELS);
|
||||
|
||||
// Sync labels (create or update)
|
||||
for (const label of labels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name
|
||||
});
|
||||
// Label exists — update it
|
||||
await github.rest.issues.updateLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description
|
||||
});
|
||||
core.info(`Updated label: ${label.name}`);
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
// Label doesn't exist — create it
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description
|
||||
});
|
||||
core.info(`Created label: ${label.name}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Label sync complete: ${labels.length} labels synced`);
|
||||
@@ -28,3 +28,5 @@ reps.txt
|
||||
cmd/server/server.exe
|
||||
cmd/ingestor/ingestor.exe
|
||||
# CI trigger
|
||||
!test-fixtures/e2e-fixture.db
|
||||
corescope-server
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"include": [
|
||||
"public/*.js"
|
||||
],
|
||||
"exclude": [
|
||||
"public/vendor/**",
|
||||
"public/leaflet-*.js",
|
||||
"public/qrcode*.js"
|
||||
]
|
||||
}
|
||||
@@ -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,17 +1,23 @@
|
||||
# AGENTS.md — MeshCore Analyzer
|
||||
# AGENTS.md — CoreScope
|
||||
|
||||
Guide for AI agents working on this codebase. Read this before writing any code.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Node.js server + static frontend. No build step. No framework. No bundler.
|
||||
Go backend + static frontend. No build step. No framework. No bundler.
|
||||
|
||||
**⚠️ The Node.js server (server.js) is DEPRECATED and has been removed. All backend code is in Go.**
|
||||
**⚠️ DO NOT create or modify any Node.js server files. All backend changes go in `cmd/server/` or `cmd/ingestor/`.**
|
||||
|
||||
```
|
||||
server.js — Express API + MQTT ingestion + WebSocket broadcast
|
||||
decoder.js — MeshCore packet parser (header, path, payload, adverts)
|
||||
packet-store.js — In-memory packet store + query engine (backed by SQLite)
|
||||
db.js — SQLite schema + prepared statements
|
||||
public/ — Frontend (vanilla JS, one file per page)
|
||||
cmd/server/ — Go API server (REST + WebSocket broadcast + static file serving)
|
||||
main.go — Entry point, flags, SPA handler
|
||||
routes.go — All /api/* endpoints
|
||||
store.go — In-memory packet store + analytics + SQLite queries
|
||||
config.go — Configuration loading
|
||||
decoder.go — MeshCore packet decoder
|
||||
cmd/ingestor/ — Go MQTT ingestor (separate binary, writes to shared SQLite DB)
|
||||
public/ — Frontend (vanilla JS, one file per page) — ACTIVE, NOT DEPRECATED
|
||||
app.js — SPA router, shared globals, theme loading
|
||||
roles.js — ROLE_COLORS, TYPE_COLORS, health thresholds, shared helpers
|
||||
nodes.js — Nodes list + side pane + full detail page
|
||||
@@ -28,17 +34,25 @@ public/ — Frontend (vanilla JS, one file per page)
|
||||
live.css — Live page styles
|
||||
home.css — Home page styles
|
||||
index.html — SPA shell, script/style tags with cache busters
|
||||
test-fixtures/ — Real data SQLite fixture from staging (used for E2E tests)
|
||||
scripts/ — Tooling (coverage collector, fixture capture, frontend instrumentation)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
1. MQTT brokers → server.js ingests packets → decoder.js parses → packet-store.js stores in memory + SQLite
|
||||
2. WebSocket broadcasts new packets to connected browsers
|
||||
3. Frontend fetches via REST API, filters/sorts client-side
|
||||
1. MQTT brokers → Go ingestor (`cmd/ingestor/`) ingests packets → decodes → writes to SQLite
|
||||
2. Go server (`cmd/server/`) polls SQLite for new packets, broadcasts via WebSocket
|
||||
3. Frontend fetches via REST API (`/api/*`), filters/sorts client-side
|
||||
|
||||
### What's Deprecated (DO NOT TOUCH)
|
||||
The following were part of the old Node.js backend and have been removed:
|
||||
- `server.js`, `db.js`, `decoder.js`, `server-helpers.js`, `packet-store.js`, `iata-coords.js`
|
||||
- All `test-server-*.js`, `test-decoder*.js`, `test-db*.js`, `test-regional*.js` files
|
||||
- If you see references to these in comments or docs, they're stale — ignore them
|
||||
|
||||
## Rules — Read These First
|
||||
|
||||
### 1. No commit without tests
|
||||
Every change that touches logic MUST have unit tests. Run `node test-packet-filter.js && node test-aging.js` before pushing. If you add new logic, add tests to the appropriate test file or create a new one. No exceptions.
|
||||
Every change that touches logic MUST have tests. For Go backend: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test ./...`. For frontend: `node test-packet-filter.js && node test-aging.js && node test-frontend-helpers.js`. If you add new logic, add tests. No exceptions.
|
||||
|
||||
### 2. No commit without browser validation
|
||||
After pushing, verify the change works in an actual browser. Use `browser profile=openclaw` against the running instance. Take a screenshot if the change is visual. If you can't validate it, say so — don't claim it works.
|
||||
@@ -77,6 +91,9 @@ Never use `git add -A` or `git add .`. Always list files explicitly: `git add fi
|
||||
### 10. Don't regress performance
|
||||
The packets page loads 30K+ packets. Don't add per-packet API calls. Don't add O(n²) loops. Client-side filtering is preferred over server-side. If you need data from the server, fetch it once and cache it.
|
||||
|
||||
### 11. PR descriptions must be clean markdown
|
||||
When opening a pull request, the description must be **valid, readable markdown**. Use real newlines (not `\n` literals), proper code fences, and correct heading syntax. Write it using `--body-file -` (piped from a heredoc or file), never inline `--body` with escaped characters. If the description renders as garbage, fix it before requesting review. This is the first thing reviewers see.
|
||||
|
||||
## MeshCore Firmware — Source of Truth
|
||||
|
||||
The MeshCore firmware source is cloned at `firmware/` (gitignored — not part of this repo). This is THE authoritative reference for anything related to the protocol, packet format, device behavior, advert structure, flags, hash sizes, route types, or how repeaters/companions/rooms/sensors behave.
|
||||
|
||||
+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,14 +1,14 @@
|
||||
# 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.
|
||||
|
||||
Self-hosted, open-source MeshCore packet analyzer — a community alternative to the closed-source `analyzer.letsmesh.net`. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
||||
Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# v3.1.0 — Now It's CoreScope
|
||||
|
||||
MeshCore Analyzer has a new name: **CoreScope**. Same mesh analysis you rely on, sharper identity, and a boatload of fixes and performance wins since v3.0.0.
|
||||
|
||||
48 commits, 30+ issues closed. Here's what changed.
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Renamed to CoreScope
|
||||
|
||||
The project is now **CoreScope** — frontend, backend, Docker images, manage.sh, docs, CI — everything has been updated. The URL, the API, the database, and your config all stay the same. Just a better name for the tool the community built.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
| What | Before | After |
|
||||
|------|--------|-------|
|
||||
| Subpath analytics | 900 ms | **5 ms** (precomputed at ingest) |
|
||||
| Distance analytics | 1.2 s | **15 ms** (precomputed at ingest) |
|
||||
| Packet ingest (prepend) | O(n) slice copy | **O(1) append** |
|
||||
| Go runtime stats | GC stop-the-world on every call | **cached ReadMemStats** |
|
||||
| All analytics endpoints | computed per-request | **TTL-cached** |
|
||||
|
||||
The in-memory store now precomputes subpaths and distance data as packets arrive, eliminating expensive full-table scans on the analytics endpoints. The O(n) slice prepend on every ingest — the single hottest line in the server — is gone. `ReadMemStats` calls are cached to prevent GC pause spikes under load.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 New Features
|
||||
|
||||
### Telemetry Decode
|
||||
Sensor nodes now report **battery voltage** and **temperature** parsed from advert payloads. Telemetry is gated on the sensor flag — only real sensors emit data, and 0°C is no longer falsely reported. Safe migration with `PRAGMA` column checks.
|
||||
|
||||
### Channel Decryption for Custom Channels
|
||||
The `hashChannels` config now works in the Go ingestor. Key derivation has been ported from Node.js with full AES-128-ECB support and garbage text detection — wrong keys silently fail instead of producing garbled output.
|
||||
|
||||
### Node Pruning
|
||||
Stale nodes are automatically moved to an `inactive_nodes` table after the configurable retention window. Pruning runs hourly. Your active node list stays clean. (#202)
|
||||
|
||||
### Duplicate Node Name Badges
|
||||
Nodes with the same display name but different public keys are flagged with a badge so you can spot collisions instantly.
|
||||
|
||||
### Sortable Channels Table
|
||||
Channel columns are now sortable with click-to-sort headers. Sort preferences persist in `localStorage` across sessions. (#167)
|
||||
|
||||
### Go Runtime Metrics
|
||||
The performance page exposes goroutine count, heap allocation, GC pause percentiles, and memory breakdown when connected to a Go backend.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **Channel decryption regression** (#176) — full AES-128-ECB in Go, garbage text detection, hashChannels key derivation ported correctly (#218)
|
||||
- **Packets page not live-updating** (#172) — WebSocket broadcast now includes the nested packet object and timestamp fields the frontend expects; multiple fixes across broadcast and render paths
|
||||
- **Node detail page crashes** (#190) — `Number()` casts and `Array.isArray` guards prevent rendering errors on unexpected data shapes
|
||||
- **Observation count staleness** (#174) — trace page and packet detail now show correct observation counts
|
||||
- **Phantom node cleanup** (#133) — `autoLearnHopNodes` no longer creates fake nodes from 1-byte repeater IDs
|
||||
- **Advert count inflation** (#200) — counts unique transmissions, not total observations (8 observers × 1 advert = 1, not 8)
|
||||
- **SQLite BUSY contention** (#214) — `MaxOpenConns(1)` + `MaxIdleConns(1)` serializes writes; load-tested under concurrent ingest
|
||||
- **Decoder bounds check** (#183) — corrupt/malformed packets no longer crash the decoder with buffer overruns
|
||||
- **noise_floor / battery_mv type mismatches** — consistent `float64` scanning handles SQLite REAL values correctly
|
||||
- **packetsLastHour always zero** (#182) — early `break` in observer loop prevented counting
|
||||
- **Channels stale messages** (#171) — latest message sorted by observation timestamp, not first-seen
|
||||
- **pprof port conflict** — non-fatal bind with separate ports prevents Go server crash on startup
|
||||
|
||||
---
|
||||
|
||||
## ♿ Accessibility & 📱 Mobile
|
||||
|
||||
### WCAG AA Compliance (10 fixes)
|
||||
- Search results keyboard-accessible with `tabindex`, `role`, and arrow-key navigation (#208)
|
||||
- 40+ table headers given `scope` attributes (#211)
|
||||
- 9 Chart.js canvases given accessible names (#210)
|
||||
- Form inputs in customizer/filters paired with labels (#212)
|
||||
|
||||
### Mobile Responsive
|
||||
- **Live page**: bottom-sheet panel instead of full-screen overlay (#203)
|
||||
- **Perf page**: responsive layout with stacked cards (#204)
|
||||
- **Nodes table**: column hiding at narrow viewports (#205)
|
||||
- **Analytics/Compare**: horizontal scroll wrappers (#206)
|
||||
- **VCR bar**: 44px minimum touch targets (#207)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Infrastructure
|
||||
|
||||
### manage.sh Refactored (#230)
|
||||
`manage.sh` is now a thin wrapper around `docker compose` — no custom container management, no divergent logic. It reads `.env` for data paths, matching how `docker-compose.yml` works. One source of truth.
|
||||
|
||||
### .env Support
|
||||
Data directory, ports, and image tags are configured via `.env`. Both `docker compose` and `manage.sh` read the same file.
|
||||
|
||||
### Branch Protection & CI on PRs
|
||||
- Branch protection enabled on `master` — CI must pass, PRs required
|
||||
- CI now triggers on `pull_request`, not just `push` — catch failures before merge (#199)
|
||||
|
||||
### Protobuf API Contract
|
||||
10 `.proto` files, 33 golden fixtures, CI validation on every push. API shape drift is caught automatically.
|
||||
|
||||
### pprof Profiling
|
||||
Controlled by `ENABLE_PPROF` env var. When enabled, exposes Go profiling endpoints on separate ports — zero overhead when off.
|
||||
|
||||
### Test Coverage
|
||||
- Go backend: **92%+** coverage
|
||||
- **49 Playwright E2E tests**
|
||||
- Both tracks gate deploy in CI
|
||||
|
||||
---
|
||||
|
||||
## 📦 Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
./manage.sh stop
|
||||
./manage.sh setup
|
||||
```
|
||||
|
||||
That's it. Your existing `config.json` and database work as-is. The rename is cosmetic — no schema changes, no API changes, no config changes.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
curl -s http://localhost/api/health | grep engine
|
||||
# "engine": "go"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Breaking Changes
|
||||
|
||||
**None.** All API endpoints, WebSocket messages, and config options are backwards-compatible. The rename affects branding only — Docker image names, page titles, and documentation.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
- **efiten** — PR #222 performance fix (O(n) slice prepend elimination)
|
||||
- **jade-on-mesh**, **lincomatic**, **LitBomb**, **mibzzer15** — ongoing testing, feedback, and issue reports
|
||||
|
||||
And to everyone running CoreScope on their mesh networks — your real-world data drives every fix and feature in this release. 48 commits since v3.0.0, and every one of them came from something the community found, reported, or requested.
|
||||
|
||||
---
|
||||
|
||||
*Previous release: [v3.0.0](RELEASE-v3.0.0.md)*
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
#!/bin/bash
|
||||
# A/B benchmark: old (pre-perf) vs new (current)
|
||||
# Usage: ./benchmark-ab.sh
|
||||
set -e
|
||||
|
||||
PORT_OLD=13003
|
||||
PORT_NEW=13004
|
||||
RUNS=3
|
||||
DB_PATH="$(pwd)/data/meshcore.db"
|
||||
|
||||
OLD_COMMIT="23caae4"
|
||||
NEW_COMMIT="$(git rev-parse HEAD)"
|
||||
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " A/B Benchmark: Pre-optimization vs Current"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo "OLD: $OLD_COMMIT (v2.0.1 — before any perf work)"
|
||||
echo "NEW: $NEW_COMMIT (current)"
|
||||
echo "Runs per endpoint: $RUNS"
|
||||
echo ""
|
||||
|
||||
# Get a real node pubkey for testing
|
||||
ORIG_DIR="$(pwd)"
|
||||
PUBKEY=$(sqlite3 "$DB_PATH" "SELECT public_key FROM nodes ORDER BY last_seen DESC LIMIT 1")
|
||||
echo "Test node: ${PUBKEY:0:16}..."
|
||||
echo ""
|
||||
|
||||
# Setup old version in temp dir
|
||||
OLD_DIR=$(mktemp -d)
|
||||
echo "Cloning old version to $OLD_DIR..."
|
||||
git worktree add "$OLD_DIR" "$OLD_COMMIT" --quiet 2>/dev/null || {
|
||||
git worktree add "$OLD_DIR" "$OLD_COMMIT" --detach --quiet
|
||||
}
|
||||
# Copy config + db symlink
|
||||
# Copy config + db + share node_modules
|
||||
cp config.json "$OLD_DIR/"
|
||||
mkdir -p "$OLD_DIR/data"
|
||||
cp "$ORIG_DIR/data/meshcore.db" "$OLD_DIR/data/meshcore.db"
|
||||
ln -sf "$ORIG_DIR/node_modules" "$OLD_DIR/node_modules"
|
||||
|
||||
ENDPOINTS=(
|
||||
"Stats|/api/stats"
|
||||
"Packets(50)|/api/packets?limit=50"
|
||||
"PacketsGrouped|/api/packets?limit=50&groupByHash=true"
|
||||
"NodesList|/api/nodes?limit=50"
|
||||
"NodeDetail|/api/nodes/$PUBKEY"
|
||||
"NodeHealth|/api/nodes/$PUBKEY/health"
|
||||
"NodeAnalytics|/api/nodes/$PUBKEY/analytics?days=7"
|
||||
"BulkHealth|/api/nodes/bulk-health?limit=50"
|
||||
"NetworkStatus|/api/nodes/network-status"
|
||||
"Channels|/api/channels"
|
||||
"Observers|/api/observers"
|
||||
"RF|/api/analytics/rf"
|
||||
"Topology|/api/analytics/topology"
|
||||
"ChannelAnalytics|/api/analytics/channels"
|
||||
"HashSizes|/api/analytics/hash-sizes"
|
||||
)
|
||||
|
||||
bench_endpoint() {
|
||||
local port=$1 path=$2 runs=$3 nocache=$4
|
||||
local total=0
|
||||
for i in $(seq 1 $runs); do
|
||||
local url="http://127.0.0.1:$port$path"
|
||||
if [ "$nocache" = "1" ]; then
|
||||
if echo "$path" | grep -q '?'; then
|
||||
url="${url}&nocache=1"
|
||||
else
|
||||
url="${url}?nocache=1"
|
||||
fi
|
||||
fi
|
||||
local ms=$(curl -s -o /dev/null -w "%{time_total}" "$url" 2>/dev/null)
|
||||
local ms_int=$(echo "$ms * 1000" | bc | cut -d. -f1)
|
||||
total=$((total + ms_int))
|
||||
done
|
||||
echo $((total / runs))
|
||||
}
|
||||
|
||||
# Launch old server
|
||||
echo "Starting OLD server (port $PORT_OLD)..."
|
||||
cd "$OLD_DIR"
|
||||
PORT=$PORT_OLD node server.js &>/dev/null &
|
||||
OLD_PID=$!
|
||||
cd - >/dev/null
|
||||
|
||||
# Launch new server
|
||||
echo "Starting NEW server (port $PORT_NEW)..."
|
||||
PORT=$PORT_NEW node server.js &>/dev/null &
|
||||
NEW_PID=$!
|
||||
|
||||
# Wait for both
|
||||
sleep 12 # old server has no memory store; new needs prewarm
|
||||
|
||||
# Verify
|
||||
curl -s "http://127.0.0.1:$PORT_OLD/api/stats" >/dev/null 2>&1 || { echo "OLD server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
|
||||
curl -s "http://127.0.0.1:$PORT_NEW/api/stats" >/dev/null 2>&1 || { echo "NEW server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "Warming up caches on new server..."
|
||||
for ep in "${ENDPOINTS[@]}"; do
|
||||
path="${ep#*|}"
|
||||
curl -s -o /dev/null "http://127.0.0.1:$PORT_NEW$path" 2>/dev/null
|
||||
done
|
||||
sleep 2
|
||||
|
||||
printf "\n%-22s %9s %9s %9s %9s\n" "Endpoint" "Old(ms)" "New-cold" "New-cache" "Speedup"
|
||||
printf "%-22s %9s %9s %9s %9s\n" "──────────────────────" "─────────" "─────────" "─────────" "─────────"
|
||||
|
||||
for ep in "${ENDPOINTS[@]}"; do
|
||||
name="${ep%%|*}"
|
||||
path="${ep#*|}"
|
||||
|
||||
old_ms=$(bench_endpoint $PORT_OLD "$path" $RUNS 0)
|
||||
new_cold=$(bench_endpoint $PORT_NEW "$path" $RUNS 1)
|
||||
new_cached=$(bench_endpoint $PORT_NEW "$path" $RUNS 0)
|
||||
|
||||
if [ "$old_ms" -gt 0 ] && [ "$new_cached" -gt 0 ]; then
|
||||
speedup="${old_ms}/${new_cached}"
|
||||
speedup_x=$(echo "scale=0; $old_ms / $new_cached" | bc 2>/dev/null || echo "?")
|
||||
printf "%-22s %7dms %7dms %7dms %7d×\n" "$name" "$old_ms" "$new_cold" "$new_cached" "$speedup_x"
|
||||
else
|
||||
printf "%-22s %7dms %7dms %7dms %9s\n" "$name" "$old_ms" "$new_cold" "$new_cached" "∞"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
|
||||
# Cleanup
|
||||
kill $OLD_PID $NEW_PID 2>/dev/null
|
||||
git worktree remove "$OLD_DIR" --force 2>/dev/null
|
||||
echo "Done."
|
||||
-246
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Benchmark suite for meshcore-analyzer.
|
||||
* Launches two server instances — one with in-memory store, one with pure SQLite —
|
||||
* and compares performance side by side.
|
||||
*
|
||||
* Usage: node benchmark.js [--runs 5] [--json]
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const RUNS = Number(args.find((a, i) => args[i - 1] === '--runs') || 5);
|
||||
const JSON_OUT = args.includes('--json');
|
||||
|
||||
const PORT_MEM = 13001; // In-memory store
|
||||
const PORT_SQL = 13002; // SQLite-only
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ name: 'Stats', path: '/api/stats' },
|
||||
{ name: 'Packets (50)', path: '/api/packets?limit=50' },
|
||||
{ name: 'Packets (100)', path: '/api/packets?limit=100' },
|
||||
{ name: 'Packets grouped', path: '/api/packets?limit=100&groupByHash=true' },
|
||||
{ name: 'Packets filtered', path: '/api/packets?limit=50&type=5' },
|
||||
{ name: 'Packets timestamps', path: '/api/packets/timestamps?since=2020-01-01' },
|
||||
{ name: 'Nodes list', path: '/api/nodes?limit=50' },
|
||||
{ name: 'Node detail', path: '/api/nodes/__FIRST_NODE__' },
|
||||
{ name: 'Node health', path: '/api/nodes/__FIRST_NODE__/health' },
|
||||
{ name: 'Bulk health', path: '/api/nodes/bulk-health?limit=50' },
|
||||
{ name: 'Network status', path: '/api/nodes/network-status' },
|
||||
{ name: 'Observers', path: '/api/observers' },
|
||||
{ name: 'Channels', path: '/api/channels' },
|
||||
{ name: 'RF Analytics', path: '/api/analytics/rf' },
|
||||
{ name: 'Topology', path: '/api/analytics/topology' },
|
||||
{ name: 'Channel Analytics', path: '/api/analytics/channels' },
|
||||
{ name: 'Hash Sizes', path: '/api/analytics/hash-sizes' },
|
||||
{ name: 'Subpaths 2-hop', path: '/api/analytics/subpaths?minLen=2&maxLen=2&limit=50' },
|
||||
{ name: 'Subpaths 3-hop', path: '/api/analytics/subpaths?minLen=3&maxLen=3&limit=30' },
|
||||
{ name: 'Subpaths 4-hop', path: '/api/analytics/subpaths?minLen=4&maxLen=4&limit=20' },
|
||||
{ name: 'Subpaths 5-8 hop', path: '/api/analytics/subpaths?minLen=5&maxLen=8&limit=15' },
|
||||
];
|
||||
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const req = http.get(url, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
resolve({ ms, bytes: Buffer.byteLength(body), status: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
function median(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)]; }
|
||||
function p95(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length*0.95)]; }
|
||||
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
|
||||
function fmt(ms) { return ms >= 1000 ? (ms/1000).toFixed(1)+'s' : ms.toFixed(1)+'ms'; }
|
||||
function fmtSize(b) { return b >= 1048576 ? (b/1048576).toFixed(1)+'MB' : b >= 1024 ? (b/1024).toFixed(0)+'KB' : b+'B'; }
|
||||
|
||||
function launchServer(port, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
env: { ...process.env, PORT: String(port), ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let started = false;
|
||||
const timeout = setTimeout(() => { if (!started) { child.kill(); reject(new Error('Server start timeout')); } }, 30000);
|
||||
|
||||
child.stdout.on('data', (d) => {
|
||||
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
|
||||
started = true; clearTimeout(timeout); resolve(child);
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (d) => {
|
||||
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
|
||||
started = true; clearTimeout(timeout); resolve(child);
|
||||
}
|
||||
});
|
||||
child.on('exit', (code) => { if (!started) { clearTimeout(timeout); reject(new Error(`Server exited with ${code}`)); } });
|
||||
|
||||
// Fallback: wait longer (SQLite-only mode pre-warms subpaths ~6s)
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true; clearTimeout(timeout);
|
||||
resolve(child);
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port, maxMs = 20000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < maxMs) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/stats`);
|
||||
if (r.status === 200) return true;
|
||||
} catch {}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server on port ${port} didn't start`);
|
||||
}
|
||||
|
||||
async function benchmarkEndpoints(port, endpoints, nocache = false) {
|
||||
const results = [];
|
||||
for (const ep of endpoints) {
|
||||
const suffix = nocache ? (ep.path.includes('?') ? '&nocache=1' : '?nocache=1') : '';
|
||||
const url = `http://127.0.0.1:${port}${ep.path}${suffix}`;
|
||||
|
||||
// Warm-up
|
||||
try { await fetch(url); } catch {}
|
||||
|
||||
const times = [];
|
||||
let bytes = 0;
|
||||
let failed = false;
|
||||
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.status !== 200) { failed = true; break; }
|
||||
times.push(r.ms);
|
||||
bytes = r.bytes;
|
||||
} catch { failed = true; break; }
|
||||
}
|
||||
|
||||
if (failed || !times.length) {
|
||||
results.push({ name: ep.name, failed: true });
|
||||
} else {
|
||||
results.push({
|
||||
name: ep.name,
|
||||
avg: Math.round(avg(times) * 10) / 10,
|
||||
p50: Math.round(median(times) * 10) / 10,
|
||||
p95: Math.round(p95(times) * 10) / 10,
|
||||
bytes
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`\nMeshCore Analyzer Benchmark — ${RUNS} runs per endpoint`);
|
||||
console.log('Launching servers...\n');
|
||||
|
||||
// Launch both servers
|
||||
let memServer, sqlServer;
|
||||
try {
|
||||
console.log(' Starting in-memory server (port ' + PORT_MEM + ')...');
|
||||
memServer = await launchServer(PORT_MEM, {});
|
||||
await waitForServer(PORT_MEM);
|
||||
console.log(' ✅ In-memory server ready');
|
||||
|
||||
console.log(' Starting SQLite-only server (port ' + PORT_SQL + ')...');
|
||||
sqlServer = await launchServer(PORT_SQL, { NO_MEMORY_STORE: '1' });
|
||||
await waitForServer(PORT_SQL);
|
||||
console.log(' ✅ SQLite-only server ready\n');
|
||||
} catch (e) {
|
||||
console.error('Failed to start servers:', e.message);
|
||||
if (memServer) memServer.kill();
|
||||
if (sqlServer) sqlServer.kill();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get first node pubkey
|
||||
let firstNode = '';
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/nodes?limit=1`);
|
||||
const data = JSON.parse(r.body);
|
||||
firstNode = data.nodes?.[0]?.public_key || '';
|
||||
} catch {}
|
||||
|
||||
const endpoints = ENDPOINTS.map(e => ({
|
||||
...e,
|
||||
path: e.path.replace('__FIRST_NODE__', firstNode),
|
||||
}));
|
||||
|
||||
// Get packet count
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/stats`);
|
||||
const stats = JSON.parse(r.body);
|
||||
console.log(`Dataset: ${(stats.totalPackets || '?').toLocaleString()} packets\n`);
|
||||
} catch {}
|
||||
|
||||
// Run benchmarks
|
||||
console.log('Benchmarking in-memory store (nocache for true compute cost)...');
|
||||
const memResults = await benchmarkEndpoints(PORT_MEM, endpoints, true);
|
||||
|
||||
console.log('Benchmarking SQLite-only (nocache)...');
|
||||
const sqlResults = await benchmarkEndpoints(PORT_SQL, endpoints, true);
|
||||
|
||||
// Also test cached in-memory for the full picture
|
||||
console.log('Benchmarking in-memory store (cached)...');
|
||||
const memCachedResults = await benchmarkEndpoints(PORT_MEM, endpoints, false);
|
||||
|
||||
// Kill servers
|
||||
memServer.kill();
|
||||
sqlServer.kill();
|
||||
|
||||
if (JSON_OUT) {
|
||||
console.log(JSON.stringify({ memoryNocache: memResults, sqliteNocache: sqlResults, memoryCached: memCachedResults }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Print results
|
||||
const W = 94;
|
||||
console.log(`\n${'═'.repeat(W)}`);
|
||||
console.log(' 🏁 BENCHMARK RESULTS: SQLite vs In-Memory Store');
|
||||
console.log(`${'═'.repeat(W)}`);
|
||||
console.log(`${'Endpoint'.padEnd(24)} ${'SQLite'.padStart(9)} ${'Memory'.padStart(9)} ${'Cached'.padStart(9)} ${'Speedup'.padStart(9)} ${'Size (SQL)'.padStart(10)} ${'Size (Mem)'.padStart(10)}`);
|
||||
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(10)} ${'─'.repeat(10)}`);
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const sql = sqlResults[i];
|
||||
const mem = memResults[i];
|
||||
const cached = memCachedResults[i];
|
||||
if (!sql || sql.failed || !mem || mem.failed) {
|
||||
console.log(`${endpoints[i].name.padEnd(24)} ${'FAILED'.padStart(9)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const speedup = sql.avg > 0 && mem.avg > 0 ? Math.round(sql.avg / mem.avg) + '×' : '—';
|
||||
const cachedStr = cached && !cached.failed ? fmt(cached.avg) : '—';
|
||||
|
||||
console.log(
|
||||
`${sql.name.padEnd(24)} ${fmt(sql.avg).padStart(9)} ${fmt(mem.avg).padStart(9)} ${cachedStr.padStart(9)} ${speedup.padStart(9)} ${fmtSize(sql.bytes).padStart(10)} ${fmtSize(mem.bytes).padStart(10)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const sqlTotal = sqlResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
|
||||
const memTotal = memResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
|
||||
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)}`);
|
||||
console.log(`${'TOTAL'.padEnd(24)} ${fmt(sqlTotal).padStart(9)} ${fmt(memTotal).padStart(9)} ${''.padStart(9)} ${(Math.round(sqlTotal/memTotal)+'×').padStart(9)}`);
|
||||
console.log(`\n${'═'.repeat(W)}\n`);
|
||||
}
|
||||
|
||||
run().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+107
-24
@@ -72,8 +72,8 @@ type Header struct {
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
NextHop string `json:"nextHop"`
|
||||
LastHop string `json:"lastHop"`
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
@@ -92,6 +92,8 @@ type AdvertFlags struct {
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
@@ -111,6 +113,10 @@ type Payload struct {
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Feat1 *int `json:"feat1,omitempty"`
|
||||
Feat2 *int `json:"feat2,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"`
|
||||
@@ -121,6 +127,8 @@ type Payload struct {
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -197,14 +205,13 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 6 {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
ExtraHash: hex.EncodeToString(buf[2:6]),
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +236,8 @@ func decodeAdvert(buf []byte) Payload {
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
@@ -237,6 +246,8 @@ func decodeAdvert(buf []byte) Payload {
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
@@ -250,11 +261,48 @@ func decodeAdvert(buf []byte) Payload {
|
||||
p.Lon = &lon
|
||||
off += 8
|
||||
}
|
||||
if hasFeat1 && len(appdata) >= off+2 {
|
||||
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat1 = &feat1
|
||||
off += 2
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat2 = &feat2
|
||||
off += 2
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,15 +488,22 @@ func decodePathPayload(buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 12 {
|
||||
if len(buf) < 9 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "TRACE",
|
||||
DestHash: hex.EncodeToString(buf[5:11]),
|
||||
SrcHash: hex.EncodeToString(buf[11:12]),
|
||||
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
||||
@@ -491,8 +546,7 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
pathByte := buf[1]
|
||||
offset := 2
|
||||
offset := 1
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
@@ -500,18 +554,39 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
}
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
@@ -533,16 +608,24 @@ func ComputeContentHash(rawHex string) string {
|
||||
return rawHex
|
||||
}
|
||||
|
||||
pathByte := buf[1]
|
||||
headerByte := buf[0]
|
||||
offset := 1
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
offset += 4
|
||||
}
|
||||
if offset >= len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
headerByte := buf[0]
|
||||
payloadStart := 2 + pathBytes
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
payloadStart += 4
|
||||
}
|
||||
payloadStart := offset + pathBytes
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
|
||||
+212
-25
@@ -129,7 +129,8 @@ func TestDecodePath3ByteHashes(t *testing.T) {
|
||||
|
||||
func TestTransportCodes(t *testing.T) {
|
||||
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
|
||||
hex := "1400" + "AABB" + "CCDD" + "1A" + strings.Repeat("00", 10)
|
||||
// Firmware order: header + transport_codes(4) + path_len + path + payload
|
||||
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
|
||||
pkt, err := DecodePacket(hex, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -140,11 +141,11 @@ func TestTransportCodes(t *testing.T) {
|
||||
if pkt.TransportCodes == nil {
|
||||
t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD")
|
||||
}
|
||||
if pkt.TransportCodes.NextHop != "AABB" {
|
||||
t.Errorf("nextHop=%s, want AABB", pkt.TransportCodes.NextHop)
|
||||
if pkt.TransportCodes.Code1 != "AABB" {
|
||||
t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1)
|
||||
}
|
||||
if pkt.TransportCodes.LastHop != "CCDD" {
|
||||
t.Errorf("lastHop=%s, want CCDD", pkt.TransportCodes.LastHop)
|
||||
if pkt.TransportCodes.Code2 != "CCDD" {
|
||||
t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2)
|
||||
}
|
||||
|
||||
// Route type 1 (FLOOD) should NOT have transport codes
|
||||
@@ -537,10 +538,11 @@ func TestDecodeTraceShort(t *testing.T) {
|
||||
|
||||
func TestDecodeTraceValid(t *testing.T) {
|
||||
buf := make([]byte, 16)
|
||||
buf[0] = 0x00
|
||||
buf[1] = 0x01 // tag LE uint32 = 1
|
||||
buf[5] = 0xAA // destHash start
|
||||
buf[11] = 0xBB
|
||||
// tag(4) + authCode(4) + flags(1) + pathData
|
||||
binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1
|
||||
binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode
|
||||
buf[8] = 0x02 // flags
|
||||
buf[9] = 0xAA // path data
|
||||
p := decodeTrace(buf)
|
||||
if p.Error != "" {
|
||||
t.Errorf("unexpected error: %s", p.Error)
|
||||
@@ -548,9 +550,43 @@ func TestDecodeTraceValid(t *testing.T) {
|
||||
if p.Tag != 1 {
|
||||
t.Errorf("tag=%d, want 1", p.Tag)
|
||||
}
|
||||
if p.AuthCode != 0xDEADBEEF {
|
||||
t.Errorf("authCode=%d, want 0xDEADBEEF", p.AuthCode)
|
||||
}
|
||||
if p.TraceFlags == nil || *p.TraceFlags != 2 {
|
||||
t.Errorf("traceFlags=%v, want 2", p.TraceFlags)
|
||||
}
|
||||
if p.Type != "TRACE" {
|
||||
t.Errorf("type=%s, want TRACE", p.Type)
|
||||
}
|
||||
if p.PathData == "" {
|
||||
t.Error("pathData should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTracePathParsing(t *testing.T) {
|
||||
// Packet from issue #276: 260001807dca00000000007d547d
|
||||
// Path byte 0x00 → hashSize=1, hops in payload at buf[9:] = 7d 54 7d
|
||||
// Expected path: ["7D", "54", "7D"]
|
||||
pkt, err := DecodePacket("260001807dca00000000007d547d", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "TRACE" {
|
||||
t.Errorf("payload type=%s, want TRACE", pkt.Payload.Type)
|
||||
}
|
||||
want := []string{"7D", "54", "7D"}
|
||||
if len(pkt.Path.Hops) != len(want) {
|
||||
t.Fatalf("hops=%v, want %v", pkt.Path.Hops, want)
|
||||
}
|
||||
for i, h := range want {
|
||||
if pkt.Path.Hops[i] != h {
|
||||
t.Errorf("hops[%d]=%s, want %s", i, pkt.Path.Hops[i], h)
|
||||
}
|
||||
}
|
||||
if pkt.Path.HashCount != 3 {
|
||||
t.Errorf("hashCount=%d, want 3", pkt.Path.HashCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAdvertShort(t *testing.T) {
|
||||
@@ -833,10 +869,9 @@ func TestComputeContentHashShortHex(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeContentHashTransportRoute(t *testing.T) {
|
||||
// Route type 0 (TRANSPORT_FLOOD) with no path hops + 4 transport code bytes
|
||||
// header=0x14 (TRANSPORT_FLOOD, ADVERT), path=0x00 (0 hops)
|
||||
// transport codes = 4 bytes, then payload
|
||||
hex := "1400" + "AABBCCDD" + strings.Repeat("EE", 10)
|
||||
// Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops)
|
||||
// header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00
|
||||
hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10)
|
||||
hash := ComputeContentHash(hex)
|
||||
if len(hash) != 16 {
|
||||
t.Errorf("hash length=%d, want 16", len(hash))
|
||||
@@ -870,12 +905,10 @@ func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) {
|
||||
|
||||
func TestComputeContentHashTransportBeyondBuffer(t *testing.T) {
|
||||
// Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes
|
||||
// total buffer too short for transport codes + path
|
||||
// header=0x00, pathByte=0x02 (2 hops, 1-byte hash), then only 2 more bytes
|
||||
// payloadStart = 2 + 2 + 4(transport) = 8, but buffer only 6 bytes
|
||||
hex := "0002" + "AABB" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
||||
// header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash)
|
||||
// offset=1+4+1+2=8, buffer needs to be >= 8
|
||||
hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
||||
hash := ComputeContentHash(hex)
|
||||
// payloadStart = 2 + 2 + 4 = 8, buffer is 10 bytes → should work
|
||||
if len(hash) != 16 {
|
||||
t.Errorf("hash length=%d, want 16", len(hash))
|
||||
}
|
||||
@@ -913,8 +946,8 @@ func TestDecodePacketWithNewlines(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
|
||||
// TRANSPORT_FLOOD (route=0) but only 3 bytes total → too short for transport codes
|
||||
_, err := DecodePacket("140011", nil)
|
||||
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes
|
||||
_, err := DecodePacket("1400", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for transport route with too-short buffer")
|
||||
}
|
||||
@@ -931,16 +964,19 @@ func TestDecodeAckShort(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodeAckValid(t *testing.T) {
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD}
|
||||
p := decodeAck(buf)
|
||||
if p.Error != "" {
|
||||
t.Errorf("unexpected error: %s", p.Error)
|
||||
}
|
||||
if p.DestHash != "aa" {
|
||||
t.Errorf("destHash=%s, want aa", p.DestHash)
|
||||
if p.ExtraHash != "ddccbbaa" {
|
||||
t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash)
|
||||
}
|
||||
if p.ExtraHash != "ccddeeff" {
|
||||
t.Errorf("extraHash=%s, want ccddeeff", p.ExtraHash)
|
||||
if p.DestHash != "" {
|
||||
t.Errorf("destHash should be empty, got %s", p.DestHash)
|
||||
}
|
||||
if p.SrcHash != "" {
|
||||
t.Errorf("srcHash should be empty, got %s", p.SrcHash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,3 +1391,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"])
|
||||
}
|
||||
}
|
||||
|
||||
+120
-19
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config mirrors the Node.js config.json structure (read-only fields).
|
||||
@@ -45,12 +47,51 @@ type Config struct {
|
||||
CacheTTL map[string]interface{} `json:"cacheTTL"`
|
||||
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
|
||||
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
|
||||
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
|
||||
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
|
||||
}
|
||||
|
||||
// PacketStoreConfig controls in-memory packet store limits.
|
||||
type PacketStoreConfig struct {
|
||||
RetentionHours float64 `json:"retentionHours"` // max age of packets in hours (0 = unlimited)
|
||||
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
|
||||
}
|
||||
|
||||
type GeoFilterConfig struct {
|
||||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||||
BufferKm float64 `json:"bufferKm,omitempty"`
|
||||
LatMin *float64 `json:"latMin,omitempty"`
|
||||
LatMax *float64 `json:"latMax,omitempty"`
|
||||
LonMin *float64 `json:"lonMin,omitempty"`
|
||||
LonMax *float64 `json:"lonMax,omitempty"`
|
||||
}
|
||||
|
||||
type TimestampConfig struct {
|
||||
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
|
||||
Timezone string `json:"timezone"` // "local" | "utc"
|
||||
FormatPreset string `json:"formatPreset"` // "iso" | "iso-seconds" | "locale"
|
||||
CustomFormat string `json:"customFormat"` // freeform, only used when AllowCustomFormat=true
|
||||
AllowCustomFormat bool `json:"allowCustomFormat"` // admin gate
|
||||
}
|
||||
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
}
|
||||
|
||||
func defaultTimestampConfig() TimestampConfig {
|
||||
return TimestampConfig{
|
||||
DefaultMode: "ago",
|
||||
Timezone: "local",
|
||||
FormatPreset: "iso",
|
||||
CustomFormat: "",
|
||||
AllowCustomFormat: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
|
||||
func (c *Config) NodeDaysOrDefault() int {
|
||||
if c.Retention != nil && c.Retention.NodeDays > 0 {
|
||||
@@ -60,10 +101,10 @@ func (c *Config) NodeDaysOrDefault() int {
|
||||
}
|
||||
|
||||
type HealthThresholds struct {
|
||||
InfraDegradedMs int `json:"infraDegradedMs"`
|
||||
InfraSilentMs int `json:"infraSilentMs"`
|
||||
NodeDegradedMs int `json:"nodeDegradedMs"`
|
||||
NodeSilentMs int `json:"nodeSilentMs"`
|
||||
InfraDegradedHours float64 `json:"infraDegradedHours"`
|
||||
InfraSilentHours float64 `json:"infraSilentHours"`
|
||||
NodeDegradedHours float64 `json:"nodeDegradedHours"`
|
||||
NodeSilentHours float64 `json:"nodeSilentHours"`
|
||||
}
|
||||
|
||||
// ThemeFile mirrors theme.json overlay.
|
||||
@@ -95,8 +136,10 @@ func LoadConfig(baseDirs ...string) (*Config, error) {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
cfg.NormalizeTimestampConfig()
|
||||
return cfg, nil
|
||||
}
|
||||
cfg.NormalizeTimestampConfig()
|
||||
return cfg, nil // defaults
|
||||
}
|
||||
|
||||
@@ -126,34 +169,46 @@ func LoadTheme(baseDirs ...string) *ThemeFile {
|
||||
|
||||
func (c *Config) GetHealthThresholds() HealthThresholds {
|
||||
h := HealthThresholds{
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
}
|
||||
if c.HealthThresholds != nil {
|
||||
if c.HealthThresholds.InfraDegradedMs > 0 {
|
||||
h.InfraDegradedMs = c.HealthThresholds.InfraDegradedMs
|
||||
if c.HealthThresholds.InfraDegradedHours > 0 {
|
||||
h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours
|
||||
}
|
||||
if c.HealthThresholds.InfraSilentMs > 0 {
|
||||
h.InfraSilentMs = c.HealthThresholds.InfraSilentMs
|
||||
if c.HealthThresholds.InfraSilentHours > 0 {
|
||||
h.InfraSilentHours = c.HealthThresholds.InfraSilentHours
|
||||
}
|
||||
if c.HealthThresholds.NodeDegradedMs > 0 {
|
||||
h.NodeDegradedMs = c.HealthThresholds.NodeDegradedMs
|
||||
if c.HealthThresholds.NodeDegradedHours > 0 {
|
||||
h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours
|
||||
}
|
||||
if c.HealthThresholds.NodeSilentMs > 0 {
|
||||
h.NodeSilentMs = c.HealthThresholds.NodeSilentMs
|
||||
if c.HealthThresholds.NodeSilentHours > 0 {
|
||||
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GetHealthMs returns degraded/silent thresholds for a given role.
|
||||
// GetHealthMs returns degraded/silent thresholds in ms for a given role.
|
||||
func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) {
|
||||
const hourMs = 3600000
|
||||
if role == "repeater" || role == "room" {
|
||||
return h.InfraDegradedMs, h.InfraSilentMs
|
||||
return int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs)
|
||||
}
|
||||
return int(h.NodeDegradedHours * hourMs), int(h.NodeSilentHours * hourMs)
|
||||
}
|
||||
|
||||
// ToClientMs returns the thresholds as ms for the frontend.
|
||||
func (h HealthThresholds) ToClientMs() map[string]int {
|
||||
const hourMs = 3600000
|
||||
return map[string]int{
|
||||
"infraDegradedMs": int(h.InfraDegradedHours * hourMs),
|
||||
"infraSilentMs": int(h.InfraSilentHours * hourMs),
|
||||
"nodeDegradedMs": int(h.NodeDegradedHours * hourMs),
|
||||
"nodeSilentMs": int(h.NodeSilentHours * hourMs),
|
||||
}
|
||||
return h.NodeDegradedMs, h.NodeSilentMs
|
||||
}
|
||||
|
||||
func (c *Config) ResolveDBPath(baseDir string) string {
|
||||
@@ -172,3 +227,49 @@ func (c *Config) PropagationBufferMs() int {
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeTimestampConfig() {
|
||||
defaults := defaultTimestampConfig()
|
||||
if c.Timestamps == nil {
|
||||
log.Printf("[config] timestamps not configured — using defaults (ago/local/iso)")
|
||||
c.Timestamps = &defaults
|
||||
return
|
||||
}
|
||||
|
||||
origMode := c.Timestamps.DefaultMode
|
||||
mode := strings.ToLower(strings.TrimSpace(origMode))
|
||||
switch mode {
|
||||
case "ago", "absolute":
|
||||
c.Timestamps.DefaultMode = mode
|
||||
default:
|
||||
log.Printf("[config] warning: timestamps.defaultMode=%q is invalid, using %q", origMode, defaults.DefaultMode)
|
||||
c.Timestamps.DefaultMode = defaults.DefaultMode
|
||||
}
|
||||
|
||||
origTimezone := c.Timestamps.Timezone
|
||||
timezone := strings.ToLower(strings.TrimSpace(origTimezone))
|
||||
switch timezone {
|
||||
case "local", "utc":
|
||||
c.Timestamps.Timezone = timezone
|
||||
default:
|
||||
log.Printf("[config] warning: timestamps.timezone=%q is invalid, using %q", origTimezone, defaults.Timezone)
|
||||
c.Timestamps.Timezone = defaults.Timezone
|
||||
}
|
||||
|
||||
origPreset := c.Timestamps.FormatPreset
|
||||
formatPreset := strings.ToLower(strings.TrimSpace(origPreset))
|
||||
switch formatPreset {
|
||||
case "iso", "iso-seconds", "locale":
|
||||
c.Timestamps.FormatPreset = formatPreset
|
||||
default:
|
||||
log.Printf("[config] warning: timestamps.formatPreset=%q is invalid, using %q", origPreset, defaults.FormatPreset)
|
||||
c.Timestamps.FormatPreset = defaults.FormatPreset
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetTimestampConfig() TimestampConfig {
|
||||
if c == nil || c.Timestamps == nil {
|
||||
return defaultTimestampConfig()
|
||||
}
|
||||
return *c.Timestamps
|
||||
}
|
||||
|
||||
+86
-33
@@ -23,14 +23,21 @@ func TestLoadConfigValidJSON(t *testing.T) {
|
||||
"SJC": "San Jose",
|
||||
},
|
||||
"healthThresholds": map[string]interface{}{
|
||||
"infraDegradedMs": 100000,
|
||||
"infraSilentMs": 200000,
|
||||
"nodeDegradedMs": 50000,
|
||||
"nodeSilentMs": 100000,
|
||||
"infraDegradedHours": 2,
|
||||
"infraSilentHours": 4,
|
||||
"nodeDegradedHours": 0.5,
|
||||
"nodeSilentHours": 2,
|
||||
},
|
||||
"liveMap": map[string]interface{}{
|
||||
"propagationBufferMs": 3000,
|
||||
},
|
||||
"timestamps": map[string]interface{}{
|
||||
"defaultMode": "absolute",
|
||||
"timezone": "utc",
|
||||
"formatPreset": "iso-seconds",
|
||||
"customFormat": "2006-01-02 15:04:05",
|
||||
"allowCustomFormat": true,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(cfgData)
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
||||
@@ -48,6 +55,18 @@ func TestLoadConfigValidJSON(t *testing.T) {
|
||||
if cfg.MapDefaults.Zoom != 12 {
|
||||
t.Errorf("expected zoom 12, got %d", cfg.MapDefaults.Zoom)
|
||||
}
|
||||
if cfg.Timestamps == nil {
|
||||
t.Fatal("expected timestamps config")
|
||||
}
|
||||
if cfg.Timestamps.DefaultMode != "absolute" {
|
||||
t.Errorf("expected defaultMode absolute, got %s", cfg.Timestamps.DefaultMode)
|
||||
}
|
||||
if cfg.Timestamps.Timezone != "utc" {
|
||||
t.Errorf("expected timezone utc, got %s", cfg.Timestamps.Timezone)
|
||||
}
|
||||
if cfg.Timestamps.FormatPreset != "iso-seconds" {
|
||||
t.Errorf("expected formatPreset iso-seconds, got %s", cfg.Timestamps.FormatPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromDataSubdir(t *testing.T) {
|
||||
@@ -76,6 +95,10 @@ func TestLoadConfigNoFiles(t *testing.T) {
|
||||
if cfg.Port != 3000 {
|
||||
t.Errorf("expected default port 3000, got %d", cfg.Port)
|
||||
}
|
||||
ts := cfg.GetTimestampConfig()
|
||||
if ts.DefaultMode != "ago" || ts.Timezone != "local" || ts.FormatPreset != "iso" {
|
||||
t.Errorf("expected default timestamp config ago/local/iso, got %s/%s/%s", ts.DefaultMode, ts.Timezone, ts.FormatPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInvalidJSON(t *testing.T) {
|
||||
@@ -102,6 +125,36 @@ func TestLoadConfigNoArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigTimestampNormalization(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgData := map[string]interface{}{
|
||||
"timestamps": map[string]interface{}{
|
||||
"defaultMode": "banana",
|
||||
"timezone": "mars",
|
||||
"formatPreset": "weird",
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(cfgData)
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Timestamps == nil {
|
||||
t.Fatal("expected timestamps to be set")
|
||||
}
|
||||
if cfg.Timestamps.DefaultMode != "ago" {
|
||||
t.Errorf("expected normalized defaultMode ago, got %s", cfg.Timestamps.DefaultMode)
|
||||
}
|
||||
if cfg.Timestamps.Timezone != "local" {
|
||||
t.Errorf("expected normalized timezone local, got %s", cfg.Timestamps.Timezone)
|
||||
}
|
||||
if cfg.Timestamps.FormatPreset != "iso" {
|
||||
t.Errorf("expected normalized formatPreset iso, got %s", cfg.Timestamps.FormatPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadThemeValidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
themeData := map[string]interface{}{
|
||||
@@ -178,68 +231,68 @@ func TestGetHealthThresholdsDefaults(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
ht := cfg.GetHealthThresholds()
|
||||
|
||||
if ht.InfraDegradedMs != 86400000 {
|
||||
t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs)
|
||||
if ht.InfraDegradedHours != 24 {
|
||||
t.Errorf("expected 24, got %v", ht.InfraDegradedHours)
|
||||
}
|
||||
if ht.InfraSilentMs != 259200000 {
|
||||
t.Errorf("expected 259200000, got %d", ht.InfraSilentMs)
|
||||
if ht.InfraSilentHours != 72 {
|
||||
t.Errorf("expected 72, got %v", ht.InfraSilentHours)
|
||||
}
|
||||
if ht.NodeDegradedMs != 3600000 {
|
||||
t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs)
|
||||
if ht.NodeDegradedHours != 1 {
|
||||
t.Errorf("expected 1, got %v", ht.NodeDegradedHours)
|
||||
}
|
||||
if ht.NodeSilentMs != 86400000 {
|
||||
t.Errorf("expected 86400000, got %d", ht.NodeSilentMs)
|
||||
if ht.NodeSilentHours != 24 {
|
||||
t.Errorf("expected 24, got %v", ht.NodeSilentHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthThresholdsCustom(t *testing.T) {
|
||||
cfg := &Config{
|
||||
HealthThresholds: &HealthThresholds{
|
||||
InfraDegradedMs: 100000,
|
||||
InfraSilentMs: 200000,
|
||||
NodeDegradedMs: 50000,
|
||||
NodeSilentMs: 100000,
|
||||
InfraDegradedHours: 2,
|
||||
InfraSilentHours: 4,
|
||||
NodeDegradedHours: 0.5,
|
||||
NodeSilentHours: 2,
|
||||
},
|
||||
}
|
||||
ht := cfg.GetHealthThresholds()
|
||||
|
||||
if ht.InfraDegradedMs != 100000 {
|
||||
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
|
||||
if ht.InfraDegradedHours != 2 {
|
||||
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
||||
}
|
||||
if ht.InfraSilentMs != 200000 {
|
||||
t.Errorf("expected 200000, got %d", ht.InfraSilentMs)
|
||||
if ht.InfraSilentHours != 4 {
|
||||
t.Errorf("expected 4, got %v", ht.InfraSilentHours)
|
||||
}
|
||||
if ht.NodeDegradedMs != 50000 {
|
||||
t.Errorf("expected 50000, got %d", ht.NodeDegradedMs)
|
||||
if ht.NodeDegradedHours != 0.5 {
|
||||
t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours)
|
||||
}
|
||||
if ht.NodeSilentMs != 100000 {
|
||||
t.Errorf("expected 100000, got %d", ht.NodeSilentMs)
|
||||
if ht.NodeSilentHours != 2 {
|
||||
t.Errorf("expected 2, got %v", ht.NodeSilentHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthThresholdsPartialCustom(t *testing.T) {
|
||||
cfg := &Config{
|
||||
HealthThresholds: &HealthThresholds{
|
||||
InfraDegradedMs: 100000,
|
||||
InfraDegradedHours: 2,
|
||||
// Others left as zero → should use defaults
|
||||
},
|
||||
}
|
||||
ht := cfg.GetHealthThresholds()
|
||||
|
||||
if ht.InfraDegradedMs != 100000 {
|
||||
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
|
||||
if ht.InfraDegradedHours != 2 {
|
||||
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
||||
}
|
||||
if ht.InfraSilentMs != 259200000 {
|
||||
t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs)
|
||||
if ht.InfraSilentHours != 72 {
|
||||
t.Errorf("expected default 72, got %v", ht.InfraSilentHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthMs(t *testing.T) {
|
||||
ht := HealthThresholds{
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
||||
+3717
-3735
File diff suppressed because it is too large
Load Diff
+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{} {
|
||||
|
||||
+143
-212
@@ -17,6 +17,8 @@ func setupTestDB(t *testing.T) *DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Force single connection so all goroutines share the same in-memory DB
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
// Create schema matching MeshCore Analyzer v3
|
||||
schema := `
|
||||
@@ -28,7 +30,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 +48,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 +75,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 +363,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()
|
||||
@@ -437,10 +513,10 @@ func TestGetNetworkStatus(t *testing.T) {
|
||||
seedTestData(t, db)
|
||||
|
||||
ht := HealthThresholds{
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
}
|
||||
result, err := db.GetNetworkStatus(ht)
|
||||
if err != nil {
|
||||
@@ -485,51 +561,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 +603,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 +652,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 +694,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()
|
||||
@@ -1129,10 +1050,10 @@ func TestGetNetworkStatusDateFormats(t *testing.T) {
|
||||
VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`)
|
||||
|
||||
ht := HealthThresholds{
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
}
|
||||
result, err := db.GetNetworkStatus(ht)
|
||||
if err != nil {
|
||||
@@ -1196,29 +1117,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 +1197,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 +1270,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())
|
||||
}
|
||||
|
||||
+70
-22
@@ -54,8 +54,8 @@ type Header struct {
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
NextHop string `json:"nextHop"`
|
||||
LastHop string `json:"lastHop"`
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
@@ -74,6 +74,8 @@ type AdvertFlags struct {
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
@@ -97,6 +99,8 @@ type Payload struct {
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -173,14 +177,13 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 6 {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
ExtraHash: hex.EncodeToString(buf[2:6]),
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +208,8 @@ func decodeAdvert(buf []byte) Payload {
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
@@ -213,6 +218,8 @@ func decodeAdvert(buf []byte) Payload {
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
@@ -226,6 +233,12 @@ func decodeAdvert(buf []byte) Payload {
|
||||
p.Lon = &lon
|
||||
off += 8
|
||||
}
|
||||
if hasFeat1 && len(appdata) >= off+2 {
|
||||
off += 2 // skip feat1 bytes (reserved for future use)
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
off += 2 // skip feat2 bytes (reserved for future use)
|
||||
}
|
||||
if p.Flags.HasName {
|
||||
name := string(appdata[off:])
|
||||
name = strings.TrimRight(name, "\x00")
|
||||
@@ -276,15 +289,22 @@ func decodePathPayload(buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 12 {
|
||||
if len(buf) < 9 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "TRACE",
|
||||
DestHash: hex.EncodeToString(buf[5:11]),
|
||||
SrcHash: hex.EncodeToString(buf[11:12]),
|
||||
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte) Payload {
|
||||
@@ -327,8 +347,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
pathByte := buf[1]
|
||||
offset := 2
|
||||
offset := 1
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
@@ -336,18 +355,39 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
}
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
@@ -367,16 +407,24 @@ func ComputeContentHash(rawHex string) string {
|
||||
return rawHex
|
||||
}
|
||||
|
||||
pathByte := buf[1]
|
||||
headerByte := buf[0]
|
||||
offset := 1
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
offset += 4
|
||||
}
|
||||
if offset >= len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
headerByte := buf[0]
|
||||
payloadStart := 2 + pathBytes
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
payloadStart += 4
|
||||
}
|
||||
payloadStart := offset + pathBytes
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeTestStore creates a PacketStore with fake packets for eviction testing.
|
||||
// It does NOT use a DB — indexes are populated manually.
|
||||
func makeTestStore(count int, startTime time.Time, intervalMin int) *PacketStore {
|
||||
store := &PacketStore{
|
||||
packets: make([]*StoreTx, 0, count),
|
||||
byHash: make(map[string]*StoreTx, count),
|
||||
byTxID: make(map[int]*StoreTx, count),
|
||||
byObsID: make(map[int]*StoreObs, count*2),
|
||||
byObserver: make(map[string][]*StoreObs),
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
spIndex: make(map[string]int),
|
||||
distHops: make([]distHopRecord, 0),
|
||||
distPaths: make([]distPathRecord, 0),
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
}
|
||||
|
||||
obsID := 1000
|
||||
for i := 0; i < count; i++ {
|
||||
ts := startTime.Add(time.Duration(i*intervalMin) * time.Minute)
|
||||
hash := fmt.Sprintf("hash%04d", i)
|
||||
txID := i + 1
|
||||
pt := 4 // ADVERT
|
||||
decodedJSON := fmt.Sprintf(`{"pubKey":"pk%04d"}`, i)
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: txID,
|
||||
Hash: hash,
|
||||
FirstSeen: ts.UTC().Format(time.RFC3339),
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: decodedJSON,
|
||||
PathJSON: `["aa","bb","cc"]`,
|
||||
}
|
||||
|
||||
// Add 2 observations per tx
|
||||
for j := 0; j < 2; j++ {
|
||||
obsID++
|
||||
obsIDStr := fmt.Sprintf("obs%d", j)
|
||||
obs := &StoreObs{
|
||||
ID: obsID,
|
||||
TransmissionID: txID,
|
||||
ObserverID: obsIDStr,
|
||||
ObserverName: fmt.Sprintf("Observer%d", j),
|
||||
Timestamp: ts.UTC().Format(time.RFC3339),
|
||||
}
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ObservationCount++
|
||||
store.byObsID[obsID] = obs
|
||||
store.byObserver[obsIDStr] = append(store.byObserver[obsIDStr], obs)
|
||||
store.totalObs++
|
||||
}
|
||||
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[hash] = tx
|
||||
store.byTxID[txID] = tx
|
||||
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
|
||||
|
||||
// Index by node
|
||||
pk := fmt.Sprintf("pk%04d", i)
|
||||
if store.nodeHashes[pk] == nil {
|
||||
store.nodeHashes[pk] = make(map[string]bool)
|
||||
}
|
||||
store.nodeHashes[pk][hash] = true
|
||||
store.byNode[pk] = append(store.byNode[pk], tx)
|
||||
|
||||
// Add to distance index
|
||||
store.distHops = append(store.distHops, distHopRecord{tx: tx, Hash: hash})
|
||||
store.distPaths = append(store.distPaths, distPathRecord{tx: tx, Hash: hash})
|
||||
|
||||
// Subpath index
|
||||
addTxToSubpathIndex(store.spIndex, tx)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestEvictStale_TimeBasedEviction(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
// 100 packets: first 50 are 48h old, last 50 are 1h old
|
||||
store := makeTestStore(100, now.Add(-48*time.Hour), 0)
|
||||
// Override: set first 50 to 48h ago, last 50 to 1h ago
|
||||
for i := 0; i < 50; i++ {
|
||||
store.packets[i].FirstSeen = now.Add(-48 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
for i := 50; i < 100; i++ {
|
||||
store.packets[i].FirstSeen = now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
store.retentionHours = 24
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted != 50 {
|
||||
t.Fatalf("expected 50 evicted, got %d", evicted)
|
||||
}
|
||||
if len(store.packets) != 50 {
|
||||
t.Fatalf("expected 50 remaining, got %d", len(store.packets))
|
||||
}
|
||||
if len(store.byHash) != 50 {
|
||||
t.Fatalf("expected 50 in byHash, got %d", len(store.byHash))
|
||||
}
|
||||
if len(store.byTxID) != 50 {
|
||||
t.Fatalf("expected 50 in byTxID, got %d", len(store.byTxID))
|
||||
}
|
||||
// 50 remaining * 2 obs each = 100 obs
|
||||
if store.totalObs != 100 {
|
||||
t.Fatalf("expected 100 obs remaining, got %d", store.totalObs)
|
||||
}
|
||||
if len(store.byObsID) != 100 {
|
||||
t.Fatalf("expected 100 in byObsID, got %d", len(store.byObsID))
|
||||
}
|
||||
if atomic.LoadInt64(&store.evicted) != 50 {
|
||||
t.Fatalf("expected evicted counter=50, got %d", atomic.LoadInt64(&store.evicted))
|
||||
}
|
||||
|
||||
// Verify evicted hashes are gone
|
||||
if _, ok := store.byHash["hash0000"]; ok {
|
||||
t.Fatal("hash0000 should have been evicted")
|
||||
}
|
||||
// Verify remaining hashes exist
|
||||
if _, ok := store.byHash["hash0050"]; !ok {
|
||||
t.Fatal("hash0050 should still exist")
|
||||
}
|
||||
|
||||
// Verify distance indexes cleaned
|
||||
if len(store.distHops) != 50 {
|
||||
t.Fatalf("expected 50 distHops, got %d", len(store.distHops))
|
||||
}
|
||||
if len(store.distPaths) != 50 {
|
||||
t.Fatalf("expected 50 distPaths, got %d", len(store.distPaths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_NoEvictionWhenDisabled(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(10, now.Add(-48*time.Hour), 60)
|
||||
// No retention set (defaults to 0)
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted != 0 {
|
||||
t.Fatalf("expected 0 evicted, got %d", evicted)
|
||||
}
|
||||
if len(store.packets) != 10 {
|
||||
t.Fatalf("expected 10 remaining, got %d", len(store.packets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_MemoryBasedEviction(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
// Create enough packets to exceed a small memory limit
|
||||
// 1000 packets * 5KB + 2000 obs * 500B ≈ 6MB
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
// All packets are recent (1h old) so time-based won't trigger
|
||||
store.retentionHours = 24
|
||||
store.maxMemoryMB = 3 // ~3MB limit, should evict roughly half
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
t.Fatal("expected some evictions for memory cap")
|
||||
}
|
||||
// After eviction, estimated memory should be <= 3MB
|
||||
estMB := store.estimatedMemoryMB()
|
||||
if estMB > 3.5 { // small tolerance
|
||||
t.Fatalf("expected <=3.5MB after eviction, got %.1fMB", estMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_CleansNodeIndexes(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(10, now.Add(-48*time.Hour), 0)
|
||||
store.retentionHours = 24
|
||||
|
||||
// Verify node indexes exist before eviction
|
||||
if len(store.byNode) != 10 {
|
||||
t.Fatalf("expected 10 nodes indexed, got %d", len(store.byNode))
|
||||
}
|
||||
if len(store.nodeHashes) != 10 {
|
||||
t.Fatalf("expected 10 nodeHashes, got %d", len(store.nodeHashes))
|
||||
}
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted != 10 {
|
||||
t.Fatalf("expected 10 evicted, got %d", evicted)
|
||||
}
|
||||
|
||||
// All should be cleaned
|
||||
if len(store.byNode) != 0 {
|
||||
t.Fatalf("expected 0 nodes, got %d", len(store.byNode))
|
||||
}
|
||||
if len(store.nodeHashes) != 0 {
|
||||
t.Fatalf("expected 0 nodeHashes, got %d", len(store.nodeHashes))
|
||||
}
|
||||
if len(store.byPayloadType) != 0 {
|
||||
t.Fatalf("expected 0 payload types, got %d", len(store.byPayloadType))
|
||||
}
|
||||
if len(store.byObserver) != 0 {
|
||||
t.Fatalf("expected 0 observers, got %d", len(store.byObserver))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_RunEvictionThreadSafe(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(20, now.Add(-48*time.Hour), 0)
|
||||
store.retentionHours = 24
|
||||
|
||||
evicted := store.RunEviction()
|
||||
if evicted != 20 {
|
||||
t.Fatalf("expected 20 evicted, got %d", evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartEvictionTicker_NoopWhenDisabled(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
stop := store.StartEvictionTicker()
|
||||
stop() // should not panic
|
||||
}
|
||||
|
||||
func TestNewPacketStoreWithConfig(t *testing.T) {
|
||||
cfg := &PacketStoreConfig{
|
||||
RetentionHours: 48,
|
||||
MaxMemoryMB: 512,
|
||||
}
|
||||
store := NewPacketStore(nil, cfg)
|
||||
if store.retentionHours != 48 {
|
||||
t.Fatalf("expected retentionHours=48, got %f", store.retentionHours)
|
||||
}
|
||||
if store.maxMemoryMB != 512 {
|
||||
t.Fatalf("expected maxMemoryMB=512, got %d", store.maxMemoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPacketStoreNilConfig(t *testing.T) {
|
||||
store := NewPacketStore(nil, nil)
|
||||
if store.retentionHours != 0 {
|
||||
t.Fatalf("expected retentionHours=0, got %f", store.retentionHours)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
module github.com/meshcore-analyzer/server
|
||||
module github.com/corescope/server
|
||||
|
||||
go 1.22
|
||||
|
||||
|
||||
+26
-4
@@ -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
|
||||
@@ -85,6 +100,9 @@ func main() {
|
||||
if dbPath != "" {
|
||||
cfg.DBPath = dbPath
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
log.Printf("[security] WARNING: no apiKey configured — write endpoints are BLOCKED (set apiKey in config.json to enable them)")
|
||||
}
|
||||
|
||||
// Resolve DB path
|
||||
resolvedDB := cfg.ResolveDBPath(configDir)
|
||||
@@ -101,7 +119,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()
|
||||
@@ -113,7 +131,7 @@ func main() {
|
||||
}
|
||||
|
||||
// In-memory packet store
|
||||
store := NewPacketStore(database)
|
||||
store := NewPacketStore(database, cfg.PacketStore)
|
||||
if err := store.Load(); err != nil {
|
||||
log.Fatalf("[store] failed to load: %v", err)
|
||||
}
|
||||
@@ -140,7 +158,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>`))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,6 +167,10 @@ func main() {
|
||||
poller.store = store
|
||||
go poller.Start()
|
||||
|
||||
// Start periodic eviction
|
||||
stopEviction := store.StartEvictionTicker()
|
||||
defer stopEviction()
|
||||
|
||||
// Graceful shutdown
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
@@ -167,7 +189,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)
|
||||
}
|
||||
|
||||
+506
-403
@@ -1,403 +1,506 @@
|
||||
package main
|
||||
|
||||
// parity_test.go — Golden fixture shape tests.
|
||||
// Validates that Go API responses match the shape of Node.js API responses.
|
||||
// Shapes were captured from the production Node.js server and stored in
|
||||
// testdata/golden/shapes.json.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// shapeSpec describes the expected JSON structure from the Node.js server.
|
||||
type shapeSpec struct {
|
||||
Type string `json:"type"`
|
||||
Keys map[string]shapeSpec `json:"keys,omitempty"`
|
||||
ElementShape *shapeSpec `json:"elementShape,omitempty"`
|
||||
DynamicKeys bool `json:"dynamicKeys,omitempty"`
|
||||
ValueShape *shapeSpec `json:"valueShape,omitempty"`
|
||||
RequiredKeys map[string]shapeSpec `json:"requiredKeys,omitempty"`
|
||||
}
|
||||
|
||||
// loadShapes reads testdata/golden/shapes.json relative to this source file.
|
||||
func loadShapes(t *testing.T) map[string]shapeSpec {
|
||||
t.Helper()
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
dir := filepath.Dir(thisFile)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "testdata", "golden", "shapes.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("cannot load shapes.json: %v", err)
|
||||
}
|
||||
var shapes map[string]shapeSpec
|
||||
if err := json.Unmarshal(data, &shapes); err != nil {
|
||||
t.Fatalf("cannot parse shapes.json: %v", err)
|
||||
}
|
||||
return shapes
|
||||
}
|
||||
|
||||
// validateShape recursively checks that `actual` matches the expected `spec`.
|
||||
// `path` tracks the JSON path for error messages.
|
||||
// Returns a list of mismatch descriptions.
|
||||
func validateShape(actual interface{}, spec shapeSpec, path string) []string {
|
||||
var errs []string
|
||||
|
||||
switch spec.Type {
|
||||
case "null", "nullable":
|
||||
// nullable means: value can be null OR matching type. Accept anything.
|
||||
return nil
|
||||
case "nullable_number":
|
||||
// Can be null or number
|
||||
if actual != nil {
|
||||
if _, ok := actual.(float64); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected number or null, got %T", path, actual))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
case "string":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected string, got null", path))
|
||||
} else if _, ok := actual.(string); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected string, got %T", path, actual))
|
||||
}
|
||||
case "number":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected number, got null", path))
|
||||
} else if _, ok := actual.(float64); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected number, got %T (%v)", path, actual, actual))
|
||||
}
|
||||
case "boolean":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected boolean, got null", path))
|
||||
} else if _, ok := actual.(bool); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected boolean, got %T", path, actual))
|
||||
}
|
||||
case "array":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected array, got null (arrays must be [] not null)", path))
|
||||
return errs
|
||||
}
|
||||
arr, ok := actual.([]interface{})
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected array, got %T", path, actual))
|
||||
return errs
|
||||
}
|
||||
if spec.ElementShape != nil && len(arr) > 0 {
|
||||
errs = append(errs, validateShape(arr[0], *spec.ElementShape, path+"[0]")...)
|
||||
}
|
||||
case "object":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected object, got null", path))
|
||||
return errs
|
||||
}
|
||||
obj, ok := actual.(map[string]interface{})
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected object, got %T", path, actual))
|
||||
return errs
|
||||
}
|
||||
|
||||
if spec.DynamicKeys {
|
||||
// Object with dynamic keys — validate value shapes
|
||||
if spec.ValueShape != nil && len(obj) > 0 {
|
||||
for k, v := range obj {
|
||||
errs = append(errs, validateShape(v, *spec.ValueShape, path+"."+k)...)
|
||||
break // check just one sample
|
||||
}
|
||||
}
|
||||
if spec.RequiredKeys != nil {
|
||||
for rk, rs := range spec.RequiredKeys {
|
||||
v, exists := obj[rk]
|
||||
if !exists {
|
||||
errs = append(errs, fmt.Sprintf("%s: missing required key %q in dynamic-key object", path, rk))
|
||||
} else {
|
||||
errs = append(errs, validateShape(v, rs, path+"."+rk)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if spec.Keys != nil {
|
||||
// Object with known keys — check each expected key exists and has correct type
|
||||
for key, keySpec := range spec.Keys {
|
||||
val, exists := obj[key]
|
||||
if !exists {
|
||||
errs = append(errs, fmt.Sprintf("%s: missing field %q (expected %s)", path, key, keySpec.Type))
|
||||
} else {
|
||||
errs = append(errs, validateShape(val, keySpec, path+"."+key)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// parityEndpoint defines one endpoint to test for parity.
|
||||
type parityEndpoint struct {
|
||||
name string // key in shapes.json
|
||||
path string // HTTP path to request
|
||||
}
|
||||
|
||||
func TestParityShapes(t *testing.T) {
|
||||
shapes := loadShapes(t)
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
endpoints := []parityEndpoint{
|
||||
{"stats", "/api/stats"},
|
||||
{"nodes", "/api/nodes?limit=5"},
|
||||
{"packets", "/api/packets?limit=5"},
|
||||
{"packets_grouped", "/api/packets?limit=5&groupByHash=true"},
|
||||
{"observers", "/api/observers"},
|
||||
{"channels", "/api/channels"},
|
||||
{"channel_messages", "/api/channels/0000000000000000/messages?limit=5"},
|
||||
{"analytics_rf", "/api/analytics/rf?days=7"},
|
||||
{"analytics_topology", "/api/analytics/topology?days=7"},
|
||||
{"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"},
|
||||
{"analytics_distance", "/api/analytics/distance?days=7"},
|
||||
{"analytics_subpaths", "/api/analytics/subpaths?days=7"},
|
||||
{"bulk_health", "/api/nodes/bulk-health"},
|
||||
{"health", "/api/health"},
|
||||
{"perf", "/api/perf"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run("Parity_"+ep.name, func(t *testing.T) {
|
||||
spec, ok := shapes[ep.name]
|
||||
if !ok {
|
||||
t.Fatalf("no shape spec found for %q in shapes.json", ep.name)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", ep.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET %s returned %d, expected 200. Body: %s",
|
||||
ep.path, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("GET %s returned invalid JSON: %v\nBody: %s",
|
||||
ep.path, err, w.Body.String())
|
||||
}
|
||||
|
||||
mismatches := validateShape(body, spec, ep.path)
|
||||
if len(mismatches) > 0 {
|
||||
t.Errorf("Go %s has %d shape mismatches vs Node.js golden:\n %s",
|
||||
ep.path, len(mismatches), strings.Join(mismatches, "\n "))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityNodeDetail tests node detail endpoint shape.
|
||||
// Uses a known test node public key from seeded data.
|
||||
func TestParityNodeDetail(t *testing.T) {
|
||||
shapes := loadShapes(t)
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
spec, ok := shapes["node_detail"]
|
||||
if !ok {
|
||||
t.Fatal("no shape spec for node_detail in shapes.json")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("node detail returned %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
mismatches := validateShape(body, spec, "/api/nodes/{pubkey}")
|
||||
if len(mismatches) > 0 {
|
||||
t.Errorf("Go node detail has %d shape mismatches vs Node.js golden:\n %s",
|
||||
len(mismatches), strings.Join(mismatches, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityArraysNotNull verifies that array-typed fields in Go responses are
|
||||
// [] (empty array) rather than null. This is a common Go/JSON pitfall where
|
||||
// nil slices marshal as null instead of [].
|
||||
// Uses shapes.json to know which fields SHOULD be arrays.
|
||||
func TestParityArraysNotNull(t *testing.T) {
|
||||
shapes := loadShapes(t)
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"stats", "/api/stats"},
|
||||
{"nodes", "/api/nodes?limit=5"},
|
||||
{"packets", "/api/packets?limit=5"},
|
||||
{"packets_grouped", "/api/packets?limit=5&groupByHash=true"},
|
||||
{"observers", "/api/observers"},
|
||||
{"channels", "/api/channels"},
|
||||
{"bulk_health", "/api/nodes/bulk-health"},
|
||||
{"analytics_rf", "/api/analytics/rf?days=7"},
|
||||
{"analytics_topology", "/api/analytics/topology?days=7"},
|
||||
{"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"},
|
||||
{"analytics_distance", "/api/analytics/distance?days=7"},
|
||||
{"analytics_subpaths", "/api/analytics/subpaths?days=7"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run("NullArrayCheck_"+ep.name, func(t *testing.T) {
|
||||
spec, ok := shapes[ep.name]
|
||||
if !ok {
|
||||
t.Skipf("no shape spec for %s", ep.name)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", ep.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Skipf("GET %s returned %d, skipping null-array check", ep.path, w.Code)
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
nullArrays := findNullArrays(body, spec, ep.path)
|
||||
if len(nullArrays) > 0 {
|
||||
t.Errorf("Go %s has null where [] expected:\n %s\n"+
|
||||
"Go nil slices marshal as null — initialize with make() or literal",
|
||||
ep.path, strings.Join(nullArrays, "\n "))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// findNullArrays walks JSON data alongside a shape spec and returns paths
|
||||
// where the spec says the field should be an array but Go returned null.
|
||||
func findNullArrays(actual interface{}, spec shapeSpec, path string) []string {
|
||||
var nulls []string
|
||||
|
||||
switch spec.Type {
|
||||
case "array":
|
||||
if actual == nil {
|
||||
nulls = append(nulls, fmt.Sprintf("%s: null (should be [])", path))
|
||||
} else if arr, ok := actual.([]interface{}); ok && spec.ElementShape != nil {
|
||||
for i, elem := range arr {
|
||||
nulls = append(nulls, findNullArrays(elem, *spec.ElementShape, fmt.Sprintf("%s[%d]", path, i))...)
|
||||
}
|
||||
}
|
||||
case "object":
|
||||
obj, ok := actual.(map[string]interface{})
|
||||
if !ok || obj == nil {
|
||||
return nulls
|
||||
}
|
||||
if spec.Keys != nil {
|
||||
for key, keySpec := range spec.Keys {
|
||||
if val, exists := obj[key]; exists {
|
||||
nulls = append(nulls, findNullArrays(val, keySpec, path+"."+key)...)
|
||||
} else if keySpec.Type == "array" {
|
||||
// Key missing entirely — also a null-array problem
|
||||
nulls = append(nulls, fmt.Sprintf("%s.%s: missing (should be [])", path, key))
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.DynamicKeys && spec.ValueShape != nil {
|
||||
for k, v := range obj {
|
||||
nulls = append(nulls, findNullArrays(v, *spec.ValueShape, path+"."+k)...)
|
||||
break // sample one
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nulls
|
||||
}
|
||||
|
||||
// TestParityHealthEngine verifies Go health endpoint declares engine=go
|
||||
// while Node declares engine=node (or omits it). The Go server must always
|
||||
// identify itself.
|
||||
func TestParityHealthEngine(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
engine, ok := body["engine"]
|
||||
if !ok {
|
||||
t.Error("health response missing 'engine' field (Go server must include engine=go)")
|
||||
} else if engine != "go" {
|
||||
t.Errorf("health engine=%v, expected 'go'", engine)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateShapeFunction directly tests the shape validator itself.
|
||||
func TestValidateShapeFunction(t *testing.T) {
|
||||
t.Run("string match", func(t *testing.T) {
|
||||
errs := validateShape("hello", shapeSpec{Type: "string"}, "$.x")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("unexpected errors: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string mismatch", func(t *testing.T) {
|
||||
errs := validateShape(42.0, shapeSpec{Type: "string"}, "$.x")
|
||||
if len(errs) != 1 {
|
||||
t.Errorf("expected 1 error, got %d: %v", len(errs), errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null array rejected", func(t *testing.T) {
|
||||
errs := validateShape(nil, shapeSpec{Type: "array"}, "$.arr")
|
||||
if len(errs) != 1 || !strings.Contains(errs[0], "null") {
|
||||
t.Errorf("expected null-array error, got: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty array OK", func(t *testing.T) {
|
||||
errs := validateShape([]interface{}{}, shapeSpec{Type: "array"}, "$.arr")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("unexpected errors for empty array: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing object key", func(t *testing.T) {
|
||||
spec := shapeSpec{Type: "object", Keys: map[string]shapeSpec{
|
||||
"name": {Type: "string"},
|
||||
"age": {Type: "number"},
|
||||
}}
|
||||
obj := map[string]interface{}{"name": "test"}
|
||||
errs := validateShape(obj, spec, "$.user")
|
||||
if len(errs) != 1 || !strings.Contains(errs[0], "age") {
|
||||
t.Errorf("expected missing age error, got: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nullable allows null", func(t *testing.T) {
|
||||
errs := validateShape(nil, shapeSpec{Type: "nullable"}, "$.x")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("nullable should accept null: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dynamic keys validates value shape", func(t *testing.T) {
|
||||
spec := shapeSpec{
|
||||
Type: "object",
|
||||
DynamicKeys: true,
|
||||
ValueShape: &shapeSpec{Type: "number"},
|
||||
}
|
||||
obj := map[string]interface{}{"a": 1.0, "b": 2.0}
|
||||
errs := validateShape(obj, spec, "$.dyn")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("unexpected errors: %v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
package main
|
||||
|
||||
// parity_test.go — Golden fixture shape tests.
|
||||
// Validates that Go API responses match the shape of Node.js API responses.
|
||||
// Shapes were captured from the production Node.js server and stored in
|
||||
// testdata/golden/shapes.json.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// shapeSpec describes the expected JSON structure from the Node.js server.
|
||||
type shapeSpec struct {
|
||||
Type string `json:"type"`
|
||||
Keys map[string]shapeSpec `json:"keys,omitempty"`
|
||||
ElementShape *shapeSpec `json:"elementShape,omitempty"`
|
||||
DynamicKeys bool `json:"dynamicKeys,omitempty"`
|
||||
ValueShape *shapeSpec `json:"valueShape,omitempty"`
|
||||
RequiredKeys map[string]shapeSpec `json:"requiredKeys,omitempty"`
|
||||
}
|
||||
|
||||
// loadShapes reads testdata/golden/shapes.json relative to this source file.
|
||||
func loadShapes(t *testing.T) map[string]shapeSpec {
|
||||
t.Helper()
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
dir := filepath.Dir(thisFile)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "testdata", "golden", "shapes.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("cannot load shapes.json: %v", err)
|
||||
}
|
||||
var shapes map[string]shapeSpec
|
||||
if err := json.Unmarshal(data, &shapes); err != nil {
|
||||
t.Fatalf("cannot parse shapes.json: %v", err)
|
||||
}
|
||||
return shapes
|
||||
}
|
||||
|
||||
// validateShape recursively checks that `actual` matches the expected `spec`.
|
||||
// `path` tracks the JSON path for error messages.
|
||||
// Returns a list of mismatch descriptions.
|
||||
func validateShape(actual interface{}, spec shapeSpec, path string) []string {
|
||||
var errs []string
|
||||
|
||||
switch spec.Type {
|
||||
case "null", "nullable":
|
||||
// nullable means: value can be null OR matching type. Accept anything.
|
||||
return nil
|
||||
case "nullable_number":
|
||||
// Can be null or number
|
||||
if actual != nil {
|
||||
if _, ok := actual.(float64); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected number or null, got %T", path, actual))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
case "string":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected string, got null", path))
|
||||
} else if _, ok := actual.(string); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected string, got %T", path, actual))
|
||||
}
|
||||
case "number":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected number, got null", path))
|
||||
} else if _, ok := actual.(float64); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected number, got %T (%v)", path, actual, actual))
|
||||
}
|
||||
case "boolean":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected boolean, got null", path))
|
||||
} else if _, ok := actual.(bool); !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected boolean, got %T", path, actual))
|
||||
}
|
||||
case "array":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected array, got null (arrays must be [] not null)", path))
|
||||
return errs
|
||||
}
|
||||
arr, ok := actual.([]interface{})
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected array, got %T", path, actual))
|
||||
return errs
|
||||
}
|
||||
if spec.ElementShape != nil && len(arr) > 0 {
|
||||
errs = append(errs, validateShape(arr[0], *spec.ElementShape, path+"[0]")...)
|
||||
}
|
||||
case "object":
|
||||
if actual == nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected object, got null", path))
|
||||
return errs
|
||||
}
|
||||
obj, ok := actual.(map[string]interface{})
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: expected object, got %T", path, actual))
|
||||
return errs
|
||||
}
|
||||
|
||||
if spec.DynamicKeys {
|
||||
// Object with dynamic keys — validate value shapes
|
||||
if spec.ValueShape != nil && len(obj) > 0 {
|
||||
for k, v := range obj {
|
||||
errs = append(errs, validateShape(v, *spec.ValueShape, path+"."+k)...)
|
||||
break // check just one sample
|
||||
}
|
||||
}
|
||||
if spec.RequiredKeys != nil {
|
||||
for rk, rs := range spec.RequiredKeys {
|
||||
v, exists := obj[rk]
|
||||
if !exists {
|
||||
errs = append(errs, fmt.Sprintf("%s: missing required key %q in dynamic-key object", path, rk))
|
||||
} else {
|
||||
errs = append(errs, validateShape(v, rs, path+"."+rk)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if spec.Keys != nil {
|
||||
// Object with known keys — check each expected key exists and has correct type
|
||||
for key, keySpec := range spec.Keys {
|
||||
val, exists := obj[key]
|
||||
if !exists {
|
||||
errs = append(errs, fmt.Sprintf("%s: missing field %q (expected %s)", path, key, keySpec.Type))
|
||||
} else {
|
||||
errs = append(errs, validateShape(val, keySpec, path+"."+key)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// parityEndpoint defines one endpoint to test for parity.
|
||||
type parityEndpoint struct {
|
||||
name string // key in shapes.json
|
||||
path string // HTTP path to request
|
||||
}
|
||||
|
||||
func TestParityShapes(t *testing.T) {
|
||||
shapes := loadShapes(t)
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
endpoints := []parityEndpoint{
|
||||
{"stats", "/api/stats"},
|
||||
{"nodes", "/api/nodes?limit=5"},
|
||||
{"packets", "/api/packets?limit=5"},
|
||||
{"packets_grouped", "/api/packets?limit=5&groupByHash=true"},
|
||||
{"observers", "/api/observers"},
|
||||
{"channels", "/api/channels"},
|
||||
{"channel_messages", "/api/channels/0000000000000000/messages?limit=5"},
|
||||
{"analytics_rf", "/api/analytics/rf?days=7"},
|
||||
{"analytics_topology", "/api/analytics/topology?days=7"},
|
||||
{"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"},
|
||||
{"analytics_distance", "/api/analytics/distance?days=7"},
|
||||
{"analytics_subpaths", "/api/analytics/subpaths?days=7"},
|
||||
{"bulk_health", "/api/nodes/bulk-health"},
|
||||
{"health", "/api/health"},
|
||||
{"perf", "/api/perf"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run("Parity_"+ep.name, func(t *testing.T) {
|
||||
spec, ok := shapes[ep.name]
|
||||
if !ok {
|
||||
t.Fatalf("no shape spec found for %q in shapes.json", ep.name)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", ep.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET %s returned %d, expected 200. Body: %s",
|
||||
ep.path, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("GET %s returned invalid JSON: %v\nBody: %s",
|
||||
ep.path, err, w.Body.String())
|
||||
}
|
||||
|
||||
mismatches := validateShape(body, spec, ep.path)
|
||||
if len(mismatches) > 0 {
|
||||
t.Errorf("Go %s has %d shape mismatches vs Node.js golden:\n %s",
|
||||
ep.path, len(mismatches), strings.Join(mismatches, "\n "))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityNodeDetail tests node detail endpoint shape.
|
||||
// Uses a known test node public key from seeded data.
|
||||
func TestParityNodeDetail(t *testing.T) {
|
||||
shapes := loadShapes(t)
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
spec, ok := shapes["node_detail"]
|
||||
if !ok {
|
||||
t.Fatal("no shape spec for node_detail in shapes.json")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("node detail returned %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
mismatches := validateShape(body, spec, "/api/nodes/{pubkey}")
|
||||
if len(mismatches) > 0 {
|
||||
t.Errorf("Go node detail has %d shape mismatches vs Node.js golden:\n %s",
|
||||
len(mismatches), strings.Join(mismatches, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityArraysNotNull verifies that array-typed fields in Go responses are
|
||||
// [] (empty array) rather than null. This is a common Go/JSON pitfall where
|
||||
// nil slices marshal as null instead of [].
|
||||
// Uses shapes.json to know which fields SHOULD be arrays.
|
||||
func TestParityArraysNotNull(t *testing.T) {
|
||||
shapes := loadShapes(t)
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"stats", "/api/stats"},
|
||||
{"nodes", "/api/nodes?limit=5"},
|
||||
{"packets", "/api/packets?limit=5"},
|
||||
{"packets_grouped", "/api/packets?limit=5&groupByHash=true"},
|
||||
{"observers", "/api/observers"},
|
||||
{"channels", "/api/channels"},
|
||||
{"bulk_health", "/api/nodes/bulk-health"},
|
||||
{"analytics_rf", "/api/analytics/rf?days=7"},
|
||||
{"analytics_topology", "/api/analytics/topology?days=7"},
|
||||
{"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"},
|
||||
{"analytics_distance", "/api/analytics/distance?days=7"},
|
||||
{"analytics_subpaths", "/api/analytics/subpaths?days=7"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run("NullArrayCheck_"+ep.name, func(t *testing.T) {
|
||||
spec, ok := shapes[ep.name]
|
||||
if !ok {
|
||||
t.Skipf("no shape spec for %s", ep.name)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", ep.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Skipf("GET %s returned %d, skipping null-array check", ep.path, w.Code)
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
nullArrays := findNullArrays(body, spec, ep.path)
|
||||
if len(nullArrays) > 0 {
|
||||
t.Errorf("Go %s has null where [] expected:\n %s\n"+
|
||||
"Go nil slices marshal as null — initialize with make() or literal",
|
||||
ep.path, strings.Join(nullArrays, "\n "))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// findNullArrays walks JSON data alongside a shape spec and returns paths
|
||||
// where the spec says the field should be an array but Go returned null.
|
||||
func findNullArrays(actual interface{}, spec shapeSpec, path string) []string {
|
||||
var nulls []string
|
||||
|
||||
switch spec.Type {
|
||||
case "array":
|
||||
if actual == nil {
|
||||
nulls = append(nulls, fmt.Sprintf("%s: null (should be [])", path))
|
||||
} else if arr, ok := actual.([]interface{}); ok && spec.ElementShape != nil {
|
||||
for i, elem := range arr {
|
||||
nulls = append(nulls, findNullArrays(elem, *spec.ElementShape, fmt.Sprintf("%s[%d]", path, i))...)
|
||||
}
|
||||
}
|
||||
case "object":
|
||||
obj, ok := actual.(map[string]interface{})
|
||||
if !ok || obj == nil {
|
||||
return nulls
|
||||
}
|
||||
if spec.Keys != nil {
|
||||
for key, keySpec := range spec.Keys {
|
||||
if val, exists := obj[key]; exists {
|
||||
nulls = append(nulls, findNullArrays(val, keySpec, path+"."+key)...)
|
||||
} else if keySpec.Type == "array" {
|
||||
// Key missing entirely — also a null-array problem
|
||||
nulls = append(nulls, fmt.Sprintf("%s.%s: missing (should be [])", path, key))
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.DynamicKeys && spec.ValueShape != nil {
|
||||
for k, v := range obj {
|
||||
nulls = append(nulls, findNullArrays(v, *spec.ValueShape, path+"."+k)...)
|
||||
break // sample one
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nulls
|
||||
}
|
||||
|
||||
// TestParityHealthEngine verifies Go health endpoint declares engine=go
|
||||
// while Node declares engine=node (or omits it). The Go server must always
|
||||
// identify itself.
|
||||
func TestParityHealthEngine(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
engine, ok := body["engine"]
|
||||
if !ok {
|
||||
t.Error("health response missing 'engine' field (Go server must include engine=go)")
|
||||
} else if engine != "go" {
|
||||
t.Errorf("health engine=%v, expected 'go'", engine)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateShapeFunction directly tests the shape validator itself.
|
||||
func TestValidateShapeFunction(t *testing.T) {
|
||||
t.Run("string match", func(t *testing.T) {
|
||||
errs := validateShape("hello", shapeSpec{Type: "string"}, "$.x")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("unexpected errors: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string mismatch", func(t *testing.T) {
|
||||
errs := validateShape(42.0, shapeSpec{Type: "string"}, "$.x")
|
||||
if len(errs) != 1 {
|
||||
t.Errorf("expected 1 error, got %d: %v", len(errs), errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null array rejected", func(t *testing.T) {
|
||||
errs := validateShape(nil, shapeSpec{Type: "array"}, "$.arr")
|
||||
if len(errs) != 1 || !strings.Contains(errs[0], "null") {
|
||||
t.Errorf("expected null-array error, got: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty array OK", func(t *testing.T) {
|
||||
errs := validateShape([]interface{}{}, shapeSpec{Type: "array"}, "$.arr")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("unexpected errors for empty array: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing object key", func(t *testing.T) {
|
||||
spec := shapeSpec{Type: "object", Keys: map[string]shapeSpec{
|
||||
"name": {Type: "string"},
|
||||
"age": {Type: "number"},
|
||||
}}
|
||||
obj := map[string]interface{}{"name": "test"}
|
||||
errs := validateShape(obj, spec, "$.user")
|
||||
if len(errs) != 1 || !strings.Contains(errs[0], "age") {
|
||||
t.Errorf("expected missing age error, got: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nullable allows null", func(t *testing.T) {
|
||||
errs := validateShape(nil, shapeSpec{Type: "nullable"}, "$.x")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("nullable should accept null: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dynamic keys validates value shape", func(t *testing.T) {
|
||||
spec := shapeSpec{
|
||||
Type: "object",
|
||||
DynamicKeys: true,
|
||||
ValueShape: &shapeSpec{Type: "number"},
|
||||
}
|
||||
obj := map[string]interface{}{"a": 1.0, "b": 2.0}
|
||||
errs := validateShape(obj, spec, "$.dyn")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("unexpected errors: %v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParityWSMultiObserverGolden(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
hub := NewHub()
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store load failed: %v", err)
|
||||
}
|
||||
|
||||
poller := NewPoller(db, hub, 50*time.Millisecond)
|
||||
poller.store = store
|
||||
|
||||
client := &Client{send: make(chan []byte, 256)}
|
||||
hub.Register(client)
|
||||
defer hub.Unregister(client)
|
||||
|
||||
go poller.Start()
|
||||
defer poller.Stop()
|
||||
|
||||
// Wait for poller to initialize its lastID/lastObsID cursors before
|
||||
// inserting new data; otherwise the poller may snapshot a lastID that
|
||||
// already includes the test data and never broadcast it.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('BEEF', 'goldenstarburst237', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}')`, now); err != nil {
|
||||
t.Fatalf("insert tx failed: %v", err)
|
||||
}
|
||||
var txID int
|
||||
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='goldenstarburst237'`).Scan(&txID); err != nil {
|
||||
t.Fatalf("query tx id failed: %v", err)
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 11.0, -88, '["p1"]', ?),
|
||||
(?, 2, 9.0, -92, '["p1","p2"]', ?),
|
||||
(?, 1, 7.0, -96, '["p1","p2","p3"]', ?)`,
|
||||
txID, ts, txID, ts+1, txID, ts+2); err != nil {
|
||||
t.Fatalf("insert obs failed: %v", err)
|
||||
}
|
||||
|
||||
type golden struct {
|
||||
Hash string
|
||||
Count int
|
||||
Paths []string
|
||||
ObserverIDs []string
|
||||
}
|
||||
expected := golden{
|
||||
Hash: "goldenstarburst237",
|
||||
Count: 3,
|
||||
Paths: []string{`["p1"]`, `["p1","p2"]`, `["p1","p2","p3"]`},
|
||||
ObserverIDs: []string{"obs1", "obs2"},
|
||||
}
|
||||
|
||||
gotPaths := make([]string, 0, expected.Count)
|
||||
gotObservers := make(map[string]bool)
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(gotPaths) < expected.Count {
|
||||
select {
|
||||
case raw := <-client.send:
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
t.Fatalf("unmarshal ws message failed: %v", err)
|
||||
}
|
||||
if msg["type"] != "packet" {
|
||||
continue
|
||||
}
|
||||
data, _ := msg["data"].(map[string]interface{})
|
||||
if data == nil || data["hash"] != expected.Hash {
|
||||
continue
|
||||
}
|
||||
if path, ok := data["path_json"].(string); ok {
|
||||
gotPaths = append(gotPaths, path)
|
||||
}
|
||||
if oid, ok := data["observer_id"].(string); ok && oid != "" {
|
||||
gotObservers[oid] = true
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for %d ws messages, got %d", expected.Count, len(gotPaths))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(gotPaths)
|
||||
sort.Strings(expected.Paths)
|
||||
if len(gotPaths) != len(expected.Paths) {
|
||||
t.Fatalf("path count mismatch: got %d want %d", len(gotPaths), len(expected.Paths))
|
||||
}
|
||||
for i := range expected.Paths {
|
||||
if gotPaths[i] != expected.Paths[i] {
|
||||
t.Fatalf("path mismatch at %d: got %q want %q", i, gotPaths[i], expected.Paths[i])
|
||||
}
|
||||
}
|
||||
for _, oid := range expected.ObserverIDs {
|
||||
if !gotObservers[oid] {
|
||||
t.Fatalf("missing expected observer %q in ws messages", oid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+335
-809
File diff suppressed because it is too large
Load Diff
+220
-13
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -17,14 +18,85 @@ func setupTestServer(t *testing.T) (*Server, *mux.Router) {
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db)
|
||||
store.Load()
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
return srv, router
|
||||
}
|
||||
|
||||
func setupTestServerWithAPIKey(t *testing.T, apiKey string) (*Server, *mux.Router) {
|
||||
t.Helper()
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
cfg := &Config{Port: 3000, APIKey: apiKey}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
return srv, router
|
||||
}
|
||||
|
||||
func TestWriteEndpointsRequireAPIKey(t *testing.T) {
|
||||
_, router := setupTestServerWithAPIKey(t, "test-secret")
|
||||
|
||||
t.Run("missing key returns 401", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/perf/reset", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "unauthorized" {
|
||||
t.Fatalf("expected unauthorized error, got %v", body["error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong key returns 401", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", "wrong-secret")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("correct key passes", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", "test-secret")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteEndpointsBlockWhenAPIKeyEmpty(t *testing.T) {
|
||||
_, router := setupTestServerWithAPIKey(t, "")
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/health", nil)
|
||||
@@ -722,6 +794,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 +907,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) {
|
||||
@@ -1179,6 +1257,19 @@ func TestConfigClientEndpoint(t *testing.T) {
|
||||
if body["propagationBufferMs"] == nil {
|
||||
t.Error("expected propagationBufferMs")
|
||||
}
|
||||
tsRaw, ok := body["timestamps"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected timestamps object")
|
||||
}
|
||||
if tsRaw["defaultMode"] != "ago" {
|
||||
t.Errorf("expected timestamps.defaultMode=ago, got %v", tsRaw["defaultMode"])
|
||||
}
|
||||
if tsRaw["timezone"] != "local" {
|
||||
t.Errorf("expected timestamps.timezone=local, got %v", tsRaw["timezone"])
|
||||
}
|
||||
if tsRaw["formatPreset"] != "iso" {
|
||||
t.Errorf("expected timestamps.formatPreset=iso, got %v", tsRaw["formatPreset"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRegionsEndpoint(t *testing.T) {
|
||||
@@ -1251,6 +1342,11 @@ func TestNodeAnalyticsNoNameNode(t *testing.T) {
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
@@ -1282,6 +1378,11 @@ func TestNodeHealthForNoNameNode(t *testing.T) {
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
@@ -1521,8 +1622,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 +1662,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 +1796,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 +1838,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1875,8 +1973,10 @@ t.Error("hash_sizes_seen should not be set for single size")
|
||||
func TestGetNodeHashSizeInfoFlipFlop(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db)
|
||||
store.Load()
|
||||
store := NewPacketStore(db, nil)
|
||||
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)
|
||||
@@ -1916,6 +2016,49 @@ t.Error("expected inconsistent flag to be true for flip-flop pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoDominant(t *testing.T) {
|
||||
// A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00
|
||||
// on direct sends) should report HashSize=2, not 1.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'Repeater2B', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"Repeater2B","pubKey":"` + pk + `"}`
|
||||
raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, no hops)
|
||||
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
|
||||
|
||||
payloadType := 4
|
||||
// 1 packet with hashSize=1, 4 packets with hashSize=2
|
||||
raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 8000 + i,
|
||||
RawHex: raw,
|
||||
Hash: "dominant" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsHashSizesNoNullArrays(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
|
||||
@@ -1934,8 +2077,72 @@ 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 TestConfigGeoFilterEndpoint(t *testing.T) {
|
||||
t.Run("no geo filter configured", func(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["polygon"] != nil {
|
||||
t.Errorf("expected polygon to be nil when no geo filter configured, got %v", body["polygon"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with polygon configured", func(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
lat0, lat1 := 50.0, 51.5
|
||||
lon0, lon1 := 3.0, 5.5
|
||||
cfg := &Config{
|
||||
Port: 3000,
|
||||
GeoFilter: &GeoFilterConfig{
|
||||
Polygon: [][2]float64{{lat0, lon0}, {lat1, lon0}, {lat1, lon1}, {lat0, lon1}},
|
||||
BufferKm: 20,
|
||||
},
|
||||
}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = NewPacketStore(db, nil)
|
||||
srv.store.Load()
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["polygon"] == nil {
|
||||
t.Error("expected polygon in response when geo filter is configured")
|
||||
}
|
||||
if body["bufferKm"] == nil {
|
||||
t.Error("expected bufferKm in response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
+435
-105
@@ -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
|
||||
@@ -98,6 +98,16 @@ type PacketStore struct {
|
||||
// computed during Load() and incrementally updated on ingest.
|
||||
distHops []distHopRecord
|
||||
distPaths []distPathRecord
|
||||
|
||||
// Cached GetNodeHashSizeInfo result — recomputed at most once every 15s
|
||||
hashSizeInfoMu sync.Mutex
|
||||
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
||||
hashSizeInfoAt time.Time
|
||||
|
||||
// Eviction config and stats
|
||||
retentionHours float64 // 0 = unlimited
|
||||
maxMemoryMB int // 0 = unlimited
|
||||
evicted int64 // total packets evicted
|
||||
}
|
||||
|
||||
// Precomputed distance records for fast analytics aggregation.
|
||||
@@ -138,8 +148,8 @@ type cachedResult struct {
|
||||
}
|
||||
|
||||
// NewPacketStore creates a new empty packet store backed by db.
|
||||
func NewPacketStore(db *DB) *PacketStore {
|
||||
return &PacketStore{
|
||||
func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
|
||||
ps := &PacketStore{
|
||||
db: db,
|
||||
packets: make([]*StoreTx, 0, 65536),
|
||||
byHash: make(map[string]*StoreTx, 65536),
|
||||
@@ -158,6 +168,11 @@ func NewPacketStore(db *DB) *PacketStore {
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
spIndex: make(map[string]int, 4096),
|
||||
}
|
||||
if cfg != nil {
|
||||
ps.retentionHours = cfg.RetentionHours
|
||||
ps.maxMemoryMB = cfg.MaxMemoryMB
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
// Load reads all transmissions + observations from SQLite into memory.
|
||||
@@ -176,7 +191,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 +199,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)
|
||||
@@ -288,7 +303,7 @@ func (s *PacketStore) Load() error {
|
||||
|
||||
s.loaded = true
|
||||
elapsed := time.Since(t0)
|
||||
estMB := (len(s.packets)*450 + s.totalObs*100) / (1024 * 1024)
|
||||
estMB := (len(s.packets)*5120 + s.totalObs*500) / (1024 * 1024)
|
||||
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (~%dMB est)",
|
||||
len(s.packets), s.totalObs, elapsed, estMB)
|
||||
return nil
|
||||
@@ -368,28 +383,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}
|
||||
}
|
||||
@@ -533,20 +552,22 @@ func (s *PacketStore) GetPerfStoreStats() map[string]interface{} {
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Rough estimate: ~430 bytes per packet + ~200 per observation
|
||||
estimatedMB := math.Round(float64(totalLoaded*430+totalObs*200)/1048576*10) / 10
|
||||
// Realistic estimate: ~5KB per packet + ~500 bytes per observation
|
||||
estimatedMB := math.Round(float64(totalLoaded*5120+totalObs*500)/1048576*10) / 10
|
||||
|
||||
evicted := atomic.LoadInt64(&s.evicted)
|
||||
|
||||
return map[string]interface{}{
|
||||
"totalLoaded": totalLoaded,
|
||||
"totalObservations": totalObs,
|
||||
"evicted": 0,
|
||||
"evicted": evicted,
|
||||
"inserts": atomic.LoadInt64(&s.insertCount),
|
||||
"queries": atomic.LoadInt64(&s.queryCount),
|
||||
"inMemory": totalLoaded,
|
||||
"sqliteOnly": false,
|
||||
"maxPackets": 2386092,
|
||||
"retentionHours": s.retentionHours,
|
||||
"maxMemoryMB": s.maxMemoryMB,
|
||||
"estimatedMB": estimatedMB,
|
||||
"maxMB": 1024,
|
||||
"indexes": map[string]interface{}{
|
||||
"byHash": hashIdx,
|
||||
"byTxID": txIdx,
|
||||
@@ -639,12 +660,12 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
estimatedMB := math.Round(float64(totalLoaded*430+totalObs*200)/1048576*10) / 10
|
||||
estimatedMB := math.Round(float64(totalLoaded*5120+totalObs*500)/1048576*10) / 10
|
||||
|
||||
return PerfPacketStoreStats{
|
||||
TotalLoaded: totalLoaded,
|
||||
TotalObservations: totalObs,
|
||||
Evicted: 0,
|
||||
Evicted: int(atomic.LoadInt64(&s.evicted)),
|
||||
Inserts: atomic.LoadInt64(&s.insertCount),
|
||||
Queries: atomic.LoadInt64(&s.queryCount),
|
||||
InMemory: totalLoaded,
|
||||
@@ -719,15 +740,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 +799,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 +955,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 {
|
||||
@@ -1023,7 +1051,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
}
|
||||
}
|
||||
|
||||
// Build broadcast maps (same shape as Node.js WS broadcast)
|
||||
// Build broadcast maps (same shape as Node.js WS broadcast), one per observation.
|
||||
result := make([]map[string]interface{}, 0, len(broadcastOrder))
|
||||
for _, txID := range broadcastOrder {
|
||||
tx := broadcastTxs[txID]
|
||||
@@ -1039,32 +1067,34 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
decoded["payload"] = payload
|
||||
}
|
||||
}
|
||||
// Build the nested packet object (packets.js checks m.data.packet)
|
||||
pkt := map[string]interface{}{
|
||||
"id": tx.ID,
|
||||
"raw_hex": strOrNil(tx.RawHex),
|
||||
"hash": strOrNil(tx.Hash),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
"timestamp": strOrNil(tx.FirstSeen),
|
||||
"route_type": intPtrOrNil(tx.RouteType),
|
||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||
"observer_id": strOrNil(tx.ObserverID),
|
||||
"observer_name": strOrNil(tx.ObserverName),
|
||||
"snr": floatPtrOrNil(tx.SNR),
|
||||
"rssi": floatPtrOrNil(tx.RSSI),
|
||||
"path_json": strOrNil(tx.PathJSON),
|
||||
"direction": strOrNil(tx.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
for _, obs := range tx.Observations {
|
||||
// Build the nested packet object (packets.js checks m.data.packet)
|
||||
pkt := map[string]interface{}{
|
||||
"id": tx.ID,
|
||||
"raw_hex": strOrNil(tx.RawHex),
|
||||
"hash": strOrNil(tx.Hash),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
"timestamp": strOrNil(tx.FirstSeen),
|
||||
"route_type": intPtrOrNil(tx.RouteType),
|
||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||
"observer_id": strOrNil(obs.ObserverID),
|
||||
"observer_name": strOrNil(obs.ObserverName),
|
||||
"snr": floatPtrOrNil(obs.SNR),
|
||||
"rssi": floatPtrOrNil(obs.RSSI),
|
||||
"path_json": strOrNil(obs.PathJSON),
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
}
|
||||
// Broadcast map: top-level fields for live.js + nested packet for packets.js
|
||||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||
for k, v := range pkt {
|
||||
broadcastMap[k] = v
|
||||
}
|
||||
broadcastMap["decoded"] = decoded
|
||||
broadcastMap["packet"] = pkt
|
||||
result = append(result, broadcastMap)
|
||||
}
|
||||
// Broadcast map: top-level fields for live.js + nested packet for packets.js
|
||||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||
for k, v := range pkt {
|
||||
broadcastMap[k] = v
|
||||
}
|
||||
broadcastMap["decoded"] = decoded
|
||||
broadcastMap["packet"] = pkt
|
||||
result = append(result, broadcastMap)
|
||||
}
|
||||
|
||||
// Invalidate analytics caches since new data was ingested
|
||||
@@ -1079,15 +1109,13 @@ 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
|
||||
}
|
||||
|
||||
// IngestNewObservations loads new observations for transmissions already in the
|
||||
// store. This catches observations that arrive after IngestNewFromDB has already
|
||||
// advanced past the transmission's ID (fixes #174).
|
||||
func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
||||
func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]interface{} {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
@@ -1113,7 +1141,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
||||
rows, err := s.db.conn.Query(querySQL, sinceObsID, limit)
|
||||
if err != nil {
|
||||
log.Printf("[store] ingest observations query error: %v", err)
|
||||
return sinceObsID
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -1156,20 +1184,16 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
||||
}
|
||||
|
||||
if len(obsRows) == 0 {
|
||||
return sinceObsID
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
newMaxObsID := sinceObsID
|
||||
updatedTxs := make(map[int]*StoreTx)
|
||||
broadcastMaps := make([]map[string]interface{}, 0, len(obsRows))
|
||||
|
||||
for _, r := range obsRows {
|
||||
if r.obsID > newMaxObsID {
|
||||
newMaxObsID = r.obsID
|
||||
}
|
||||
|
||||
// Already ingested (e.g. by IngestNewFromDB in same cycle)
|
||||
if _, exists := s.byObsID[r.obsID]; exists {
|
||||
continue
|
||||
@@ -1212,6 +1236,43 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
||||
}
|
||||
s.totalObs++
|
||||
updatedTxs[r.txID] = tx
|
||||
|
||||
decoded := map[string]interface{}{
|
||||
"header": map[string]interface{}{
|
||||
"payloadTypeName": resolvePayloadTypeName(tx.PayloadType),
|
||||
},
|
||||
}
|
||||
if tx.DecodedJSON != "" {
|
||||
var payload map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &payload) == nil {
|
||||
decoded["payload"] = payload
|
||||
}
|
||||
}
|
||||
|
||||
pkt := map[string]interface{}{
|
||||
"id": tx.ID,
|
||||
"raw_hex": strOrNil(tx.RawHex),
|
||||
"hash": strOrNil(tx.Hash),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
"timestamp": strOrNil(tx.FirstSeen),
|
||||
"route_type": intPtrOrNil(tx.RouteType),
|
||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||
"observer_id": strOrNil(obs.ObserverID),
|
||||
"observer_name": strOrNil(obs.ObserverName),
|
||||
"snr": floatPtrOrNil(obs.SNR),
|
||||
"rssi": floatPtrOrNil(obs.RSSI),
|
||||
"path_json": strOrNil(obs.PathJSON),
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
}
|
||||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||
for k, v := range pkt {
|
||||
broadcastMap[k] = v
|
||||
}
|
||||
broadcastMap["decoded"] = decoded
|
||||
broadcastMap["packet"] = pkt
|
||||
broadcastMaps = append(broadcastMaps, broadcastMap)
|
||||
}
|
||||
|
||||
// Re-pick best observation for updated transmissions and update subpath index
|
||||
@@ -1263,11 +1324,10 @@ 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
|
||||
return broadcastMaps
|
||||
}
|
||||
|
||||
// MaxTransmissionID returns the highest transmission ID in the store.
|
||||
@@ -1651,6 +1711,218 @@ func (s *PacketStore) buildDistanceIndex() {
|
||||
len(s.distHops), len(s.distPaths))
|
||||
}
|
||||
|
||||
// estimatedMemoryMB returns estimated memory usage of the packet store.
|
||||
func (s *PacketStore) estimatedMemoryMB() float64 {
|
||||
return float64(len(s.packets)*5120+s.totalObs*500) / 1048576.0
|
||||
}
|
||||
|
||||
// EvictStale removes packets older than the retention window and/or exceeding
|
||||
// the memory cap. Must be called with s.mu held (Lock). Returns the number of
|
||||
// packets evicted.
|
||||
func (s *PacketStore) EvictStale() int {
|
||||
if s.retentionHours <= 0 && s.maxMemoryMB <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
cutoffIdx := 0
|
||||
|
||||
// Time-based eviction: find how many packets from the head are too old
|
||||
if s.retentionHours > 0 {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(s.retentionHours*3600) * time.Second).Format(time.RFC3339)
|
||||
for cutoffIdx < len(s.packets) && s.packets[cutoffIdx].FirstSeen < cutoff {
|
||||
cutoffIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Memory-based eviction: if still over budget, trim more from head
|
||||
if s.maxMemoryMB > 0 {
|
||||
for cutoffIdx < len(s.packets) && s.estimatedMemoryMB() > float64(s.maxMemoryMB) {
|
||||
// Estimate how many more to evict: rough binary approach
|
||||
overMB := s.estimatedMemoryMB() - float64(s.maxMemoryMB)
|
||||
// ~5KB per packet, so overMB * 1024*1024 / 5120 packets
|
||||
extra := int(overMB * 1048576.0 / 5120.0)
|
||||
if extra < 100 {
|
||||
extra = 100
|
||||
}
|
||||
cutoffIdx += extra
|
||||
if cutoffIdx > len(s.packets) {
|
||||
cutoffIdx = len(s.packets)
|
||||
}
|
||||
// Recalculate estimated memory with fewer packets
|
||||
// (we haven't actually removed yet, so simulate)
|
||||
remainingPkts := len(s.packets) - cutoffIdx
|
||||
remainingObs := s.totalObs
|
||||
for _, tx := range s.packets[:cutoffIdx] {
|
||||
remainingObs -= len(tx.Observations)
|
||||
}
|
||||
estMB := float64(remainingPkts*5120+remainingObs*500) / 1048576.0
|
||||
if estMB <= float64(s.maxMemoryMB) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cutoffIdx == 0 {
|
||||
return 0
|
||||
}
|
||||
if cutoffIdx > len(s.packets) {
|
||||
cutoffIdx = len(s.packets)
|
||||
}
|
||||
|
||||
evicting := s.packets[:cutoffIdx]
|
||||
evictedObs := 0
|
||||
|
||||
// Remove from all indexes
|
||||
for _, tx := range evicting {
|
||||
delete(s.byHash, tx.Hash)
|
||||
delete(s.byTxID, tx.ID)
|
||||
|
||||
// Remove observations from indexes
|
||||
for _, obs := range tx.Observations {
|
||||
delete(s.byObsID, obs.ID)
|
||||
// Remove from byObserver
|
||||
if obs.ObserverID != "" {
|
||||
obsList := s.byObserver[obs.ObserverID]
|
||||
for i, o := range obsList {
|
||||
if o.ID == obs.ID {
|
||||
s.byObserver[obs.ObserverID] = append(obsList[:i], obsList[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.byObserver[obs.ObserverID]) == 0 {
|
||||
delete(s.byObserver, obs.ObserverID)
|
||||
}
|
||||
}
|
||||
evictedObs++
|
||||
}
|
||||
|
||||
// Remove from byPayloadType
|
||||
if tx.PayloadType != nil {
|
||||
pt := *tx.PayloadType
|
||||
ptList := s.byPayloadType[pt]
|
||||
for i, t := range ptList {
|
||||
if t.ID == tx.ID {
|
||||
s.byPayloadType[pt] = append(ptList[:i], ptList[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.byPayloadType[pt]) == 0 {
|
||||
delete(s.byPayloadType, pt)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from byNode and nodeHashes
|
||||
if tx.DecodedJSON != "" {
|
||||
var decoded map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) == nil {
|
||||
for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} {
|
||||
if v, ok := decoded[field].(string); ok && v != "" {
|
||||
if hashes, ok := s.nodeHashes[v]; ok {
|
||||
delete(hashes, tx.Hash)
|
||||
if len(hashes) == 0 {
|
||||
delete(s.nodeHashes, v)
|
||||
}
|
||||
}
|
||||
// Remove tx from byNode
|
||||
nodeList := s.byNode[v]
|
||||
for i, t := range nodeList {
|
||||
if t.ID == tx.ID {
|
||||
s.byNode[v] = append(nodeList[:i], nodeList[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.byNode[v]) == 0 {
|
||||
delete(s.byNode, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from subpath index
|
||||
removeTxFromSubpathIndex(s.spIndex, tx)
|
||||
}
|
||||
|
||||
// Remove from distance indexes — filter out records referencing evicted txs
|
||||
evictedTxSet := make(map[*StoreTx]bool, cutoffIdx)
|
||||
for _, tx := range evicting {
|
||||
evictedTxSet[tx] = true
|
||||
}
|
||||
newDistHops := s.distHops[:0]
|
||||
for i := range s.distHops {
|
||||
if !evictedTxSet[s.distHops[i].tx] {
|
||||
newDistHops = append(newDistHops, s.distHops[i])
|
||||
}
|
||||
}
|
||||
s.distHops = newDistHops
|
||||
|
||||
newDistPaths := s.distPaths[:0]
|
||||
for i := range s.distPaths {
|
||||
if !evictedTxSet[s.distPaths[i].tx] {
|
||||
newDistPaths = append(newDistPaths, s.distPaths[i])
|
||||
}
|
||||
}
|
||||
s.distPaths = newDistPaths
|
||||
|
||||
// Trim packets slice
|
||||
n := copy(s.packets, s.packets[cutoffIdx:])
|
||||
s.packets = s.packets[:n]
|
||||
s.totalObs -= evictedObs
|
||||
|
||||
evictCount := cutoffIdx
|
||||
atomic.AddInt64(&s.evicted, int64(evictCount))
|
||||
freedMB := float64(evictCount*5120+evictedObs*500) / 1048576.0
|
||||
log.Printf("[store] Evicted %d packets older than %.0fh (freed ~%.1fMB estimated)",
|
||||
evictCount, s.retentionHours, freedMB)
|
||||
|
||||
// Invalidate analytics caches
|
||||
s.cacheMu.Lock()
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Invalidate hash size cache
|
||||
s.hashSizeInfoMu.Lock()
|
||||
s.hashSizeInfoCache = nil
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
|
||||
return evictCount
|
||||
}
|
||||
|
||||
// RunEviction acquires the write lock and runs eviction. Safe to call from
|
||||
// a goroutine. Returns evicted count.
|
||||
func (s *PacketStore) RunEviction() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.EvictStale()
|
||||
}
|
||||
|
||||
// StartEvictionTicker starts a background goroutine that runs eviction every
|
||||
// minute. Returns a stop function.
|
||||
func (s *PacketStore) StartEvictionTicker() func() {
|
||||
if s.retentionHours <= 0 && s.maxMemoryMB <= 0 {
|
||||
return func() {} // no-op
|
||||
}
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.RunEviction()
|
||||
case <-done:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// computeDistancesForTx computes distance records for a single transmission.
|
||||
func computeDistancesForTx(tx *StoreTx, nodeByPk map[string]*nodeInfo, repeaterSet map[string]bool, resolveHop func(string) *nodeInfo) ([]distHopRecord, *distPathRecord) {
|
||||
pathHops := txGetParsedPath(tx)
|
||||
@@ -1888,7 +2160,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 +2171,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
|
||||
}
|
||||
@@ -2262,6 +2533,7 @@ func (s *PacketStore) computeAnalyticsRF(region string) map[string]interface{} {
|
||||
seenTypeHashes := make(map[string]bool, len(s.packets))
|
||||
typeBuckets := map[int]int{}
|
||||
hourBuckets := map[string]int{}
|
||||
seenHourHash := make(map[string]bool, len(s.packets)) // dedup packets-per-hour by hash+hour
|
||||
snrByType := map[string]*struct{ vals []float64 }{}
|
||||
sigTime := map[string]*struct {
|
||||
snrs []float64
|
||||
@@ -2334,10 +2606,16 @@ func (s *PacketStore) computeAnalyticsRF(region string) map[string]interface{} {
|
||||
rssiVals = append(rssiVals, *obs.RSSI)
|
||||
}
|
||||
|
||||
// Packets per hour
|
||||
// Packets per hour (unique by hash per hour)
|
||||
if len(ts) >= 13 {
|
||||
hr := ts[:13]
|
||||
hourBuckets[hr]++
|
||||
hk := hash + "|" + hr
|
||||
if hash == "" || !seenHourHash[hk] {
|
||||
if hash != "" {
|
||||
seenHourHash[hk] = true
|
||||
}
|
||||
hourBuckets[hr]++
|
||||
}
|
||||
}
|
||||
|
||||
// Packet sizes (unique by hash)
|
||||
@@ -2425,7 +2703,14 @@ func (s *PacketStore) computeAnalyticsRF(region string) map[string]interface{} {
|
||||
}
|
||||
|
||||
if len(ts) >= 13 {
|
||||
hourBuckets[ts[:13]]++
|
||||
hr := ts[:13]
|
||||
hk := hash + "|" + hr
|
||||
if hash == "" || !seenHourHash[hk] {
|
||||
if hash != "" {
|
||||
seenHourHash[hk] = true
|
||||
}
|
||||
hourBuckets[hr]++
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3698,12 +3983,21 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
|
||||
return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int)
|
||||
})
|
||||
|
||||
// Distribution by repeaters: count unique nodes per hash size
|
||||
distributionByRepeaters := map[string]int{"1": 0, "2": 0, "3": 0}
|
||||
for _, data := range byNode {
|
||||
hs := data["hashSize"].(int)
|
||||
key := strconv.Itoa(hs)
|
||||
distributionByRepeaters[key]++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total": total,
|
||||
"distribution": distribution,
|
||||
"hourly": hourly,
|
||||
"topHops": topHops,
|
||||
"multiByteNodes": multiByteNodes,
|
||||
"total": total,
|
||||
"distribution": distribution,
|
||||
"distributionByRepeaters": distributionByRepeaters,
|
||||
"hourly": hourly,
|
||||
"topHops": topHops,
|
||||
"multiByteNodes": multiByteNodes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3715,8 +4009,26 @@ type hashSizeNodeInfo struct {
|
||||
Inconsistent bool
|
||||
}
|
||||
|
||||
// GetNodeHashSizeInfo scans advert packets to compute per-node hash size data.
|
||||
// GetNodeHashSizeInfo returns cached per-node hash size data, recomputing at most every 15s.
|
||||
func (s *PacketStore) GetNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
const ttl = 15 * time.Second
|
||||
s.hashSizeInfoMu.Lock()
|
||||
if s.hashSizeInfoCache != nil && time.Since(s.hashSizeInfoAt) < ttl {
|
||||
cached := s.hashSizeInfoCache
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
result := s.computeNodeHashSizeInfo()
|
||||
s.hashSizeInfoMu.Lock()
|
||||
s.hashSizeInfoCache = result
|
||||
s.hashSizeInfoAt = time.Now()
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
// computeNodeHashSizeInfo scans advert packets to compute per-node hash size data.
|
||||
func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -3755,14 +4067,32 @@ func (s *PacketStore) GetNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
ni = &hashSizeNodeInfo{AllSizes: make(map[int]bool)}
|
||||
info[pk] = ni
|
||||
}
|
||||
ni.HashSize = hs
|
||||
ni.AllSizes[hs] = true
|
||||
ni.Seq = append(ni.Seq, hs)
|
||||
}
|
||||
|
||||
// Compute flip-flop (inconsistent) flag: need >= 3 observations,
|
||||
// >= 2 unique sizes, and >= 2 transitions in the sequence.
|
||||
// Post-process: compute dominant hash size (mode) and flip-flop flag.
|
||||
// Using the last-seen value would misreport nodes that occasionally send
|
||||
// with pathByte=0x00 (hashSize=1) when transmitting directly with no
|
||||
// relay hops, even though their true hash size is 2 or 3.
|
||||
for _, ni := range info {
|
||||
// Dominant hash size: pick the most frequently observed size.
|
||||
// On a tie, prefer the larger value (more specific).
|
||||
counts := make(map[int]int, len(ni.AllSizes))
|
||||
for _, hs := range ni.Seq {
|
||||
counts[hs]++
|
||||
}
|
||||
best, bestCount := 1, 0
|
||||
for hs, cnt := range counts {
|
||||
if cnt > bestCount || (cnt == bestCount && hs > best) {
|
||||
best = hs
|
||||
bestCount = cnt
|
||||
}
|
||||
}
|
||||
ni.HashSize = best
|
||||
|
||||
// Flip-flop (inconsistent) flag: need >= 3 observations,
|
||||
// >= 2 unique sizes, and >= 2 transitions in the sequence.
|
||||
if len(ni.Seq) < 3 || len(ni.AllSizes) < 2 {
|
||||
continue
|
||||
}
|
||||
@@ -4069,13 +4399,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
-1
@@ -919,7 +919,8 @@ type ClientConfigResponse struct {
|
||||
WsReconnectMs interface{} `json:"wsReconnectMs"`
|
||||
CacheInvalidateMs interface{} `json:"cacheInvalidateMs"`
|
||||
ExternalUrls interface{} `json:"externalUrls"`
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
Timestamps TimestampConfig `json:"timestamps"`
|
||||
}
|
||||
|
||||
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||
|
||||
+245
-229
@@ -1,229 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Hub manages WebSocket clients and broadcasts.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
}
|
||||
|
||||
// Client is a single WebSocket connection.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ClientCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
log.Printf("[ws] client connected (%d total)", h.ClientCount())
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("[ws] client disconnected (%d total)", h.ClientCount())
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients.
|
||||
func (h *Hub) Broadcast(msg interface{}) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[ws] marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
// Client buffer full — drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeWS handles the WebSocket upgrade and runs the client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
h.Register(client)
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump(h)
|
||||
}
|
||||
|
||||
// wsOrStatic upgrades WebSocket requests at any path, serves static files otherwise.
|
||||
func wsOrStatic(hub *Hub, static http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||
hub.ServeWS(w, r)
|
||||
return
|
||||
}
|
||||
static.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) readPump(hub *Hub) {
|
||||
defer func() {
|
||||
hub.Unregister(c)
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(512)
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poller watches for new transmissions in SQLite and broadcasts them.
|
||||
type Poller struct {
|
||||
db *DB
|
||||
hub *Hub
|
||||
store *PacketStore // optional: if set, new transmissions are ingested into memory
|
||||
interval time.Duration
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller {
|
||||
return &Poller{db: db, hub: hub, interval: interval, stop: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (p *Poller) Start() {
|
||||
lastID := p.db.GetMaxTransmissionID()
|
||||
lastObsID := p.db.GetMaxObservationID()
|
||||
log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval)
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if p.store != nil {
|
||||
// Ingest new transmissions into in-memory store and broadcast
|
||||
newTxs, newMax := p.store.IngestNewFromDB(lastID, 100)
|
||||
if newMax > lastID {
|
||||
lastID = newMax
|
||||
}
|
||||
// Ingest new observations for existing transmissions (fixes #174)
|
||||
newObsMax := p.store.IngestNewObservations(lastObsID, 500)
|
||||
if newObsMax > lastObsID {
|
||||
lastObsID = newObsMax
|
||||
}
|
||||
if len(newTxs) > 0 {
|
||||
log.Printf("[broadcast] sending %d packets to %d clients (lastID now %d)", len(newTxs), p.hub.ClientCount(), lastID)
|
||||
}
|
||||
for _, tx := range newTxs {
|
||||
p.hub.Broadcast(WSMessage{
|
||||
Type: "packet",
|
||||
Data: tx,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Fallback: direct DB query (used when store is nil, e.g. tests)
|
||||
newTxs, err := p.db.GetNewTransmissionsSince(lastID, 100)
|
||||
if err != nil {
|
||||
log.Printf("[poller] error: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, tx := range newTxs {
|
||||
id, _ := tx["id"].(int)
|
||||
if id > lastID {
|
||||
lastID = id
|
||||
}
|
||||
// Copy packet fields for the nested packet (avoids circular ref)
|
||||
pkt := make(map[string]interface{}, len(tx))
|
||||
for k, v := range tx {
|
||||
pkt[k] = v
|
||||
}
|
||||
tx["packet"] = pkt
|
||||
p.hub.Broadcast(WSMessage{
|
||||
Type: "packet",
|
||||
Data: tx,
|
||||
})
|
||||
}
|
||||
}
|
||||
case <-p.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) Stop() {
|
||||
close(p.stop)
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Hub manages WebSocket clients and broadcasts.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
}
|
||||
|
||||
// Client is a single WebSocket connection.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ClientCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
log.Printf("[ws] client connected (%d total)", h.ClientCount())
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("[ws] client disconnected (%d total)", h.ClientCount())
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients.
|
||||
func (h *Hub) Broadcast(msg interface{}) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[ws] marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
// Client buffer full — drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeWS handles the WebSocket upgrade and runs the client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
h.Register(client)
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump(h)
|
||||
}
|
||||
|
||||
// wsOrStatic upgrades WebSocket requests at any path, serves static files otherwise.
|
||||
func wsOrStatic(hub *Hub, static http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||
hub.ServeWS(w, r)
|
||||
return
|
||||
}
|
||||
static.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) readPump(hub *Hub) {
|
||||
defer func() {
|
||||
hub.Unregister(c)
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(512)
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poller watches for new transmissions in SQLite and broadcasts them.
|
||||
type Poller struct {
|
||||
db *DB
|
||||
hub *Hub
|
||||
store *PacketStore // optional: if set, new transmissions are ingested into memory
|
||||
interval time.Duration
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller {
|
||||
return &Poller{db: db, hub: hub, interval: interval, stop: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (p *Poller) Start() {
|
||||
lastID := p.db.GetMaxTransmissionID()
|
||||
lastObsID := p.db.GetMaxObservationID()
|
||||
log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval)
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if p.store != nil {
|
||||
// Ingest new transmissions into in-memory store and broadcast
|
||||
newTxs, newMax := p.store.IngestNewFromDB(lastID, 100)
|
||||
if newMax > lastID {
|
||||
lastID = newMax
|
||||
}
|
||||
// Ingest new observations for existing transmissions (fixes #174)
|
||||
nextObsID := lastObsID
|
||||
if err := p.db.conn.QueryRow(`
|
||||
SELECT COALESCE(MAX(id), ?) FROM (
|
||||
SELECT id FROM observations
|
||||
WHERE id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 500
|
||||
)`, lastObsID, lastObsID).Scan(&nextObsID); err != nil {
|
||||
nextObsID = lastObsID
|
||||
}
|
||||
newObs := p.store.IngestNewObservations(lastObsID, 500)
|
||||
if nextObsID > lastObsID {
|
||||
lastObsID = nextObsID
|
||||
}
|
||||
if len(newTxs) > 0 {
|
||||
log.Printf("[broadcast] sending %d packets to %d clients (lastID now %d)", len(newTxs), p.hub.ClientCount(), lastID)
|
||||
}
|
||||
for _, tx := range newTxs {
|
||||
p.hub.Broadcast(WSMessage{
|
||||
Type: "packet",
|
||||
Data: tx,
|
||||
})
|
||||
}
|
||||
for _, obs := range newObs {
|
||||
p.hub.Broadcast(WSMessage{
|
||||
Type: "packet",
|
||||
Data: obs,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Fallback: direct DB query (used when store is nil, e.g. tests)
|
||||
newTxs, err := p.db.GetNewTransmissionsSince(lastID, 100)
|
||||
if err != nil {
|
||||
log.Printf("[poller] error: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, tx := range newTxs {
|
||||
id, _ := tx["id"].(int)
|
||||
if id > lastID {
|
||||
lastID = id
|
||||
}
|
||||
// Copy packet fields for the nested packet (avoids circular ref)
|
||||
pkt := make(map[string]interface{}, len(tx))
|
||||
for k, v := range tx {
|
||||
pkt[k] = v
|
||||
}
|
||||
tx["packet"] = pkt
|
||||
p.hub.Broadcast(WSMessage{
|
||||
Type: "packet",
|
||||
Data: tx,
|
||||
})
|
||||
}
|
||||
}
|
||||
case <-p.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) Stop() {
|
||||
close(p.stop)
|
||||
}
|
||||
|
||||
+415
-275
@@ -1,275 +1,415 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestHubBroadcast(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Create a test server with WebSocket endpoint
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hub.ServeWS(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Connect a WebSocket client
|
||||
wsURL := "ws" + srv.URL[4:] // replace http with ws
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Wait for registration
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() != 1 {
|
||||
t.Errorf("expected 1 client, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Broadcast a message
|
||||
hub.Broadcast(map[string]interface{}{
|
||||
"type": "packet",
|
||||
"data": map[string]interface{}{"id": 1, "hash": "test123"},
|
||||
})
|
||||
|
||||
// Read the message
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
t.Error("expected non-empty message")
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
conn.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestPollerCreation(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
hub := NewHub()
|
||||
|
||||
poller := NewPoller(db, hub, 100*time.Millisecond)
|
||||
if poller == nil {
|
||||
t.Fatal("expected poller")
|
||||
}
|
||||
|
||||
// Start and stop
|
||||
go poller.Start()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
poller.Stop()
|
||||
}
|
||||
|
||||
func TestHubMultipleClients(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hub.ServeWS(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + srv.URL[4:]
|
||||
|
||||
// Connect two clients
|
||||
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn1.Close()
|
||||
|
||||
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn2.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() != 2 {
|
||||
t.Errorf("expected 2 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Broadcast and both should receive
|
||||
hub.Broadcast(map[string]interface{}{"type": "test", "data": "hello"})
|
||||
|
||||
conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg1, err := conn1.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("conn1 read error: %v", err)
|
||||
}
|
||||
if len(msg1) == 0 {
|
||||
t.Error("expected non-empty message on conn1")
|
||||
}
|
||||
|
||||
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg2, err := conn2.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("conn2 read error: %v", err)
|
||||
}
|
||||
if len(msg2) == 0 {
|
||||
t.Error("expected non-empty message on conn2")
|
||||
}
|
||||
|
||||
// Disconnect one
|
||||
conn1.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Remaining client should still work
|
||||
hub.Broadcast(map[string]interface{}{"type": "test2"})
|
||||
|
||||
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg3, err := conn2.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("conn2 read error after disconnect: %v", err)
|
||||
}
|
||||
if len(msg3) == 0 {
|
||||
t.Error("expected non-empty message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastFullBuffer(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
// Create a client with tiny buffer (1)
|
||||
client := &Client{
|
||||
send: make(chan []byte, 1),
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.clients[client] = true
|
||||
hub.mu.Unlock()
|
||||
|
||||
// Fill the buffer
|
||||
client.send <- []byte("first")
|
||||
|
||||
// This broadcast should drop the message (buffer full)
|
||||
hub.Broadcast(map[string]interface{}{"type": "dropped"})
|
||||
|
||||
// Channel should still only have the first message
|
||||
select {
|
||||
case msg := <-client.send:
|
||||
if string(msg) != "first" {
|
||||
t.Errorf("expected 'first', got %s", string(msg))
|
||||
}
|
||||
default:
|
||||
t.Error("expected message in channel")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
hub.mu.Lock()
|
||||
delete(hub.clients, client)
|
||||
hub.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestBroadcastMarshalError(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
// Marshal error: functions can't be marshaled to JSON
|
||||
hub.Broadcast(map[string]interface{}{"bad": func() {}})
|
||||
// Should not panic — just log and return
|
||||
}
|
||||
|
||||
func TestPollerBroadcastsNewData(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
hub := NewHub()
|
||||
|
||||
// Create a client to receive broadcasts
|
||||
client := &Client{
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.clients[client] = true
|
||||
hub.mu.Unlock()
|
||||
|
||||
poller := NewPoller(db, hub, 50*time.Millisecond)
|
||||
go poller.Start()
|
||||
|
||||
// Insert new data to trigger broadcast
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type)
|
||||
VALUES ('EEFF', 'newhash123456789', '2026-01-16T10:00:00Z', 1, 4)`)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
poller.Stop()
|
||||
|
||||
// Check if client received broadcast with packet field (fixes #162)
|
||||
select {
|
||||
case msg := <-client.send:
|
||||
if len(msg) == 0 {
|
||||
t.Error("expected non-empty broadcast message")
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(msg, &parsed); err != nil {
|
||||
t.Fatalf("failed to parse broadcast: %v", err)
|
||||
}
|
||||
if parsed["type"] != "packet" {
|
||||
t.Errorf("expected type=packet, got %v", parsed["type"])
|
||||
}
|
||||
data, ok := parsed["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected data to be an object")
|
||||
}
|
||||
// packets.js filters on m.data.packet — must exist
|
||||
pkt, ok := data["packet"]
|
||||
if !ok || pkt == nil {
|
||||
t.Error("expected data.packet to exist (required by packets.js WS handler)")
|
||||
}
|
||||
pktMap, ok := pkt.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected data.packet to be an object")
|
||||
}
|
||||
// Verify key fields exist in nested packet (timestamp required by packets.js)
|
||||
for _, field := range []string{"id", "hash", "payload_type", "timestamp"} {
|
||||
if _, exists := pktMap[field]; !exists {
|
||||
t.Errorf("expected data.packet.%s to exist", field)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Might not have received due to timing
|
||||
}
|
||||
|
||||
// Clean up
|
||||
hub.mu.Lock()
|
||||
delete(hub.clients, client)
|
||||
hub.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestHubRegisterUnregister(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
client := &Client{
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
if hub.ClientCount() != 1 {
|
||||
t.Errorf("expected 1 client after register, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
hub.Unregister(client)
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients after unregister, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Unregister again should be safe
|
||||
hub.Unregister(client)
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestHubBroadcast(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Create a test server with WebSocket endpoint
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hub.ServeWS(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Connect a WebSocket client
|
||||
wsURL := "ws" + srv.URL[4:] // replace http with ws
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Wait for registration
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() != 1 {
|
||||
t.Errorf("expected 1 client, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Broadcast a message
|
||||
hub.Broadcast(map[string]interface{}{
|
||||
"type": "packet",
|
||||
"data": map[string]interface{}{"id": 1, "hash": "test123"},
|
||||
})
|
||||
|
||||
// Read the message
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
t.Error("expected non-empty message")
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
conn.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestPollerCreation(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
hub := NewHub()
|
||||
|
||||
poller := NewPoller(db, hub, 100*time.Millisecond)
|
||||
if poller == nil {
|
||||
t.Fatal("expected poller")
|
||||
}
|
||||
|
||||
// Start and stop
|
||||
go poller.Start()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
poller.Stop()
|
||||
}
|
||||
|
||||
func TestHubMultipleClients(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hub.ServeWS(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + srv.URL[4:]
|
||||
|
||||
// Connect two clients
|
||||
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn1.Close()
|
||||
|
||||
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn2.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() != 2 {
|
||||
t.Errorf("expected 2 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Broadcast and both should receive
|
||||
hub.Broadcast(map[string]interface{}{"type": "test", "data": "hello"})
|
||||
|
||||
conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg1, err := conn1.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("conn1 read error: %v", err)
|
||||
}
|
||||
if len(msg1) == 0 {
|
||||
t.Error("expected non-empty message on conn1")
|
||||
}
|
||||
|
||||
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg2, err := conn2.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("conn2 read error: %v", err)
|
||||
}
|
||||
if len(msg2) == 0 {
|
||||
t.Error("expected non-empty message on conn2")
|
||||
}
|
||||
|
||||
// Disconnect one
|
||||
conn1.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Remaining client should still work
|
||||
hub.Broadcast(map[string]interface{}{"type": "test2"})
|
||||
|
||||
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg3, err := conn2.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("conn2 read error after disconnect: %v", err)
|
||||
}
|
||||
if len(msg3) == 0 {
|
||||
t.Error("expected non-empty message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastFullBuffer(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
// Create a client with tiny buffer (1)
|
||||
client := &Client{
|
||||
send: make(chan []byte, 1),
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.clients[client] = true
|
||||
hub.mu.Unlock()
|
||||
|
||||
// Fill the buffer
|
||||
client.send <- []byte("first")
|
||||
|
||||
// This broadcast should drop the message (buffer full)
|
||||
hub.Broadcast(map[string]interface{}{"type": "dropped"})
|
||||
|
||||
// Channel should still only have the first message
|
||||
select {
|
||||
case msg := <-client.send:
|
||||
if string(msg) != "first" {
|
||||
t.Errorf("expected 'first', got %s", string(msg))
|
||||
}
|
||||
default:
|
||||
t.Error("expected message in channel")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
hub.mu.Lock()
|
||||
delete(hub.clients, client)
|
||||
hub.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestBroadcastMarshalError(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
// Marshal error: functions can't be marshaled to JSON
|
||||
hub.Broadcast(map[string]interface{}{"bad": func() {}})
|
||||
// Should not panic — just log and return
|
||||
}
|
||||
|
||||
func TestPollerBroadcastsNewData(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
hub := NewHub()
|
||||
|
||||
// Create a client to receive broadcasts
|
||||
client := &Client{
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.clients[client] = true
|
||||
hub.mu.Unlock()
|
||||
|
||||
poller := NewPoller(db, hub, 50*time.Millisecond)
|
||||
go poller.Start()
|
||||
|
||||
// Insert new data to trigger broadcast
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type)
|
||||
VALUES ('EEFF', 'newhash123456789', '2026-01-16T10:00:00Z', 1, 4)`)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
poller.Stop()
|
||||
|
||||
// Check if client received broadcast with packet field (fixes #162)
|
||||
select {
|
||||
case msg := <-client.send:
|
||||
if len(msg) == 0 {
|
||||
t.Error("expected non-empty broadcast message")
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(msg, &parsed); err != nil {
|
||||
t.Fatalf("failed to parse broadcast: %v", err)
|
||||
}
|
||||
if parsed["type"] != "packet" {
|
||||
t.Errorf("expected type=packet, got %v", parsed["type"])
|
||||
}
|
||||
data, ok := parsed["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected data to be an object")
|
||||
}
|
||||
// packets.js filters on m.data.packet — must exist
|
||||
pkt, ok := data["packet"]
|
||||
if !ok || pkt == nil {
|
||||
t.Error("expected data.packet to exist (required by packets.js WS handler)")
|
||||
}
|
||||
pktMap, ok := pkt.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected data.packet to be an object")
|
||||
}
|
||||
// Verify key fields exist in nested packet (timestamp required by packets.js)
|
||||
for _, field := range []string{"id", "hash", "payload_type", "timestamp"} {
|
||||
if _, exists := pktMap[field]; !exists {
|
||||
t.Errorf("expected data.packet.%s to exist", field)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Might not have received due to timing
|
||||
}
|
||||
|
||||
// Clean up
|
||||
hub.mu.Lock()
|
||||
delete(hub.clients, client)
|
||||
hub.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestPollerBroadcastsMultipleObservations(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
hub := NewHub()
|
||||
|
||||
client := &Client{
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.clients[client] = true
|
||||
hub.mu.Unlock()
|
||||
defer func() {
|
||||
hub.mu.Lock()
|
||||
delete(hub.clients, client)
|
||||
hub.mu.Unlock()
|
||||
}()
|
||||
|
||||
poller := NewPoller(db, hub, 50*time.Millisecond)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store load failed: %v", err)
|
||||
}
|
||||
poller.store = store
|
||||
go poller.Start()
|
||||
defer poller.Stop()
|
||||
|
||||
// Wait for poller to initialize its lastID/lastObsID cursors before
|
||||
// inserting new data; otherwise the poller may snapshot a lastID that
|
||||
// already includes the test data and never broadcast it.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('FACE', 'starbursthash237a', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}')`, now); err != nil {
|
||||
t.Fatalf("insert tx failed: %v", err)
|
||||
}
|
||||
var txID int
|
||||
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='starbursthash237a'`).Scan(&txID); err != nil {
|
||||
t.Fatalf("query tx id failed: %v", err)
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 14.0, -82, '["aa"]', ?),
|
||||
(?, 2, 10.5, -90, '["aa","bb"]', ?),
|
||||
(?, 1, 7.0, -96, '["aa","bb","cc"]', ?)`,
|
||||
txID, ts, txID, ts+1, txID, ts+2); err != nil {
|
||||
t.Fatalf("insert observations failed: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
var dataMsgs []map[string]interface{}
|
||||
for len(dataMsgs) < 3 {
|
||||
select {
|
||||
case raw := <-client.send:
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal ws msg failed: %v", err)
|
||||
}
|
||||
if parsed["type"] != "packet" {
|
||||
continue
|
||||
}
|
||||
data, ok := parsed["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if data["hash"] == "starbursthash237a" {
|
||||
dataMsgs = append(dataMsgs, data)
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for 3 observation broadcasts, got %d", len(dataMsgs))
|
||||
}
|
||||
}
|
||||
|
||||
if len(dataMsgs) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(dataMsgs))
|
||||
}
|
||||
|
||||
paths := make([]string, 0, 3)
|
||||
observers := make(map[string]bool)
|
||||
for _, m := range dataMsgs {
|
||||
hash, _ := m["hash"].(string)
|
||||
if hash != "starbursthash237a" {
|
||||
t.Fatalf("unexpected hash %q", hash)
|
||||
}
|
||||
p, _ := m["path_json"].(string)
|
||||
paths = append(paths, p)
|
||||
if oid, ok := m["observer_id"].(string); ok && oid != "" {
|
||||
observers[oid] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
wantPaths := []string{`["aa","bb","cc"]`, `["aa","bb"]`, `["aa"]`}
|
||||
sort.Strings(wantPaths)
|
||||
for i := range wantPaths {
|
||||
if paths[i] != wantPaths[i] {
|
||||
t.Fatalf("path mismatch at %d: got %q want %q", i, paths[i], wantPaths[i])
|
||||
}
|
||||
}
|
||||
if len(observers) < 2 {
|
||||
t.Fatalf("expected observations from >=2 observers, got %d", len(observers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestNewObservationsBroadcast(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store load failed: %v", err)
|
||||
}
|
||||
|
||||
maxObs := db.GetMaxObservationID()
|
||||
now := time.Now().Unix()
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 6.0, -100, '["aa","zz"]', ?),
|
||||
(1, 1, 5.0, -101, '["aa","yy"]', ?)`, now, now+1); err != nil {
|
||||
t.Fatalf("insert new observations failed: %v", err)
|
||||
}
|
||||
|
||||
maps := store.IngestNewObservations(maxObs, 500)
|
||||
if len(maps) != 2 {
|
||||
t.Fatalf("expected 2 broadcast maps, got %d", len(maps))
|
||||
}
|
||||
for _, m := range maps {
|
||||
if m["hash"] != "abc123def4567890" {
|
||||
t.Fatalf("unexpected hash in map: %v", m["hash"])
|
||||
}
|
||||
path, ok := m["path_json"].(string)
|
||||
if !ok || path == "" {
|
||||
t.Fatalf("missing path_json in map: %#v", m)
|
||||
}
|
||||
if _, ok := m["observer_id"]; !ok {
|
||||
t.Fatalf("missing observer_id in map: %#v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRegisterUnregister(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
client := &Client{
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
if hub.ClientCount() != 1 {
|
||||
t.Errorf("expected 1 client after register, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
hub.Unregister(client)
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients after unregister, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Unregister again should be safe
|
||||
hub.Unregister(client)
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
}
|
||||
|
||||
+16
-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" },
|
||||
@@ -98,6 +98,13 @@
|
||||
"#bookclub",
|
||||
"#shtf"
|
||||
],
|
||||
"healthThresholds": {
|
||||
"infraDegradedHours": 24,
|
||||
"infraSilentHours": 72,
|
||||
"nodeDegradedHours": 1,
|
||||
"nodeSilentHours": 24,
|
||||
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others."
|
||||
},
|
||||
"defaultRegion": "SJC",
|
||||
"mapDefaults": {
|
||||
"center": [
|
||||
@@ -137,6 +144,13 @@
|
||||
"propagationBufferMs": 5000,
|
||||
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
|
||||
},
|
||||
"timestamps": {
|
||||
"defaultMode": "ago",
|
||||
"timezone": "local",
|
||||
"formatPreset": "iso",
|
||||
"customFormat": "",
|
||||
"allowCustomFormat": false
|
||||
},
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
|
||||
@@ -1,901 +0,0 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure data directory exists
|
||||
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'meshcore.db');
|
||||
const dataDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('wal_autocheckpoint = 0'); // Disable auto-checkpoint — manual checkpoint on timer to avoid random event loop spikes
|
||||
|
||||
// --- Migration: drop legacy tables (replaced by transmissions + observations in v2.3.0) ---
|
||||
// Drop paths first (has FK to packets)
|
||||
const legacyTables = ['paths', 'packets'];
|
||||
for (const t of legacyTables) {
|
||||
const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(t);
|
||||
if (exists) {
|
||||
console.log(`[migration] Dropping legacy table: ${t}`);
|
||||
db.exec(`DROP TABLE IF EXISTS ${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schema ---
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inactive_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
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_observers_last_seen ON observers(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_inactive_nodes_last_seen ON inactive_nodes(last_seen);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
`);
|
||||
|
||||
// --- Determine schema version ---
|
||||
let schemaVersion = db.pragma('user_version', { simple: true }) || 0;
|
||||
|
||||
// Migrate from old schema_version table to pragma user_version
|
||||
if (schemaVersion === 0) {
|
||||
try {
|
||||
const row = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
|
||||
if (row && row.version >= 3) {
|
||||
db.pragma(`user_version = ${row.version}`);
|
||||
schemaVersion = row.version;
|
||||
db.exec('DROP TABLE IF EXISTS schema_version');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Detect v3 schema by column presence (handles crash between migration and version write)
|
||||
if (schemaVersion === 0) {
|
||||
try {
|
||||
const cols = db.pragma('table_info(observations)').map(c => c.name);
|
||||
if (cols.includes('observer_idx') && !cols.includes('observer_id')) {
|
||||
db.pragma('user_version = 3');
|
||||
schemaVersion = 3;
|
||||
console.log('[migration-v3] Detected already-migrated schema, set user_version = 3');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// --- v3 migration: lean observations table ---
|
||||
function needsV3Migration() {
|
||||
if (schemaVersion >= 3) return false;
|
||||
// Check if observations table exists with old observer_id TEXT column
|
||||
const obsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").get();
|
||||
if (!obsExists) return false;
|
||||
const cols = db.pragma('table_info(observations)').map(c => c.name);
|
||||
return cols.includes('observer_id');
|
||||
}
|
||||
|
||||
function runV3Migration() {
|
||||
const startTime = Date.now();
|
||||
console.log('[migration-v3] Starting observations table optimization...');
|
||||
|
||||
// a. Backup DB
|
||||
const backupPath = dbPath + `.pre-v3-backup-${Date.now()}`;
|
||||
try {
|
||||
console.log(`[migration-v3] Backing up DB to ${backupPath}...`);
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
console.log(`[migration-v3] Backup complete (${Date.now() - startTime}ms)`);
|
||||
} catch (e) {
|
||||
console.error(`[migration-v3] Backup failed, aborting migration: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// b. Create lean table
|
||||
let stepStart = Date.now();
|
||||
db.exec(`
|
||||
CREATE TABLE observations_v3 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_idx INTEGER,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
console.log(`[migration-v3] Created observations_v3 table (${Date.now() - stepStart}ms)`);
|
||||
|
||||
// c. Migrate data
|
||||
stepStart = Date.now();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO observations_v3 (id, transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp)
|
||||
SELECT o.id, o.transmission_id, obs.rowid, o.direction, o.snr, o.rssi, o.score, o.path_json,
|
||||
CAST(strftime('%s', o.timestamp) AS INTEGER)
|
||||
FROM observations o
|
||||
LEFT JOIN observers obs ON obs.id = o.observer_id
|
||||
`).run();
|
||||
console.log(`[migration-v3] Migrated ${result.changes} rows (${Date.now() - stepStart}ms)`);
|
||||
|
||||
// d. Drop view, old table, rename
|
||||
stepStart = Date.now();
|
||||
db.exec('DROP VIEW IF EXISTS packets_v');
|
||||
db.exec('DROP TABLE observations');
|
||||
db.exec('ALTER TABLE observations_v3 RENAME TO observations');
|
||||
console.log(`[migration-v3] Replaced observations table (${Date.now() - stepStart}ms)`);
|
||||
|
||||
// f. Create indexes
|
||||
stepStart = Date.now();
|
||||
db.exec(`
|
||||
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
|
||||
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
|
||||
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
|
||||
`);
|
||||
console.log(`[migration-v3] Created indexes (${Date.now() - stepStart}ms)`);
|
||||
|
||||
// g. Set schema version
|
||||
|
||||
db.pragma('user_version = 3');
|
||||
schemaVersion = 3;
|
||||
|
||||
// h. Rebuild view (done below in common code)
|
||||
|
||||
// i. VACUUM + checkpoint
|
||||
stepStart = Date.now();
|
||||
db.exec('VACUUM');
|
||||
db.pragma('wal_checkpoint(TRUNCATE)');
|
||||
console.log(`[migration-v3] VACUUM + checkpoint complete (${Date.now() - stepStart}ms)`);
|
||||
|
||||
console.log(`[migration-v3] Migration complete! Total time: ${Date.now() - startTime}ms`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[migration-v3] Migration failed: ${e.message}`);
|
||||
console.error('[migration-v3] Restore from backup if needed: ' + dbPath + '.pre-v3-backup');
|
||||
// Try to clean up v3 table if it exists
|
||||
try { db.exec('DROP TABLE IF EXISTS observations_v3'); } catch {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const isV3 = schemaVersion >= 3;
|
||||
|
||||
if (!isV3 && needsV3Migration()) {
|
||||
runV3Migration();
|
||||
}
|
||||
|
||||
// If user_version < 3 and no migration happened (fresh DB or migration skipped), create old-style table
|
||||
if (schemaVersion < 3) {
|
||||
const obsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").get();
|
||||
if (!obsExists) {
|
||||
// Fresh DB — create v3 schema directly
|
||||
db.exec(`
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_idx INTEGER,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
|
||||
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
|
||||
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
|
||||
`);
|
||||
|
||||
db.pragma('user_version = 3');
|
||||
schemaVersion = 3;
|
||||
} else {
|
||||
// Old-style observations table exists but migration wasn't run (or failed)
|
||||
// Ensure indexes exist for old schema
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_hash ON observations(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
|
||||
`);
|
||||
// Dedup cleanup for old schema
|
||||
try {
|
||||
db.exec(`DROP INDEX IF EXISTS idx_observations_dedup`);
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_dedup ON observations(hash, observer_id, COALESCE(path_json, ''))`);
|
||||
db.exec(`DELETE FROM observations WHERE id NOT IN (SELECT MIN(id) FROM observations GROUP BY hash, observer_id, COALESCE(path_json, ''))`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create/rebuild packets_v view ---
|
||||
db.exec('DROP VIEW IF EXISTS packets_v');
|
||||
if (schemaVersion >= 3) {
|
||||
db.exec(`
|
||||
CREATE VIEW packets_v AS
|
||||
SELECT o.id, t.raw_hex,
|
||||
datetime(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
|
||||
`);
|
||||
} else {
|
||||
db.exec(`
|
||||
CREATE VIEW packets_v AS
|
||||
SELECT o.id, t.raw_hex, o.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
|
||||
`);
|
||||
}
|
||||
|
||||
// --- Migrations for existing DBs ---
|
||||
const observerCols = db.pragma('table_info(observers)').map(c => c.name);
|
||||
for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv', 'uptime_secs', 'noise_floor']) {
|
||||
if (!observerCols.includes(col)) {
|
||||
const type = ['battery_mv', 'uptime_secs', 'noise_floor'].includes(col) ? 'INTEGER' : 'TEXT';
|
||||
db.exec(`ALTER TABLE observers ADD COLUMN ${col} ${type}`);
|
||||
console.log(`[migration] Added observers.${col}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cleanup corrupted nodes on startup ---
|
||||
// Remove nodes with obviously invalid data (short pubkeys, control chars in names, etc.)
|
||||
{
|
||||
const cleaned = db.prepare(`
|
||||
DELETE FROM nodes WHERE
|
||||
length(public_key) < 16
|
||||
OR public_key GLOB '*[^0-9a-fA-F]*'
|
||||
OR (lat IS NOT NULL AND (lat < -90 OR lat > 90))
|
||||
OR (lon IS NOT NULL AND (lon < -180 OR lon > 180))
|
||||
`).run();
|
||||
if (cleaned.changes > 0) console.log(`[cleanup] Removed ${cleaned.changes} corrupted node(s) from DB`);
|
||||
}
|
||||
|
||||
// --- One-time migration: recalculate advert_count to count unique transmissions only ---
|
||||
{
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY)`);
|
||||
const done = db.prepare(`SELECT 1 FROM _migrations WHERE name = 'advert_count_unique_v1'`).get();
|
||||
if (!done) {
|
||||
const start = Date.now();
|
||||
console.log('[migration] Recalculating advert_count (unique transmissions only)...');
|
||||
db.prepare(`
|
||||
UPDATE nodes SET advert_count = (
|
||||
SELECT COUNT(*) FROM transmissions t
|
||||
WHERE t.payload_type = 4
|
||||
AND t.decoded_json LIKE '%' || nodes.public_key || '%'
|
||||
)
|
||||
`).run();
|
||||
db.prepare(`INSERT INTO _migrations (name) VALUES ('advert_count_unique_v1')`).run();
|
||||
console.log(`[migration] advert_count recalculated in ${Date.now() - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepared statements ---
|
||||
const stmts = {
|
||||
upsertNode: db.prepare(`
|
||||
INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
|
||||
VALUES (@public_key, @name, @role, @lat, @lon, @last_seen, @first_seen)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
role = COALESCE(@role, role),
|
||||
lat = COALESCE(@lat, lat),
|
||||
lon = COALESCE(@lon, lon),
|
||||
last_seen = @last_seen
|
||||
`),
|
||||
incrementAdvertCount: db.prepare(`
|
||||
UPDATE nodes SET advert_count = advert_count + 1 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)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
iata = COALESCE(@iata, iata),
|
||||
last_seen = @last_seen,
|
||||
packet_count = packet_count + 1,
|
||||
model = COALESCE(@model, model),
|
||||
firmware = COALESCE(@firmware, firmware),
|
||||
client_version = COALESCE(@client_version, client_version),
|
||||
radio = COALESCE(@radio, radio),
|
||||
battery_mv = COALESCE(@battery_mv, battery_mv),
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
updateObserverStatus: 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, 0, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
iata = COALESCE(@iata, iata),
|
||||
last_seen = @last_seen,
|
||||
model = COALESCE(@model, model),
|
||||
firmware = COALESCE(@firmware, firmware),
|
||||
client_version = COALESCE(@client_version, client_version),
|
||||
radio = COALESCE(@radio, radio),
|
||||
battery_mv = COALESCE(@battery_mv, battery_mv),
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
getPacket: db.prepare(`SELECT * FROM packets_v WHERE id = ?`),
|
||||
getNode: db.prepare(`SELECT * FROM nodes WHERE public_key = ?`),
|
||||
getRecentPacketsForNode: db.prepare(`
|
||||
SELECT * FROM packets_v WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT 20
|
||||
`),
|
||||
getObservers: db.prepare(`SELECT * FROM observers ORDER BY last_seen DESC`),
|
||||
countPackets: db.prepare(`SELECT COUNT(*) as count FROM observations`),
|
||||
countNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
|
||||
countActiveNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE last_seen > ?`),
|
||||
countActiveNodesByRole: db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ? AND last_seen > ?`),
|
||||
countObservers: db.prepare(`SELECT COUNT(*) as count FROM observers`),
|
||||
countRecentPackets: schemaVersion >= 3
|
||||
? db.prepare(`SELECT COUNT(*) as count FROM observations WHERE timestamp > CAST(strftime('%s', ?) AS INTEGER)`)
|
||||
: db.prepare(`SELECT COUNT(*) as count FROM observations WHERE timestamp > ?`),
|
||||
getTransmissionByHash: db.prepare(`SELECT id, first_seen FROM transmissions WHERE hash = ?`),
|
||||
insertTransmission: db.prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (@raw_hex, @hash, @first_seen, @route_type, @payload_type, @payload_version, @decoded_json)
|
||||
`),
|
||||
updateTransmissionFirstSeen: db.prepare(`UPDATE transmissions SET first_seen = @first_seen WHERE id = @id`),
|
||||
insertObservation: schemaVersion >= 3
|
||||
? db.prepare(`
|
||||
INSERT OR IGNORE INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (@transmission_id, @observer_idx, @direction, @snr, @rssi, @score, @path_json, @timestamp)
|
||||
`)
|
||||
: db.prepare(`
|
||||
INSERT OR IGNORE INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (@transmission_id, @hash, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @path_json, @timestamp)
|
||||
`),
|
||||
getObserverRowid: db.prepare(`SELECT rowid FROM observers WHERE id = ?`),
|
||||
};
|
||||
|
||||
// --- In-memory observer map (observer_id text → rowid integer) ---
|
||||
const observerIdToRowid = new Map();
|
||||
if (schemaVersion >= 3) {
|
||||
const rows = db.prepare('SELECT id, rowid FROM observers').all();
|
||||
for (const r of rows) observerIdToRowid.set(r.id, r.rowid);
|
||||
}
|
||||
|
||||
// --- In-memory dedup set for v3 ---
|
||||
const dedupSet = new Map(); // key → timestamp (for cleanup)
|
||||
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function cleanupDedupSet() {
|
||||
const cutoff = Date.now() - DEDUP_TTL_MS;
|
||||
for (const [key, ts] of dedupSet) {
|
||||
if (ts < cutoff) dedupSet.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic cleanup every 60s
|
||||
setInterval(cleanupDedupSet, 60000).unref();
|
||||
|
||||
function resolveObserverIdx(observerId) {
|
||||
if (!observerId) return null;
|
||||
let rowid = observerIdToRowid.get(observerId);
|
||||
if (rowid !== undefined) return rowid;
|
||||
// Try DB lookup (observer may have been inserted elsewhere)
|
||||
const row = stmts.getObserverRowid.get(observerId);
|
||||
if (row) {
|
||||
observerIdToRowid.set(observerId, row.rowid);
|
||||
return row.rowid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
function insertTransmission(data) {
|
||||
const hash = data.hash;
|
||||
if (!hash) return null;
|
||||
|
||||
const timestamp = data.timestamp || new Date().toISOString();
|
||||
let transmissionId;
|
||||
|
||||
let isNew = false;
|
||||
const existing = stmts.getTransmissionByHash.get(hash);
|
||||
if (existing) {
|
||||
transmissionId = existing.id;
|
||||
if (timestamp < existing.first_seen) {
|
||||
stmts.updateTransmissionFirstSeen.run({ id: transmissionId, first_seen: timestamp });
|
||||
}
|
||||
} else {
|
||||
isNew = true;
|
||||
const result = stmts.insertTransmission.run({
|
||||
raw_hex: data.raw_hex || '',
|
||||
hash,
|
||||
first_seen: timestamp,
|
||||
route_type: data.route_type ?? null,
|
||||
payload_type: data.payload_type ?? null,
|
||||
payload_version: data.payload_version ?? null,
|
||||
decoded_json: data.decoded_json || null,
|
||||
});
|
||||
transmissionId = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
let obsResult;
|
||||
if (schemaVersion >= 3) {
|
||||
const observerIdx = resolveObserverIdx(data.observer_id);
|
||||
const epochTs = typeof timestamp === 'number' ? timestamp : Math.floor(new Date(timestamp).getTime() / 1000);
|
||||
|
||||
// In-memory dedup check
|
||||
const dedupKey = `${transmissionId}|${observerIdx}|${data.path_json || ''}`;
|
||||
if (dedupSet.has(dedupKey)) {
|
||||
return { transmissionId, observationId: 0, isNew };
|
||||
}
|
||||
|
||||
obsResult = stmts.insertObservation.run({
|
||||
transmission_id: transmissionId,
|
||||
observer_idx: observerIdx,
|
||||
direction: data.direction || null,
|
||||
snr: data.snr ?? null,
|
||||
rssi: data.rssi ?? null,
|
||||
score: data.score ?? null,
|
||||
path_json: data.path_json || null,
|
||||
timestamp: epochTs,
|
||||
});
|
||||
dedupSet.set(dedupKey, Date.now());
|
||||
} else {
|
||||
obsResult = stmts.insertObservation.run({
|
||||
transmission_id: transmissionId,
|
||||
hash,
|
||||
observer_id: data.observer_id || null,
|
||||
observer_name: data.observer_name || null,
|
||||
direction: data.direction || null,
|
||||
snr: data.snr ?? null,
|
||||
rssi: data.rssi ?? null,
|
||||
score: data.score ?? null,
|
||||
path_json: data.path_json || null,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return { transmissionId, observationId: obsResult.lastInsertRowid, isNew };
|
||||
}
|
||||
|
||||
function incrementAdvertCount(publicKey) {
|
||||
stmts.incrementAdvertCount.run({ public_key: publicKey });
|
||||
}
|
||||
|
||||
function upsertNode(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.upsertNode.run({
|
||||
public_key: data.public_key,
|
||||
name: data.name || null,
|
||||
role: data.role || null,
|
||||
lat: data.lat ?? null,
|
||||
lon: data.lon ?? null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
});
|
||||
}
|
||||
|
||||
function upsertObserver(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.upsertObserver.run({
|
||||
id: data.id,
|
||||
name: data.name || null,
|
||||
iata: data.iata || null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
model: data.model || null,
|
||||
firmware: data.firmware || null,
|
||||
client_version: data.client_version || null,
|
||||
radio: data.radio || null,
|
||||
battery_mv: data.battery_mv || null,
|
||||
uptime_secs: data.uptime_secs || null,
|
||||
noise_floor: data.noise_floor || null,
|
||||
});
|
||||
// Update in-memory map for v3
|
||||
if (schemaVersion >= 3 && !observerIdToRowid.has(data.id)) {
|
||||
const row = stmts.getObserverRowid.get(data.id);
|
||||
if (row) observerIdToRowid.set(data.id, row.rowid);
|
||||
}
|
||||
}
|
||||
|
||||
function updateObserverStatus(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.updateObserverStatus.run({
|
||||
id: data.id,
|
||||
name: data.name || null,
|
||||
iata: data.iata || null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
model: data.model || null,
|
||||
firmware: data.firmware || null,
|
||||
client_version: data.client_version || null,
|
||||
radio: data.radio || null,
|
||||
battery_mv: data.battery_mv || null,
|
||||
uptime_secs: data.uptime_secs || null,
|
||||
noise_floor: data.noise_floor || null,
|
||||
});
|
||||
}
|
||||
|
||||
function getPackets({ limit = 50, offset = 0, type, route, hash, since } = {}) {
|
||||
let where = [];
|
||||
let params = {};
|
||||
if (type !== undefined) { where.push('payload_type = @type'); params.type = type; }
|
||||
if (route !== undefined) { where.push('route_type = @route'); params.route = route; }
|
||||
if (hash) { where.push('hash = @hash'); params.hash = hash; }
|
||||
if (since) { where.push('timestamp > @since'); params.since = since; }
|
||||
const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const rows = db.prepare(`SELECT * FROM packets_v ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
|
||||
const total = db.prepare(`SELECT COUNT(*) as count FROM packets_v ${clause}`).get(params).count;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
function getTransmission(id) {
|
||||
try {
|
||||
return db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function getPacket(id) {
|
||||
const packet = stmts.getPacket.get(id);
|
||||
if (!packet) return null;
|
||||
return packet;
|
||||
}
|
||||
|
||||
function getNodes({ limit = 50, offset = 0, sortBy = 'last_seen' } = {}) {
|
||||
const allowed = ['last_seen', 'name', 'advert_count', 'first_seen'];
|
||||
const col = allowed.includes(sortBy) ? sortBy : 'last_seen';
|
||||
const dir = col === 'name' ? 'ASC' : 'DESC';
|
||||
const rows = db.prepare(`SELECT * FROM nodes ORDER BY ${col} ${dir} LIMIT ? OFFSET ?`).all(limit, offset);
|
||||
const total = stmts.countNodes.get().count;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
function getNode(pubkey) {
|
||||
const node = stmts.getNode.get(pubkey);
|
||||
if (!node) return null;
|
||||
// Match by: pubkey anywhere, name in sender/text fields, name as text prefix ("Name: msg")
|
||||
const namePattern = node.name ? `%${node.name}%` : `%${pubkey}%`;
|
||||
const textPrefix = node.name ? `%"text":"${node.name}:%` : `%${pubkey}%`;
|
||||
node.recentPackets = stmts.getRecentPacketsForNode.all(
|
||||
`%${pubkey}%`,
|
||||
namePattern,
|
||||
textPrefix,
|
||||
`%"sender":"${node.name || pubkey}"%`
|
||||
);
|
||||
return node;
|
||||
}
|
||||
|
||||
function getObservers() {
|
||||
return stmts.getObservers.all();
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600000).toISOString();
|
||||
// Try to get transmission count from normalized schema
|
||||
let totalTransmissions = null;
|
||||
try {
|
||||
totalTransmissions = db.prepare('SELECT COUNT(*) as count FROM transmissions').get().count;
|
||||
} catch {}
|
||||
return {
|
||||
totalPackets: totalTransmissions || stmts.countPackets.get().count,
|
||||
totalTransmissions,
|
||||
totalObservations: stmts.countPackets.get().count,
|
||||
totalNodes: stmts.countActiveNodes.get(sevenDaysAgo).count,
|
||||
totalNodesAllTime: stmts.countNodes.get().count,
|
||||
totalObservers: stmts.countObservers.get().count,
|
||||
packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count,
|
||||
packetsLast24h: stmts.countRecentPackets.get(new Date(Date.now() - 24 * 3600000).toISOString()).count,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Run directly ---
|
||||
if (require.main === module) {
|
||||
console.log('Stats:', getStats());
|
||||
}
|
||||
|
||||
// Remove phantom nodes created by autoLearnHopNodes before this fix.
|
||||
// Real MeshCore pubkeys are 32 bytes (64 hex chars). Phantom nodes have only
|
||||
// the hop prefix as their public_key (typically 4-8 hex chars).
|
||||
// Threshold: public_key <= 16 hex chars (8 bytes) is too short to be real.
|
||||
function removePhantomNodes() {
|
||||
const result = db.prepare(`DELETE FROM nodes WHERE LENGTH(public_key) <= 16`).run();
|
||||
if (result.changes > 0) {
|
||||
console.log(`[cleanup] Removed ${result.changes} phantom node(s) with short public_key prefixes`);
|
||||
}
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
function searchNodes(query, limit = 10) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM nodes
|
||||
WHERE name LIKE @q OR public_key LIKE @prefix
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT @limit
|
||||
`).all({ q: `%${query}%`, prefix: `${query}%`, limit });
|
||||
}
|
||||
|
||||
function getNodeHealth(pubkey) {
|
||||
const node = stmts.getNode.get(pubkey);
|
||||
if (!node) return null;
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const todayISO = todayStart.toISOString();
|
||||
|
||||
const keyPattern = `%${pubkey}%`;
|
||||
// Also match by node name in decoded_json (channel messages have sender name, not pubkey)
|
||||
const namePattern = node.name ? `%${node.name.replace(/[%_]/g, '')}%` : null;
|
||||
const whereClause = namePattern
|
||||
? `(decoded_json LIKE @keyPattern OR decoded_json LIKE @namePattern)`
|
||||
: `decoded_json LIKE @keyPattern`;
|
||||
const params = namePattern ? { keyPattern, namePattern } : { keyPattern };
|
||||
|
||||
// Observers that heard this node
|
||||
const observers = db.prepare(`
|
||||
SELECT observer_id, observer_name,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount
|
||||
FROM packets_v
|
||||
WHERE ${whereClause} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id
|
||||
ORDER BY packetCount DESC
|
||||
`).all(params);
|
||||
|
||||
// Stats
|
||||
const packetsToday = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause} AND timestamp > @since
|
||||
`).get({ ...params, since: todayISO }).count;
|
||||
|
||||
const avgStats = db.prepare(`
|
||||
SELECT AVG(snr) as avgSnr FROM packets_v WHERE ${whereClause}
|
||||
`).get(params);
|
||||
|
||||
const lastHeard = db.prepare(`
|
||||
SELECT MAX(timestamp) as lastHeard FROM packets_v WHERE ${whereClause}
|
||||
`).get(params).lastHeard;
|
||||
|
||||
// Avg hops from path_json
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets_v WHERE ${whereClause} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
let totalHops = 0, hopCount = 0;
|
||||
for (const row of pathRows) {
|
||||
try {
|
||||
const hops = JSON.parse(row.path_json);
|
||||
if (Array.isArray(hops)) { totalHops += hops.length; hopCount++; }
|
||||
} catch {}
|
||||
}
|
||||
const avgHops = hopCount > 0 ? Math.round(totalHops / hopCount) : 0;
|
||||
|
||||
const totalPackets = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause}
|
||||
`).get(params).count;
|
||||
|
||||
// Recent 10 packets
|
||||
const recentPackets = db.prepare(`
|
||||
SELECT * FROM packets_v WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
|
||||
`).all(params);
|
||||
|
||||
return {
|
||||
node,
|
||||
observers,
|
||||
stats: { totalPackets, packetsToday, avgSnr: avgStats.avgSnr, avgHops, lastHeard },
|
||||
recentPackets,
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeAnalytics(pubkey, days) {
|
||||
const node = stmts.getNode.get(pubkey);
|
||||
if (!node) return null;
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - days * 86400000);
|
||||
const fromISO = from.toISOString();
|
||||
const toISO = now.toISOString();
|
||||
|
||||
const keyPattern = `%${pubkey}%`;
|
||||
const namePattern = node.name ? `%${node.name.replace(/[%_]/g, '')}%` : null;
|
||||
const whereClause = namePattern
|
||||
? `(decoded_json LIKE @keyPattern OR decoded_json LIKE @namePattern)`
|
||||
: `decoded_json LIKE @keyPattern`;
|
||||
const timeWhere = `${whereClause} AND timestamp > @fromISO`;
|
||||
const params = namePattern ? { keyPattern, namePattern, fromISO } : { keyPattern, fromISO };
|
||||
|
||||
// Activity timeline
|
||||
const activityTimeline = db.prepare(`
|
||||
SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count
|
||||
FROM packets_v WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
|
||||
`).all(params);
|
||||
|
||||
// SNR trend
|
||||
const snrTrend = db.prepare(`
|
||||
SELECT timestamp, snr, rssi, observer_id, observer_name
|
||||
FROM packets_v WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
|
||||
`).all(params);
|
||||
|
||||
// Packet type breakdown
|
||||
const packetTypeBreakdown = db.prepare(`
|
||||
SELECT payload_type, COUNT(*) as count FROM packets_v WHERE ${timeWhere} GROUP BY payload_type
|
||||
`).all(params);
|
||||
|
||||
// Observer coverage
|
||||
const observerCoverage = db.prepare(`
|
||||
SELECT observer_id, observer_name, COUNT(*) as packetCount,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, MIN(timestamp) as firstSeen, MAX(timestamp) as lastSeen
|
||||
FROM packets_v WHERE ${timeWhere} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id ORDER BY packetCount DESC
|
||||
`).all(params);
|
||||
|
||||
// Hop distribution
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets_v WHERE ${timeWhere} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const hopCounts = {};
|
||||
let totalWithPath = 0, relayedCount = 0;
|
||||
for (const row of pathRows) {
|
||||
try {
|
||||
const hops = JSON.parse(row.path_json);
|
||||
if (Array.isArray(hops)) {
|
||||
const h = hops.length;
|
||||
const key = h >= 4 ? '4+' : String(h);
|
||||
hopCounts[key] = (hopCounts[key] || 0) + 1;
|
||||
totalWithPath++;
|
||||
if (h > 1) relayedCount++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const hopDistribution = Object.entries(hopCounts).map(([hops, count]) => ({ hops, count }))
|
||||
.sort((a, b) => a.hops.localeCompare(b.hops, undefined, { numeric: true }));
|
||||
|
||||
// Peer interactions from decoded_json
|
||||
const decodedRows = db.prepare(`
|
||||
SELECT decoded_json, timestamp FROM packets_v WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const peerMap = {};
|
||||
for (const row of decodedRows) {
|
||||
try {
|
||||
const d = JSON.parse(row.decoded_json);
|
||||
// Look for sender/recipient pubkeys that aren't this node
|
||||
const candidates = [];
|
||||
if (d.sender_key && d.sender_key !== pubkey) candidates.push({ key: d.sender_key, name: d.sender_name || d.sender_short_name });
|
||||
if (d.recipient_key && d.recipient_key !== pubkey) candidates.push({ key: d.recipient_key, name: d.recipient_name || d.recipient_short_name });
|
||||
if (d.pubkey && d.pubkey !== pubkey) candidates.push({ key: d.pubkey, name: d.name });
|
||||
for (const c of candidates) {
|
||||
if (!c.key) continue;
|
||||
if (!peerMap[c.key]) peerMap[c.key] = { peer_key: c.key, peer_name: c.name || c.key.slice(0, 12), messageCount: 0, lastContact: row.timestamp };
|
||||
peerMap[c.key].messageCount++;
|
||||
if (row.timestamp > peerMap[c.key].lastContact) peerMap[c.key].lastContact = row.timestamp;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const peerInteractions = Object.values(peerMap).sort((a, b) => b.messageCount - a.messageCount).slice(0, 20);
|
||||
|
||||
// Uptime heatmap
|
||||
const uptimeHeatmap = db.prepare(`
|
||||
SELECT CAST(strftime('%w', timestamp) AS INTEGER) as dayOfWeek,
|
||||
CAST(strftime('%H', timestamp) AS INTEGER) as hour, COUNT(*) as count
|
||||
FROM packets_v WHERE ${timeWhere} GROUP BY dayOfWeek, hour
|
||||
`).all(params);
|
||||
|
||||
// Computed stats
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets_v WHERE ${timeWhere}`).get(params).count;
|
||||
const uniqueObservers = observerCoverage.length;
|
||||
const uniquePeers = peerInteractions.length;
|
||||
const avgPacketsPerDay = days > 0 ? Math.round(totalPackets / days * 10) / 10 : totalPackets;
|
||||
|
||||
// Availability: distinct hours with packets / total hours
|
||||
const distinctHours = activityTimeline.length;
|
||||
const totalHours = days * 24;
|
||||
const availabilityPct = totalHours > 0 ? Math.round(distinctHours / totalHours * 1000) / 10 : 0;
|
||||
|
||||
// Longest silence
|
||||
const timestamps = db.prepare(`
|
||||
SELECT timestamp FROM packets_v WHERE ${timeWhere} ORDER BY timestamp
|
||||
`).all(params).map(r => new Date(r.timestamp).getTime());
|
||||
|
||||
let longestSilenceMs = 0, longestSilenceStart = null;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const gap = timestamps[i] - timestamps[i - 1];
|
||||
if (gap > longestSilenceMs) { longestSilenceMs = gap; longestSilenceStart = new Date(timestamps[i - 1]).toISOString(); }
|
||||
}
|
||||
|
||||
// Signal grade
|
||||
const snrValues = snrTrend.map(r => r.snr);
|
||||
const snrMean = snrValues.length > 0 ? snrValues.reduce((a, b) => a + b, 0) / snrValues.length : 0;
|
||||
const snrStdDev = snrValues.length > 1 ? Math.sqrt(snrValues.reduce((s, v) => s + (v - snrMean) ** 2, 0) / snrValues.length) : 0;
|
||||
let signalGrade = 'D';
|
||||
if (snrMean > 15 && snrStdDev < 2) signalGrade = 'A';
|
||||
else if (snrMean > 15) signalGrade = 'A-';
|
||||
else if (snrMean > 12 && snrStdDev < 3) signalGrade = 'B+';
|
||||
else if (snrMean > 8) signalGrade = 'B';
|
||||
else if (snrMean > 3) signalGrade = 'C';
|
||||
|
||||
const relayPct = totalWithPath > 0 ? Math.round(relayedCount / totalWithPath * 1000) / 10 : 0;
|
||||
|
||||
return {
|
||||
node,
|
||||
timeRange: { from: fromISO, to: toISO, days },
|
||||
activityTimeline,
|
||||
snrTrend,
|
||||
packetTypeBreakdown,
|
||||
observerCoverage,
|
||||
hopDistribution,
|
||||
peerInteractions,
|
||||
uptimeHeatmap,
|
||||
computedStats: {
|
||||
availabilityPct, longestSilenceMs, longestSilenceStart, signalGrade,
|
||||
snrMean: Math.round(snrMean * 10) / 10, snrStdDev: Math.round(snrStdDev * 10) / 10,
|
||||
relayPct, totalPackets, uniqueObservers, uniquePeers, avgPacketsPerDay
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Move stale nodes to inactive_nodes table based on retention.nodeDays config.
|
||||
function moveStaleNodes(nodeDays) {
|
||||
if (!nodeDays || nodeDays <= 0) return 0;
|
||||
const cutoff = new Date(Date.now() - nodeDays * 24 * 3600000).toISOString();
|
||||
const move = db.transaction(() => {
|
||||
db.prepare(`INSERT OR REPLACE INTO inactive_nodes SELECT * FROM nodes WHERE last_seen < ?`).run(cutoff);
|
||||
const result = db.prepare(`DELETE FROM nodes WHERE last_seen < ?`).run(cutoff);
|
||||
return result.changes;
|
||||
});
|
||||
const moved = move();
|
||||
if (moved > 0) {
|
||||
console.log(`[retention] Moved ${moved} node(s) to inactive_nodes (not seen in ${nodeDays} days)`);
|
||||
}
|
||||
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 };
|
||||
-407
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* MeshCore Packet Decoder
|
||||
* Custom implementation — does NOT use meshcore-decoder library (known path_length bug).
|
||||
*
|
||||
* Packet layout:
|
||||
* [header(1)] [pathLength(1)] [transportCodes?] [path hops] [payload...]
|
||||
*
|
||||
* Header byte (LSB first):
|
||||
* bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT)
|
||||
* bits 5-2: payloadType
|
||||
* bits 7-6: payloadVersion
|
||||
*
|
||||
* Path length byte:
|
||||
* bits 5-0: hash_count (number of hops, 0-63)
|
||||
* bits 7-6: (value >> 6) + 1 = hash_size (1-4 bytes per hop hash)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const ROUTE_TYPES = {
|
||||
0: 'TRANSPORT_FLOOD',
|
||||
1: 'FLOOD',
|
||||
2: 'DIRECT',
|
||||
3: 'TRANSPORT_DIRECT',
|
||||
};
|
||||
|
||||
const PAYLOAD_TYPES = {
|
||||
0x00: 'REQ',
|
||||
0x01: 'RESPONSE',
|
||||
0x02: 'TXT_MSG',
|
||||
0x03: 'ACK',
|
||||
0x04: 'ADVERT',
|
||||
0x05: 'GRP_TXT',
|
||||
0x06: 'GRP_DATA',
|
||||
0x07: 'ANON_REQ',
|
||||
0x08: 'PATH',
|
||||
0x09: 'TRACE',
|
||||
0x0A: 'MULTIPART',
|
||||
0x0B: 'CONTROL',
|
||||
0x0F: 'RAW_CUSTOM',
|
||||
};
|
||||
|
||||
// Route types that carry transport codes (nextHop + lastHop, 2 bytes each)
|
||||
const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT
|
||||
|
||||
// --- Header parsing ---
|
||||
|
||||
function decodeHeader(byte) {
|
||||
return {
|
||||
routeType: byte & 0x03,
|
||||
routeTypeName: ROUTE_TYPES[byte & 0x03] || 'UNKNOWN',
|
||||
payloadType: (byte >> 2) & 0x0F,
|
||||
payloadTypeName: PAYLOAD_TYPES[(byte >> 2) & 0x0F] || 'UNKNOWN',
|
||||
payloadVersion: (byte >> 6) & 0x03,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Path parsing ---
|
||||
|
||||
function decodePath(pathByte, buf, offset) {
|
||||
const hashSize = (pathByte >> 6) + 1; // 1-4 bytes per hash
|
||||
const hashCount = pathByte & 0x3F; // 0-63 hops
|
||||
const available = buf.length - offset;
|
||||
// Cap to what the buffer actually holds — corrupt packets may claim more hops than exist
|
||||
const safeCount = Math.min(hashCount, Math.floor(available / hashSize));
|
||||
const totalBytes = safeCount * hashSize;
|
||||
const hops = [];
|
||||
|
||||
for (let i = 0; i < safeCount; i++) {
|
||||
hops.push(buf.subarray(offset + i * hashSize, offset + i * hashSize + hashSize).toString('hex').toUpperCase());
|
||||
}
|
||||
|
||||
return {
|
||||
hashSize,
|
||||
hashCount: safeCount,
|
||||
hops,
|
||||
bytesConsumed: totalBytes,
|
||||
truncated: safeCount < hashCount,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Payload decoders ---
|
||||
|
||||
/** REQ / RESPONSE / TXT_MSG: dest(1) + src(1) + MAC(2) + encrypted (PAYLOAD_VER_1, per Mesh.cpp) */
|
||||
function decodeEncryptedPayload(buf) {
|
||||
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
destHash: buf.subarray(0, 1).toString('hex'),
|
||||
srcHash: buf.subarray(1, 2).toString('hex'),
|
||||
mac: buf.subarray(2, 4).toString('hex'),
|
||||
encryptedData: buf.subarray(4).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** ACK: dest(1) + src(1) + ack_hash(4) (per Mesh.cpp) */
|
||||
function decodeAck(buf) {
|
||||
if (buf.length < 6) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
destHash: buf.subarray(0, 1).toString('hex'),
|
||||
srcHash: buf.subarray(1, 2).toString('hex'),
|
||||
extraHash: buf.subarray(2, 6).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** ADVERT: pubkey(32) + timestamp(4 LE) + signature(64) + appdata */
|
||||
function decodeAdvert(buf) {
|
||||
if (buf.length < 100) return { error: 'too short for advert', raw: buf.toString('hex') };
|
||||
const pubKey = buf.subarray(0, 32).toString('hex');
|
||||
const timestamp = buf.readUInt32LE(32);
|
||||
const signature = buf.subarray(36, 100).toString('hex');
|
||||
const appdata = buf.subarray(100);
|
||||
|
||||
const result = { pubKey, timestamp, timestampISO: new Date(timestamp * 1000).toISOString(), signature };
|
||||
|
||||
if (appdata.length > 0) {
|
||||
const flags = appdata[0];
|
||||
const advType = flags & 0x0F; // lower nibble is enum type, not individual bits
|
||||
result.flags = {
|
||||
raw: flags,
|
||||
type: advType,
|
||||
chat: advType === 1,
|
||||
repeater: advType === 2,
|
||||
room: advType === 3,
|
||||
sensor: advType === 4,
|
||||
hasLocation: !!(flags & 0x10),
|
||||
hasName: !!(flags & 0x80),
|
||||
};
|
||||
|
||||
let off = 1;
|
||||
if (result.flags.hasLocation && appdata.length >= off + 8) {
|
||||
result.lat = appdata.readInt32LE(off) / 1e6;
|
||||
result.lon = appdata.readInt32LE(off + 4) / 1e6;
|
||||
off += 8;
|
||||
}
|
||||
if (result.flags.hasName) {
|
||||
let name = appdata.subarray(off).toString('utf8');
|
||||
// Strip non-printable characters (< 0x20 except tab/newline) and DEL
|
||||
name = name.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
||||
result.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains non-printable characters (binary garbage).
|
||||
* Returns true if more than 2 non-printable chars found (excluding \n, \t).
|
||||
*/
|
||||
function hasNonPrintableChars(text) {
|
||||
if (!text) return false;
|
||||
let count = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code < 0x20 && code !== 0x0A && code !== 0x09) count++;
|
||||
else if (code === 0xFFFD) count++; // Unicode replacement char (invalid UTF-8)
|
||||
if (count > 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** GRP_TXT: channel_hash(1) + MAC(2) + encrypted */
|
||||
function decodeGrpTxt(buf, channelKeys) {
|
||||
if (buf.length < 3) return { error: 'too short', raw: buf.toString('hex') };
|
||||
const channelHash = buf[0];
|
||||
const channelHashHex = channelHash.toString(16).padStart(2, '0').toUpperCase();
|
||||
const mac = buf.subarray(1, 3).toString('hex');
|
||||
const encryptedData = buf.subarray(3).toString('hex');
|
||||
|
||||
const hasKeys = channelKeys && Object.keys(channelKeys).length > 0;
|
||||
|
||||
// Try decryption with known channel keys
|
||||
if (hasKeys && encryptedData.length >= 10) {
|
||||
try {
|
||||
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
|
||||
for (const [name, key] of Object.entries(channelKeys)) {
|
||||
const result = ChannelCrypto.decryptGroupTextMessage(encryptedData, mac, key);
|
||||
if (result.success && result.data) {
|
||||
const text = result.data.sender && result.data.message
|
||||
? `${result.data.sender}: ${result.data.message}`
|
||||
: result.data.message || '';
|
||||
// Validate decrypted text is printable UTF-8 (not binary garbage)
|
||||
if (hasNonPrintableChars(text)) {
|
||||
return {
|
||||
type: 'GRP_TXT', channelHash, channelHashHex, channel: name,
|
||||
decryptionStatus: 'decryption_failed', text: null, mac, encryptedData,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'CHAN',
|
||||
channel: name,
|
||||
channelHash,
|
||||
channelHashHex,
|
||||
decryptionStatus: 'decrypted',
|
||||
sender: result.data.sender || null,
|
||||
text,
|
||||
sender_timestamp: result.data.timestamp,
|
||||
flags: result.data.flags,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) { /* decryption failed, fall through */ }
|
||||
|
||||
return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'decryption_failed', mac, encryptedData };
|
||||
}
|
||||
|
||||
return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData };
|
||||
}
|
||||
|
||||
/** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */
|
||||
function decodeAnonReq(buf) {
|
||||
if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
destHash: buf.subarray(0, 1).toString('hex'),
|
||||
ephemeralPubKey: buf.subarray(1, 33).toString('hex'),
|
||||
mac: buf.subarray(33, 35).toString('hex'),
|
||||
encryptedData: buf.subarray(35).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** PATH: dest(6) + src(6) + MAC(4) + path_data */
|
||||
function decodePath_payload(buf) {
|
||||
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
destHash: buf.subarray(0, 1).toString('hex'),
|
||||
srcHash: buf.subarray(1, 2).toString('hex'),
|
||||
mac: buf.subarray(2, 4).toString('hex'),
|
||||
pathData: buf.subarray(4).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** TRACE: flags(1) + tag(4) + dest(6) + src(1) */
|
||||
function decodeTrace(buf) {
|
||||
if (buf.length < 12) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
flags: buf[0],
|
||||
tag: buf.readUInt32LE(1),
|
||||
destHash: buf.subarray(5, 11).toString('hex'),
|
||||
srcHash: buf.subarray(11, 12).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
// Dispatcher
|
||||
function decodePayload(type, buf, channelKeys) {
|
||||
switch (type) {
|
||||
case 0x00: return { type: 'REQ', ...decodeEncryptedPayload(buf) };
|
||||
case 0x01: return { type: 'RESPONSE', ...decodeEncryptedPayload(buf) };
|
||||
case 0x02: return { type: 'TXT_MSG', ...decodeEncryptedPayload(buf) };
|
||||
case 0x03: return { type: 'ACK', ...decodeAck(buf) };
|
||||
case 0x04: return { type: 'ADVERT', ...decodeAdvert(buf) };
|
||||
case 0x05: return { type: 'GRP_TXT', ...decodeGrpTxt(buf, channelKeys) };
|
||||
case 0x07: return { type: 'ANON_REQ', ...decodeAnonReq(buf) };
|
||||
case 0x08: return { type: 'PATH', ...decodePath_payload(buf) };
|
||||
case 0x09: return { type: 'TRACE', ...decodeTrace(buf) };
|
||||
default: return { type: 'UNKNOWN', raw: buf.toString('hex') };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main decoder ---
|
||||
|
||||
function decodePacket(hexString, channelKeys) {
|
||||
const hex = hexString.replace(/\s+/g, '');
|
||||
const buf = Buffer.from(hex, 'hex');
|
||||
|
||||
if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)');
|
||||
|
||||
const header = decodeHeader(buf[0]);
|
||||
const pathByte = buf[1];
|
||||
let offset = 2;
|
||||
|
||||
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT
|
||||
let transportCodes = null;
|
||||
if (TRANSPORT_ROUTES.has(header.routeType)) {
|
||||
if (buf.length < offset + 4) throw new Error('Packet too short for transport codes');
|
||||
transportCodes = {
|
||||
nextHop: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
|
||||
lastHop: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
|
||||
};
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
// Path
|
||||
const path = decodePath(pathByte, buf, offset);
|
||||
offset += path.bytesConsumed;
|
||||
|
||||
// Payload (rest of buffer)
|
||||
const payloadBuf = buf.subarray(offset);
|
||||
const payload = decodePayload(header.payloadType, payloadBuf, channelKeys);
|
||||
|
||||
return {
|
||||
header: {
|
||||
routeType: header.routeType,
|
||||
routeTypeName: header.routeTypeName,
|
||||
payloadType: header.payloadType,
|
||||
payloadTypeName: header.payloadTypeName,
|
||||
payloadVersion: header.payloadVersion,
|
||||
},
|
||||
transportCodes,
|
||||
path: {
|
||||
hashSize: path.hashSize,
|
||||
hashCount: path.hashCount,
|
||||
hops: path.hops,
|
||||
truncated: path.truncated,
|
||||
},
|
||||
payload,
|
||||
raw: hex.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- ADVERT validation ---
|
||||
|
||||
const VALID_ROLES = new Set(['repeater', 'companion', 'room', 'sensor']);
|
||||
|
||||
/**
|
||||
* Validate decoded ADVERT data before upserting into the DB.
|
||||
* Returns { valid: true } or { valid: false, reason: string }.
|
||||
*/
|
||||
function validateAdvert(advert) {
|
||||
if (!advert || advert.error) return { valid: false, reason: advert?.error || 'null advert' };
|
||||
|
||||
// pubkey must be at least 16 hex chars (8 bytes) and not all zeros
|
||||
const pk = advert.pubKey || '';
|
||||
if (pk.length < 16) return { valid: false, reason: `pubkey too short (${pk.length} hex chars)` };
|
||||
if (/^0+$/.test(pk)) return { valid: false, reason: 'pubkey is all zeros' };
|
||||
|
||||
// lat/lon must be in valid ranges if present
|
||||
if (advert.lat != null) {
|
||||
if (!Number.isFinite(advert.lat) || advert.lat < -90 || advert.lat > 90) {
|
||||
return { valid: false, reason: `invalid lat: ${advert.lat}` };
|
||||
}
|
||||
}
|
||||
if (advert.lon != null) {
|
||||
if (!Number.isFinite(advert.lon) || advert.lon < -180 || advert.lon > 180) {
|
||||
return { valid: false, reason: `invalid lon: ${advert.lon}` };
|
||||
}
|
||||
}
|
||||
|
||||
// name must not contain control chars (except space) or be garbage
|
||||
if (advert.name != null) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(advert.name)) {
|
||||
return { valid: false, reason: 'name contains control characters' };
|
||||
}
|
||||
// Reject names that are mostly non-printable or suspiciously long
|
||||
if (advert.name.length > 64) {
|
||||
return { valid: false, reason: `name too long (${advert.name.length} chars)` };
|
||||
}
|
||||
}
|
||||
|
||||
// role derivation check — flags byte should produce a known role
|
||||
if (advert.flags) {
|
||||
const role = advert.flags.repeater ? 'repeater' : advert.flags.room ? 'room' : advert.flags.sensor ? 'sensor' : 'companion';
|
||||
if (!VALID_ROLES.has(role)) return { valid: false, reason: `unknown role: ${role}` };
|
||||
}
|
||||
|
||||
// timestamp: decoded but not currently used for node storage — skip validation
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
module.exports = { decodePacket, validateAdvert, hasNonPrintableChars, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES };
|
||||
|
||||
// --- Tests ---
|
||||
if (require.main === module) {
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
|
||||
const pkt1 = decodePacket(
|
||||
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
||||
);
|
||||
console.log(JSON.stringify(pkt1, null, 2));
|
||||
console.log();
|
||||
|
||||
// Assertions
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error('ASSERT FAILED: ' + msg); };
|
||||
assert(pkt1.header.routeTypeName === 'FLOOD', 'route should be FLOOD');
|
||||
assert(pkt1.header.payloadTypeName === 'ADVERT', 'payload should be ADVERT');
|
||||
assert(pkt1.path.hashSize === 2, 'hashSize should be 2');
|
||||
assert(pkt1.path.hashCount === 5, 'hashCount should be 5');
|
||||
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
||||
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
||||
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
||||
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
|
||||
console.log('✅ Test 1 passed\n');
|
||||
|
||||
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
||||
// Build a minimal advert: header=0x11 (FLOOD+ADVERT), pathLen=0x00 (1-byte hashes, 0 hops)
|
||||
// Then a minimal advert payload: 32-byte pubkey + 4-byte ts + 64-byte sig + flags(1)
|
||||
const fakePubKey = '00'.repeat(32);
|
||||
const fakeTs = '78563412'; // LE = 0x12345678
|
||||
const fakeSig = 'AA'.repeat(64);
|
||||
const flags = '00'; // no location, no name
|
||||
const pkt2hex = '1100' + fakePubKey + fakeTs + fakeSig + flags;
|
||||
const pkt2 = decodePacket(pkt2hex);
|
||||
console.log(JSON.stringify(pkt2, null, 2));
|
||||
console.log();
|
||||
|
||||
assert(pkt2.header.routeTypeName === 'FLOOD', 'route should be FLOOD');
|
||||
assert(pkt2.header.payloadTypeName === 'ADVERT', 'payload should be ADVERT');
|
||||
assert(pkt2.path.hashSize === 1, 'hashSize should be 1');
|
||||
assert(pkt2.path.hashCount === 0, 'hashCount should be 0');
|
||||
assert(pkt2.path.hops.length === 0, 'no hops');
|
||||
assert(pkt2.payload.timestamp === 0x12345678, 'timestamp');
|
||||
console.log('✅ Test 2 passed\n');
|
||||
|
||||
console.log('All tests passed ✅');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Staging-only compose file. Production is managed by docker-compose.yml.
|
||||
# Override defaults via .env or environment variables.
|
||||
|
||||
services:
|
||||
staging-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
image: corescope-go:latest
|
||||
container_name: corescope-staging-go
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 3g
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
||||
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
|
||||
- "6060:6060" # pprof server
|
||||
- "6061:6061" # pprof ingestor
|
||||
volumes:
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
|
||||
- caddy-data-staging-go:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=staging
|
||||
- ENABLE_PPROF=true
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
# Named volume for Caddy TLS certificates (not user data — managed by Caddy internally)
|
||||
caddy-data-staging-go:
|
||||
+37
-78
@@ -1,78 +1,37 @@
|
||||
# Volume paths unified with manage.sh — see manage.sh lines 9-12, 56-68, 98-113
|
||||
# Override defaults via .env or environment variables.
|
||||
|
||||
services:
|
||||
prod:
|
||||
image: meshcore-analyzer:latest
|
||||
container_name: meshcore-prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
|
||||
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
|
||||
- "${PROD_MQTT_PORT:-1883}:1883"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
|
||||
- caddy-data:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
staging:
|
||||
image: meshcore-analyzer:latest
|
||||
container_name: meshcore-staging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STAGING_HTTP_PORT:-81}:${STAGING_HTTP_PORT:-81}"
|
||||
- "${STAGING_MQTT_PORT:-1884}:1883"
|
||||
volumes:
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/config.json:/app/config.json:ro
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
|
||||
- caddy-data-staging:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=staging
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
profiles:
|
||||
- staging
|
||||
|
||||
staging-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
image: meshcore-go:latest
|
||||
container_name: meshcore-staging-go
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
||||
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
|
||||
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
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
profiles:
|
||||
- staging-go
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-data-staging:
|
||||
caddy-data-staging-go:
|
||||
# All container config lives here. manage.sh is just a wrapper around docker compose.
|
||||
# Override defaults via .env or environment variables.
|
||||
# CRITICAL: All data mounts use bind mounts (~/path), NOT named volumes.
|
||||
# This ensures the DB and theme are visible on the host filesystem for backup.
|
||||
|
||||
services:
|
||||
prod:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
image: corescope:latest
|
||||
container_name: corescope-prod
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${PROD_HTTP_PORT:-80}:80"
|
||||
- "${PROD_HTTPS_PORT:-443}:443"
|
||||
- "${PROD_MQTT_PORT:-1883}:1883"
|
||||
volumes:
|
||||
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
|
||||
- caddy-data:/data/caddy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
|
||||
caddy-data:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copy example config if no config.json exists (not bind-mounted)
|
||||
if [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found, copying from config.example.json"
|
||||
cp /app/config.example.json /app/config.json
|
||||
# Config lives in the data directory (bind-mounted from host)
|
||||
# The Go server already searches /app/data/config.json via LoadConfig
|
||||
# but the ingestor expects a direct path — symlink for compatibility
|
||||
if [ -f /app/data/config.json ]; then
|
||||
ln -sf /app/data/config.json /app/config.json
|
||||
elif [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found in /app/data/ — using built-in defaults"
|
||||
fi
|
||||
|
||||
# theme.json: check data/ volume (admin-editable on host)
|
||||
|
||||
@@ -14,21 +14,25 @@ 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
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stdout_logfile=/dev/stdout
|
||||
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
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
|
||||
@@ -14,8 +14,8 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:meshcore-analyzer]
|
||||
command=node /app/server.js
|
||||
[program:corescope]
|
||||
command=/app/corescope-server
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=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.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# CoreScope Migration Guide
|
||||
|
||||
MeshCore Analyzer has been renamed to **CoreScope**. This document covers what you need to update.
|
||||
|
||||
## What Changed
|
||||
|
||||
- **Repository name**: `meshcore-analyzer` → `corescope`
|
||||
- **Docker image name**: `meshcore-analyzer:latest` → `corescope:latest`
|
||||
- **Docker container prefixes**: `meshcore-*` → `corescope-*`
|
||||
- **Default site name**: "MeshCore Analyzer" → "CoreScope"
|
||||
|
||||
## What Did NOT Change
|
||||
|
||||
- **Data directories** — `~/meshcore-data/` stays as-is
|
||||
- **Database filename** — `meshcore.db` is unchanged
|
||||
- **MQTT topics** — `meshcore/#` topics are protocol-level and unchanged
|
||||
- **Browser state** — Favorites, localStorage keys, and settings are preserved
|
||||
- **Config file format** — `config.json` structure is the same
|
||||
|
||||
---
|
||||
|
||||
## 1. Git Remote Update
|
||||
|
||||
Update your local clone to point to the new repository URL:
|
||||
|
||||
```bash
|
||||
git remote set-url origin https://github.com/Kpa-clawbot/corescope.git
|
||||
git pull
|
||||
```
|
||||
|
||||
## 2. Docker (manage.sh) Users
|
||||
|
||||
Rebuild with the new image name:
|
||||
|
||||
```bash
|
||||
./manage.sh stop
|
||||
git pull
|
||||
./manage.sh setup
|
||||
```
|
||||
|
||||
The new image is `corescope:latest`. You can clean up the old image:
|
||||
|
||||
```bash
|
||||
docker rmi meshcore-analyzer:latest
|
||||
```
|
||||
|
||||
## 3. Docker Compose Users
|
||||
|
||||
Rebuild containers with the new names:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Container names change from `meshcore-*` to `corescope-*`. Old containers are removed by `docker compose down`.
|
||||
|
||||
## 4. Data Directories
|
||||
|
||||
**No action required.** The data directory `~/meshcore-data/` and database file `meshcore.db` are unchanged. Your existing data carries over automatically.
|
||||
|
||||
## 5. Config
|
||||
|
||||
If you customized `branding.siteName` in your `config.json`, update it to your preferred name. Otherwise the new default "CoreScope" applies automatically.
|
||||
|
||||
No other config keys changed.
|
||||
|
||||
## 6. MQTT
|
||||
|
||||
**No action required.** MQTT topics (`meshcore/#`) are protocol-level and are not affected by the rename.
|
||||
|
||||
## 7. Browser
|
||||
|
||||
**No action required.** Bookmarks/favorites will continue to work at the same host and port. localStorage keys are unchanged, so your settings and preferences are preserved.
|
||||
|
||||
## 8. CI/CD
|
||||
|
||||
If you have custom CI/CD pipelines that reference:
|
||||
|
||||
- The old repository URL (`meshcore-analyzer`)
|
||||
- The old Docker image name (`meshcore-analyzer:latest`)
|
||||
- Old container names (`meshcore-*`)
|
||||
|
||||
Update those references to use the new names.
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
| Item | Action Required? | What to Do |
|
||||
|------|-----------------|------------|
|
||||
| Git remote | ✅ Yes | `git remote set-url origin …corescope.git` |
|
||||
| Docker image | ✅ Yes | Rebuild; optionally `docker rmi` old image |
|
||||
| Docker Compose | ✅ Yes | `docker compose down && build && up` |
|
||||
| Data directories | ❌ No | Unchanged |
|
||||
| Config | ⚠️ Maybe | Only if you customized `branding.siteName` |
|
||||
| MQTT | ❌ No | Topics unchanged |
|
||||
| Browser | ❌ No | Settings preserved |
|
||||
| CI/CD | ⚠️ Maybe | Update if referencing old repo/image names |
|
||||
@@ -1,90 +0,0 @@
|
||||
// IATA airport coordinates for regional node filtering
|
||||
// Used by resolve-hops to determine if a node is geographically near an observer's region
|
||||
const IATA_COORDS = {
|
||||
// US West Coast
|
||||
SJC: { lat: 37.3626, lon: -121.9290 },
|
||||
SFO: { lat: 37.6213, lon: -122.3790 },
|
||||
OAK: { lat: 37.7213, lon: -122.2208 },
|
||||
SEA: { lat: 47.4502, lon: -122.3088 },
|
||||
PDX: { lat: 45.5898, lon: -122.5951 },
|
||||
LAX: { lat: 33.9425, lon: -118.4081 },
|
||||
SAN: { lat: 32.7338, lon: -117.1933 },
|
||||
SMF: { lat: 38.6954, lon: -121.5908 },
|
||||
MRY: { lat: 36.5870, lon: -121.8430 },
|
||||
EUG: { lat: 44.1246, lon: -123.2119 },
|
||||
RDD: { lat: 40.5090, lon: -122.2934 },
|
||||
MFR: { lat: 42.3742, lon: -122.8735 },
|
||||
FAT: { lat: 36.7762, lon: -119.7181 },
|
||||
SBA: { lat: 34.4262, lon: -119.8405 },
|
||||
RNO: { lat: 39.4991, lon: -119.7681 },
|
||||
BOI: { lat: 43.5644, lon: -116.2228 },
|
||||
LAS: { lat: 36.0840, lon: -115.1537 },
|
||||
PHX: { lat: 33.4373, lon: -112.0078 },
|
||||
SLC: { lat: 40.7884, lon: -111.9778 },
|
||||
// US Mountain/Central
|
||||
DEN: { lat: 39.8561, lon: -104.6737 },
|
||||
DFW: { lat: 32.8998, lon: -97.0403 },
|
||||
IAH: { lat: 29.9844, lon: -95.3414 },
|
||||
AUS: { lat: 30.1975, lon: -97.6664 },
|
||||
MSP: { lat: 44.8848, lon: -93.2223 },
|
||||
// US East Coast
|
||||
ATL: { lat: 33.6407, lon: -84.4277 },
|
||||
ORD: { lat: 41.9742, lon: -87.9073 },
|
||||
JFK: { lat: 40.6413, lon: -73.7781 },
|
||||
EWR: { lat: 40.6895, lon: -74.1745 },
|
||||
BOS: { lat: 42.3656, lon: -71.0096 },
|
||||
MIA: { lat: 25.7959, lon: -80.2870 },
|
||||
IAD: { lat: 38.9531, lon: -77.4565 },
|
||||
CLT: { lat: 35.2144, lon: -80.9473 },
|
||||
DTW: { lat: 42.2124, lon: -83.3534 },
|
||||
MCO: { lat: 28.4312, lon: -81.3081 },
|
||||
BNA: { lat: 36.1263, lon: -86.6774 },
|
||||
RDU: { lat: 35.8801, lon: -78.7880 },
|
||||
// Canada
|
||||
YVR: { lat: 49.1967, lon: -123.1815 },
|
||||
YYZ: { lat: 43.6777, lon: -79.6248 },
|
||||
YYC: { lat: 51.1215, lon: -114.0076 },
|
||||
YEG: { lat: 53.3097, lon: -113.5800 },
|
||||
YOW: { lat: 45.3225, lon: -75.6692 },
|
||||
// Europe
|
||||
LHR: { lat: 51.4700, lon: -0.4543 },
|
||||
CDG: { lat: 49.0097, lon: 2.5479 },
|
||||
FRA: { lat: 50.0379, lon: 8.5622 },
|
||||
AMS: { lat: 52.3105, lon: 4.7683 },
|
||||
MUC: { lat: 48.3537, lon: 11.7750 },
|
||||
SOF: { lat: 42.6952, lon: 23.4062 },
|
||||
// Asia/Pacific
|
||||
NRT: { lat: 35.7720, lon: 140.3929 },
|
||||
HND: { lat: 35.5494, lon: 139.7798 },
|
||||
ICN: { lat: 37.4602, lon: 126.4407 },
|
||||
SYD: { lat: -33.9461, lon: 151.1772 },
|
||||
MEL: { lat: -37.6690, lon: 144.8410 },
|
||||
};
|
||||
|
||||
// Haversine distance in km
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// Default radius for "near region" — LoRa max realistic range ~300km
|
||||
const DEFAULT_REGION_RADIUS_KM = 300;
|
||||
|
||||
/**
|
||||
* Check if a node is geographically within radius of an IATA region center.
|
||||
* Returns { near: boolean, distKm: number } or null if can't determine.
|
||||
*/
|
||||
function nodeNearRegion(nodeLat, nodeLon, iata, radiusKm = DEFAULT_REGION_RADIUS_KM) {
|
||||
const center = IATA_COORDS[iata];
|
||||
if (!center) return null;
|
||||
if (nodeLat == null || nodeLon == null || (nodeLat === 0 && nodeLon === 0)) return null;
|
||||
const distKm = haversineKm(nodeLat, nodeLon, center.lat, center.lon);
|
||||
return { near: distKm <= radiusKm, distKm: Math.round(distKm) };
|
||||
}
|
||||
|
||||
module.exports = { IATA_COORDS, haversineKm, nodeNearRegion, DEFAULT_REGION_RADIUS_KM };
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "npx c8 --reporter=text --reporter=text-summary sh test-all.sh",
|
||||
"test:unit": "node test-packet-filter.js && node test-aging.js && node test-regional-filter.js",
|
||||
"test:unit": "node test-packet-filter.js && node test-aging.js && node test-frontend-helpers.js",
|
||||
"test:coverage": "npx c8 --reporter=text --reporter=html sh test-all.sh",
|
||||
"test:full-coverage": "sh scripts/combined-coverage.sh"
|
||||
},
|
||||
|
||||
-752
@@ -1,752 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* In-memory packet store — loads transmissions + observations from SQLite on startup,
|
||||
* serves reads from RAM, writes to both RAM + SQLite.
|
||||
* M3: Restructured around transmissions (deduped by hash) with observations.
|
||||
* Caps memory at configurable limit (default 1GB).
|
||||
*/
|
||||
class PacketStore {
|
||||
constructor(dbModule, config = {}) {
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertTransmission, .getPacket)
|
||||
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
|
||||
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
|
||||
this.estPacketBytes = config.estimatedPacketBytes || 450;
|
||||
this.maxPackets = Math.floor(this.maxBytes / this.estPacketBytes);
|
||||
|
||||
// SQLite-only mode: skip RAM loading, all reads go to DB
|
||||
this.sqliteOnly = process.env.NO_MEMORY_STORE === '1';
|
||||
|
||||
// Primary storage: transmissions sorted by first_seen DESC (newest first)
|
||||
// Each transmission looks like a packet for backward compat
|
||||
this.packets = [];
|
||||
|
||||
// Indexes
|
||||
this.byId = new Map(); // observation_id → observation object (backward compat for packet detail links)
|
||||
this.byTxId = new Map(); // transmission_id → transmission object
|
||||
this.byHash = new Map(); // hash → transmission object (1:1)
|
||||
this.byObserver = new Map(); // observer_id → [observation objects]
|
||||
this.byNode = new Map(); // pubkey → [transmission objects] (deduped)
|
||||
|
||||
// Track which hashes are indexed per node pubkey (avoid dupes in byNode)
|
||||
this._nodeHashIndex = new Map(); // pubkey → Set<hash>
|
||||
this._advertByObserver = new Map(); // pubkey → Set<observer_id> (ADVERT-only, for region filtering)
|
||||
|
||||
this.loaded = false;
|
||||
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
|
||||
}
|
||||
|
||||
/** Load all packets from SQLite into memory */
|
||||
load() {
|
||||
if (this.sqliteOnly) {
|
||||
console.log('[PacketStore] SQLite-only mode (NO_MEMORY_STORE=1) — all reads go to database');
|
||||
this.loaded = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
|
||||
// Check if normalized schema exists
|
||||
const hasTransmissions = this.db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='transmissions'"
|
||||
).get();
|
||||
|
||||
if (hasTransmissions) {
|
||||
this._loadNormalized();
|
||||
} else {
|
||||
this._loadLegacy();
|
||||
}
|
||||
|
||||
this.stats.totalLoaded = this.packets.length;
|
||||
this.loaded = true;
|
||||
const elapsed = Date.now() - t0;
|
||||
console.log(`[PacketStore] Loaded ${this.packets.length} transmissions (${this.stats.totalObservations} observations) in ${elapsed}ms (${Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024)}MB est)`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Load from normalized transmissions + observations tables */
|
||||
_loadNormalized() {
|
||||
// Detect v3 schema (observer_idx instead of observer_id in observations)
|
||||
const obsCols = this.db.pragma('table_info(observations)').map(c => c.name);
|
||||
const isV3 = obsCols.includes('observer_idx');
|
||||
|
||||
const sql = isV3
|
||||
? `SELECT t.id AS transmission_id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id AS observation_id, obs.id AS observer_id, obs.name AS observer_name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, datetime(o.timestamp, 'unixepoch') AS obs_timestamp
|
||||
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`
|
||||
: `SELECT t.id AS transmission_id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id AS observation_id, o.observer_id, o.observer_name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp AS obs_timestamp
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC`;
|
||||
|
||||
for (const row of this.db.prepare(sql).iterate()) {
|
||||
if (this.packets.length >= this.maxPackets && !this.byHash.has(row.hash)) break;
|
||||
|
||||
let tx = this.byHash.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.transmission_id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.first_seen,
|
||||
timestamp: row.first_seen,
|
||||
route_type: row.route_type,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
// Filled from first observation for backward compat
|
||||
observer_id: null,
|
||||
observer_name: null,
|
||||
snr: null,
|
||||
rssi: null,
|
||||
path_json: null,
|
||||
direction: null,
|
||||
};
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this.byTxId.set(tx.id, tx);
|
||||
this._indexByNode(tx);
|
||||
}
|
||||
|
||||
if (row.observation_id != null) {
|
||||
const obs = {
|
||||
id: row.observation_id,
|
||||
transmission_id: tx.id,
|
||||
hash: tx.hash,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
direction: row.direction,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
score: row.score,
|
||||
path_json: row.path_json,
|
||||
timestamp: row.obs_timestamp,
|
||||
};
|
||||
|
||||
// Dedup: skip if same observer + same path already loaded
|
||||
const isDupeLoad = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (isDupeLoad) continue;
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
// Fill first observation data into transmission for backward compat
|
||||
if (tx.observer_id == null && obs.observer_id) {
|
||||
tx.observer_id = obs.observer_id;
|
||||
tx.observer_name = obs.observer_name;
|
||||
tx.snr = obs.snr;
|
||||
tx.rssi = obs.rssi;
|
||||
tx.path_json = obs.path_json;
|
||||
tx.direction = obs.direction;
|
||||
}
|
||||
|
||||
// byId maps observation IDs for packet detail links
|
||||
this.byId.set(obs.id, obs);
|
||||
|
||||
// byObserver
|
||||
if (obs.observer_id) {
|
||||
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
|
||||
this.byObserver.get(obs.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-load: set each transmission's display path to the LONGEST observation path
|
||||
// (most representative of mesh topology — short paths are just nearby observers)
|
||||
for (const tx of this.packets) {
|
||||
if (tx.observations.length > 0) {
|
||||
let best = tx.observations[0];
|
||||
let bestLen = 0;
|
||||
try { bestLen = JSON.parse(best.path_json || '[]').length; } catch {}
|
||||
for (let i = 1; i < tx.observations.length; i++) {
|
||||
let len = 0;
|
||||
try { len = JSON.parse(tx.observations[i].path_json || '[]').length; } catch {}
|
||||
if (len > bestLen) { best = tx.observations[i]; bestLen = len; }
|
||||
}
|
||||
tx.observer_id = best.observer_id;
|
||||
tx.observer_name = best.observer_name;
|
||||
tx.snr = best.snr;
|
||||
tx.rssi = best.rssi;
|
||||
tx.path_json = best.path_json;
|
||||
tx.direction = best.direction;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-load: build ADVERT-by-observer index (needs all observations loaded first)
|
||||
for (const tx of this.packets) {
|
||||
if (tx.payload_type === 4 && tx.decoded_json) {
|
||||
try {
|
||||
const d = JSON.parse(tx.decoded_json);
|
||||
if (d.pubKey) this._indexAdvertObservers(d.pubKey, tx);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(`[PacketStore] ADVERT observer index: ${this._advertByObserver.size} nodes tracked`);
|
||||
}
|
||||
|
||||
/** Fallback: load from legacy packets table */
|
||||
_loadLegacy() {
|
||||
for (const row of this.db.prepare(
|
||||
'SELECT * FROM packets_v ORDER BY timestamp DESC'
|
||||
).iterate()) {
|
||||
if (this.packets.length >= this.maxPackets) break;
|
||||
this._indexLegacy(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Index a legacy packet row (old flat structure) — builds transmission + observation */
|
||||
_indexLegacy(pkt) {
|
||||
let tx = this.byHash.get(pkt.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: pkt.id,
|
||||
raw_hex: pkt.raw_hex,
|
||||
hash: pkt.hash,
|
||||
first_seen: pkt.timestamp,
|
||||
timestamp: pkt.timestamp,
|
||||
route_type: pkt.route_type,
|
||||
payload_type: pkt.payload_type,
|
||||
decoded_json: pkt.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
observer_id: pkt.observer_id,
|
||||
observer_name: pkt.observer_name,
|
||||
snr: pkt.snr,
|
||||
rssi: pkt.rssi,
|
||||
path_json: pkt.path_json,
|
||||
direction: pkt.direction,
|
||||
};
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this.byTxId.set(tx.id, tx);
|
||||
this._indexByNode(tx);
|
||||
}
|
||||
|
||||
if (pkt.timestamp < tx.first_seen) {
|
||||
tx.first_seen = pkt.timestamp;
|
||||
tx.timestamp = pkt.timestamp;
|
||||
}
|
||||
// Update display path if new observation has longer path
|
||||
let newPathLen = 0, curPathLen = 0;
|
||||
try { newPathLen = JSON.parse(pkt.path_json || '[]').length; } catch {}
|
||||
try { curPathLen = JSON.parse(tx.path_json || '[]').length; } catch {}
|
||||
if (newPathLen > curPathLen) {
|
||||
tx.observer_id = pkt.observer_id;
|
||||
tx.observer_name = pkt.observer_name;
|
||||
tx.path_json = pkt.path_json;
|
||||
}
|
||||
|
||||
const obs = {
|
||||
id: pkt.id,
|
||||
transmission_id: tx.id,
|
||||
observer_id: pkt.observer_id,
|
||||
observer_name: pkt.observer_name,
|
||||
direction: pkt.direction,
|
||||
snr: pkt.snr,
|
||||
rssi: pkt.rssi,
|
||||
score: pkt.score,
|
||||
path_json: pkt.path_json,
|
||||
timestamp: pkt.timestamp,
|
||||
};
|
||||
// Dedup: skip if same observer + same path already recorded for this transmission
|
||||
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (isDupe) return tx;
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
this.byId.set(pkt.id, obs);
|
||||
|
||||
if (pkt.observer_id) {
|
||||
if (!this.byObserver.has(pkt.observer_id)) this.byObserver.set(pkt.observer_id, []);
|
||||
this.byObserver.get(pkt.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
|
||||
/** Extract node pubkeys from decoded_json and index transmission in byNode */
|
||||
_indexByNode(tx) {
|
||||
if (!tx.decoded_json) return;
|
||||
try {
|
||||
const decoded = JSON.parse(tx.decoded_json);
|
||||
const keys = new Set();
|
||||
if (decoded.pubKey) keys.add(decoded.pubKey);
|
||||
if (decoded.destPubKey) keys.add(decoded.destPubKey);
|
||||
if (decoded.srcPubKey) keys.add(decoded.srcPubKey);
|
||||
for (const k of keys) {
|
||||
if (!this._nodeHashIndex.has(k)) this._nodeHashIndex.set(k, new Set());
|
||||
if (this._nodeHashIndex.get(k).has(tx.hash)) continue;
|
||||
this._nodeHashIndex.get(k).add(tx.hash);
|
||||
if (!this.byNode.has(k)) this.byNode.set(k, []);
|
||||
this.byNode.get(k).push(tx);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Track which observers saw an ADVERT from a given pubkey */
|
||||
_indexAdvertObservers(pubkey, tx) {
|
||||
if (!this._advertByObserver.has(pubkey)) this._advertByObserver.set(pubkey, new Set());
|
||||
const s = this._advertByObserver.get(pubkey);
|
||||
for (const obs of tx.observations) {
|
||||
if (obs.observer_id) s.add(obs.observer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get node pubkeys whose ADVERTs were seen by any of the given observer IDs */
|
||||
getNodesByAdvertObservers(observerIds) {
|
||||
const result = new Set();
|
||||
for (const [pubkey, observers] of this._advertByObserver) {
|
||||
for (const obsId of observerIds) {
|
||||
if (observers.has(obsId)) { result.add(pubkey); break; }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Remove oldest transmissions when over memory limit */
|
||||
_evict() {
|
||||
while (this.packets.length > this.maxPackets) {
|
||||
const old = this.packets.pop();
|
||||
this.byHash.delete(old.hash);
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTxId.delete(old.id);
|
||||
// Remove observations from byId and byObserver
|
||||
for (const obs of old.observations) {
|
||||
this.byId.delete(obs.id);
|
||||
if (obs.observer_id && this.byObserver.has(obs.observer_id)) {
|
||||
const arr = this.byObserver.get(obs.observer_id).filter(o => o.id !== obs.id);
|
||||
if (arr.length) this.byObserver.set(obs.observer_id, arr); else this.byObserver.delete(obs.observer_id);
|
||||
}
|
||||
}
|
||||
// Skip node index cleanup (expensive, low value)
|
||||
this.stats.evicted++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert a new packet (to both memory and SQLite) */
|
||||
insert(packetData) {
|
||||
// Write to normalized tables and get the transmission ID
|
||||
const txResult = this.dbModule.insertTransmission ? this.dbModule.insertTransmission(packetData) : null;
|
||||
const transmissionId = txResult ? txResult.transmissionId : null;
|
||||
const observationId = txResult ? txResult.observationId : null;
|
||||
|
||||
// Build row directly from packetData — avoids view ID mismatch issues
|
||||
const row = {
|
||||
id: observationId,
|
||||
raw_hex: packetData.raw_hex,
|
||||
hash: packetData.hash,
|
||||
timestamp: packetData.timestamp,
|
||||
route_type: packetData.route_type,
|
||||
payload_type: packetData.payload_type,
|
||||
payload_version: packetData.payload_version,
|
||||
decoded_json: packetData.decoded_json,
|
||||
observer_id: packetData.observer_id,
|
||||
observer_name: packetData.observer_name,
|
||||
snr: packetData.snr,
|
||||
rssi: packetData.rssi,
|
||||
path_json: packetData.path_json,
|
||||
direction: packetData.direction,
|
||||
};
|
||||
if (!this.sqliteOnly) {
|
||||
// Update or create transmission in memory
|
||||
let tx = this.byHash.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: transmissionId || row.id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.timestamp,
|
||||
timestamp: row.timestamp,
|
||||
route_type: row.route_type,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
path_json: row.path_json,
|
||||
direction: row.direction,
|
||||
};
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.unshift(tx); // newest first
|
||||
this.byTxId.set(tx.id, tx);
|
||||
this._indexByNode(tx);
|
||||
} else {
|
||||
// Update first_seen if earlier
|
||||
if (row.timestamp < tx.first_seen) {
|
||||
tx.first_seen = row.timestamp;
|
||||
tx.timestamp = row.timestamp;
|
||||
}
|
||||
// Update display path if new observation has longer path
|
||||
let newPathLen = 0, curPathLen = 0;
|
||||
try { newPathLen = JSON.parse(row.path_json || '[]').length; } catch {}
|
||||
try { curPathLen = JSON.parse(tx.path_json || '[]').length; } catch {}
|
||||
if (newPathLen > curPathLen) {
|
||||
tx.observer_id = row.observer_id;
|
||||
tx.observer_name = row.observer_name;
|
||||
tx.path_json = row.path_json;
|
||||
}
|
||||
}
|
||||
|
||||
// Add observation
|
||||
const obs = {
|
||||
id: row.id,
|
||||
transmission_id: tx.id,
|
||||
hash: tx.hash,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
direction: row.direction,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
score: row.score,
|
||||
path_json: row.path_json,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
// Dedup: skip if same observer + same path already recorded for this transmission
|
||||
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (!isDupe) {
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
}
|
||||
|
||||
// Update transmission's display fields if this is first observation
|
||||
if (tx.observations.length === 1) {
|
||||
tx.observer_id = obs.observer_id;
|
||||
tx.observer_name = obs.observer_name;
|
||||
tx.snr = obs.snr;
|
||||
tx.rssi = obs.rssi;
|
||||
tx.path_json = obs.path_json;
|
||||
}
|
||||
|
||||
this.byId.set(obs.id, obs);
|
||||
if (obs.observer_id) {
|
||||
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
|
||||
this.byObserver.get(obs.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
|
||||
// Update ADVERT observer index for live ingestion
|
||||
if (tx.payload_type === 4 && obs.observer_id && tx.decoded_json) {
|
||||
try {
|
||||
const d = JSON.parse(tx.decoded_json);
|
||||
if (d.pubKey) {
|
||||
if (!this._advertByObserver.has(d.pubKey)) this._advertByObserver.set(d.pubKey, new Set());
|
||||
this._advertByObserver.get(d.pubKey).add(obs.observer_id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this._evict();
|
||||
this.stats.inserts++;
|
||||
}
|
||||
return observationId || transmissionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL packets referencing a node — by pubkey index + name + pubkey text search.
|
||||
* Returns unique transmissions (deduped).
|
||||
* @param {string} nodeIdOrName - pubkey or friendly name
|
||||
* @param {Array} [fromPackets] - packet array to filter (defaults to this.packets)
|
||||
* @returns {{ packets: Array, pubkey: string, nodeName: string }}
|
||||
*/
|
||||
findPacketsForNode(nodeIdOrName, fromPackets) {
|
||||
let pubkey = nodeIdOrName;
|
||||
let nodeName = nodeIdOrName;
|
||||
|
||||
// Always resolve to get both pubkey and name
|
||||
try {
|
||||
const row = this.db.prepare("SELECT public_key, name FROM nodes WHERE public_key = ? OR name = ? LIMIT 1").get(nodeIdOrName, nodeIdOrName);
|
||||
if (row) { pubkey = row.public_key; nodeName = row.name || nodeIdOrName; }
|
||||
} catch {}
|
||||
|
||||
// Combine: index hits + text search
|
||||
const indexed = this.byNode.get(pubkey);
|
||||
const hashSet = indexed ? new Set(indexed.map(t => t.hash)) : new Set();
|
||||
const source = fromPackets || this.packets;
|
||||
const packets = source.filter(t =>
|
||||
hashSet.has(t.hash) ||
|
||||
(t.decoded_json && (t.decoded_json.includes(nodeName) || t.decoded_json.includes(pubkey)))
|
||||
);
|
||||
|
||||
return { packets, pubkey, nodeName };
|
||||
}
|
||||
|
||||
/** Count transmissions and observations for a node */
|
||||
countForNode(pubkey) {
|
||||
const txs = this.byNode.get(pubkey) || [];
|
||||
let observations = 0;
|
||||
for (const tx of txs) observations += tx.observation_count;
|
||||
return { transmissions: txs.length, observations };
|
||||
}
|
||||
|
||||
/** Query packets with filters — all from memory (or SQLite in fallback mode) */
|
||||
query({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node, order = 'DESC' } = {}) {
|
||||
this.stats.queries++;
|
||||
|
||||
if (this.sqliteOnly) return this._querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order });
|
||||
|
||||
let results = this.packets;
|
||||
|
||||
// Use indexes for single-key filters when possible
|
||||
if (hash && !type && !route && !region && !observer && !since && !until && !node) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? [tx] : [];
|
||||
} else if (observer && !type && !route && !region && !hash && !since && !until && !node) {
|
||||
// For observer filter, find unique transmissions where any observation matches
|
||||
results = this._transmissionsForObserver(observer);
|
||||
} else if (node && !type && !route && !region && !observer && !hash && !since && !until) {
|
||||
results = this.findPacketsForNode(node).packets;
|
||||
} else {
|
||||
// Apply filters sequentially
|
||||
if (type !== undefined) {
|
||||
const t = Number(type);
|
||||
results = results.filter(p => p.payload_type === t);
|
||||
}
|
||||
if (route !== undefined) {
|
||||
const r = Number(route);
|
||||
results = results.filter(p => p.route_type === r);
|
||||
}
|
||||
if (observer) results = this._transmissionsForObserver(observer, results);
|
||||
if (hash) {
|
||||
const h = hash.toLowerCase();
|
||||
const tx = this.byHash.get(h);
|
||||
results = tx ? results.filter(p => p.hash === h) : [];
|
||||
}
|
||||
if (since) results = results.filter(p => p.timestamp > since);
|
||||
if (until) results = results.filter(p => p.timestamp < until);
|
||||
if (region) {
|
||||
const regionObservers = new Set();
|
||||
try {
|
||||
const obs = this.db.prepare('SELECT id FROM observers WHERE iata = ?').all(region);
|
||||
obs.forEach(o => regionObservers.add(o.id));
|
||||
} catch {}
|
||||
results = results.filter(p =>
|
||||
p.observations.some(o => regionObservers.has(o.observer_id))
|
||||
);
|
||||
}
|
||||
if (node) {
|
||||
results = this.findPacketsForNode(node, results).packets;
|
||||
}
|
||||
}
|
||||
|
||||
const total = results.length;
|
||||
|
||||
// Sort
|
||||
if (order === 'ASC') {
|
||||
results = results.slice().sort((a, b) => {
|
||||
if (a.timestamp < b.timestamp) return -1;
|
||||
if (a.timestamp > b.timestamp) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
// Default DESC — packets array is already sorted newest-first
|
||||
|
||||
// Paginate
|
||||
const paginated = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Find unique transmissions that have at least one observation from given observer */
|
||||
_transmissionsForObserver(observerId, fromTransmissions) {
|
||||
if (fromTransmissions) {
|
||||
return fromTransmissions.filter(tx =>
|
||||
tx.observations.some(o => o.observer_id === observerId)
|
||||
);
|
||||
}
|
||||
// Use byObserver index: get observations, then unique transmissions
|
||||
const obs = this.byObserver.get(observerId) || [];
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const o of obs) {
|
||||
const txId = o.transmission_id;
|
||||
if (!seen.has(txId)) {
|
||||
seen.add(txId);
|
||||
const tx = this.byTxId.get(txId);
|
||||
if (tx) result.push(tx);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Query with groupByHash — now trivial since packets ARE transmissions */
|
||||
queryGrouped({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node } = {}) {
|
||||
this.stats.queries++;
|
||||
|
||||
if (this.sqliteOnly) return this._queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node });
|
||||
|
||||
// Get filtered transmissions
|
||||
const { packets: filtered, total: filteredTotal } = this.query({
|
||||
limit: 999999, offset: 0, type, route, region, observer, hash, since, until, node
|
||||
});
|
||||
|
||||
// Already grouped by hash — just format for backward compat
|
||||
const sorted = filtered.map(tx => ({
|
||||
hash: tx.hash,
|
||||
first_seen: tx.first_seen || tx.timestamp,
|
||||
count: tx.observation_count,
|
||||
observer_count: new Set(tx.observations.map(o => o.observer_id).filter(Boolean)).size,
|
||||
latest: tx.observations.length ? tx.observations.reduce((max, o) => o.timestamp > max ? o.timestamp : max, tx.observations[0].timestamp) : tx.timestamp,
|
||||
observer_id: tx.observer_id,
|
||||
observer_name: tx.observer_name,
|
||||
path_json: tx.path_json,
|
||||
payload_type: tx.payload_type,
|
||||
route_type: tx.route_type,
|
||||
raw_hex: tx.raw_hex,
|
||||
decoded_json: tx.decoded_json,
|
||||
observation_count: tx.observation_count,
|
||||
snr: tx.snr,
|
||||
rssi: tx.rssi,
|
||||
})).sort((a, b) => b.latest.localeCompare(a.latest));
|
||||
|
||||
const total = sorted.length;
|
||||
const paginated = sorted.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Get timestamps for sparkline */
|
||||
getTimestamps(since) {
|
||||
if (this.sqliteOnly) {
|
||||
return this.db.prepare('SELECT timestamp FROM packets_v WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
}
|
||||
const results = [];
|
||||
for (const p of this.packets) {
|
||||
if (p.timestamp <= since) break;
|
||||
results.push(p.timestamp);
|
||||
}
|
||||
return results.reverse();
|
||||
}
|
||||
|
||||
/** Get a single packet by ID — checks observation IDs first (backward compat) */
|
||||
getById(id) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE id = ?').get(id) || null;
|
||||
const obs = this.byId.get(id) || null;
|
||||
return this._enrichObs(obs);
|
||||
}
|
||||
|
||||
/** Get a transmission by its transmission table ID */
|
||||
getByTxId(id) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null;
|
||||
return this.byTxId.get(id) || null;
|
||||
}
|
||||
|
||||
/** Get all siblings of a packet (same hash) — returns enriched observations array */
|
||||
getSiblings(hash) {
|
||||
const h = hash.toLowerCase();
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE hash = ? ORDER BY timestamp DESC').all(h);
|
||||
const tx = this.byHash.get(h);
|
||||
return tx ? tx.observations.map(o => this._enrichObs(o)) : [];
|
||||
}
|
||||
|
||||
/** Get all transmissions (backward compat — returns packets array) */
|
||||
all() {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all();
|
||||
return this.packets;
|
||||
}
|
||||
|
||||
/** Get all transmissions matching a filter function */
|
||||
filter(fn) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all().filter(fn);
|
||||
return this.packets.filter(fn);
|
||||
}
|
||||
|
||||
/** Enrich a lean observation with transmission fields (for API responses) */
|
||||
_enrichObs(obs) {
|
||||
if (!obs) return null;
|
||||
const tx = this.byTxId.get(obs.transmission_id);
|
||||
if (!tx) return obs;
|
||||
return {
|
||||
...obs,
|
||||
hash: tx.hash,
|
||||
raw_hex: tx.raw_hex,
|
||||
payload_type: tx.payload_type,
|
||||
decoded_json: tx.decoded_json,
|
||||
route_type: tx.route_type,
|
||||
};
|
||||
}
|
||||
|
||||
/** Enrich an array of observations with transmission fields */
|
||||
enrichObservations(observations) {
|
||||
if (!observations || !observations.length) return observations;
|
||||
return observations.map(o => this._enrichObs(o));
|
||||
}
|
||||
|
||||
/** Memory stats */
|
||||
getStats() {
|
||||
return {
|
||||
...this.stats,
|
||||
inMemory: this.sqliteOnly ? 0 : this.packets.length,
|
||||
sqliteOnly: this.sqliteOnly,
|
||||
maxPackets: this.maxPackets,
|
||||
estimatedMB: this.sqliteOnly ? 0 : Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024),
|
||||
maxMB: Math.round(this.maxBytes / 1024 / 1024),
|
||||
indexes: {
|
||||
byHash: this.byHash.size,
|
||||
byObserver: this.byObserver.size,
|
||||
byNode: this.byNode.size,
|
||||
advertByObserver: this._advertByObserver.size,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** SQLite fallback: query with filters */
|
||||
_querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order }) {
|
||||
const where = []; const params = [];
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('decoded_json LIKE ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets_v ${w}`).get(...params).c;
|
||||
const packets = this.db.prepare(`SELECT * FROM packets_v ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
/** SQLite fallback: grouped query */
|
||||
_queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node }) {
|
||||
const where = []; const params = [];
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('decoded_json LIKE ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
|
||||
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
|
||||
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(route_type) as route_type,
|
||||
MIN(raw_hex) as raw_hex, MIN(decoded_json) as decoded_json, MIN(snr) as snr, MIN(rssi) as rssi
|
||||
FROM packets_v ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
const packets = this.db.prepare(sql).all(...params, limit, offset);
|
||||
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets_v ${w}`;
|
||||
const total = this.db.prepare(countSql).get(...params).c;
|
||||
return { packets, total };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PacketStore;
|
||||
@@ -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";
|
||||
|
||||
+434
-98
@@ -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>';
|
||||
@@ -867,6 +876,26 @@
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
${data.distributionByRepeaters ? (() => {
|
||||
const dr = data.distributionByRepeaters;
|
||||
const totalRepeaters = (dr[1] || 0) + (dr[2] || 0) + (dr[3] || 0);
|
||||
const rpct = (n) => totalRepeaters ? (n / totalRepeaters * 100).toFixed(1) : '0';
|
||||
const maxRepeaters = Math.max(dr[1] || 0, dr[2] || 0, dr[3] || 0, 1);
|
||||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||||
return `<h4 style="margin:16px 0 4px">By Repeaters</h4>
|
||||
<p class="text-muted">${totalRepeaters.toLocaleString()} unique repeaters</p>
|
||||
<div class="hash-bars">
|
||||
${[1, 2, 3].map(size => {
|
||||
const count = dr[size] || 0;
|
||||
const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0);
|
||||
return `<div class="hash-bar-row">
|
||||
<div class="hash-bar-label"><strong>${size}-byte</strong></div>
|
||||
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${width}%;background:${colors[size]};opacity:0.7"></div></div>
|
||||
<div class="hash-bar-value">${count.toLocaleString()} <span class="text-muted">(${rpct(count)}%)</span></div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
})() : ''}
|
||||
</div>
|
||||
<div class="analytics-card flex-1">
|
||||
<h3>📈 Hash Size Over Time</h3>
|
||||
@@ -880,7 +909,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 +925,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}`;
|
||||
@@ -931,13 +960,23 @@
|
||||
</div>
|
||||
|
||||
<div class="analytics-card" id="hashMatrixSection">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">🔢 1-Byte Hash Usage Matrix</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h3 style="margin:0" id="hashMatrixTitle">🔢 Hash Usage Matrix</h3>
|
||||
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
|
||||
<div class="hash-byte-selector" id="hashByteSelector" style="display:flex;gap:4px">
|
||||
<button class="hash-byte-btn active" data-bytes="1">1-Byte</button>
|
||||
<button class="hash-byte-btn" data-bytes="2">2-Byte</button>
|
||||
<button class="hash-byte-btn" data-bytes="3">3-Byte</button>
|
||||
</div>
|
||||
<p class="text-muted" id="hashMatrixDesc" style="margin:0;font-size:0.8em">Click a cell to see which nodes share that prefix.</p>
|
||||
</div>
|
||||
<div id="hashMatrix"></div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-card" id="collisionRiskSection">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">💥 1-Byte Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle">💥 Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||||
</div>
|
||||
`;
|
||||
@@ -952,7 +991,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() : '?';
|
||||
@@ -974,10 +1013,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Only repeaters matter for routing — filter out non-repeaters for collision analysis
|
||||
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
|
||||
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
|
||||
renderHashMatrix(data.topHops, repeaterNodes);
|
||||
renderCollisions(data.topHops, repeaterNodes);
|
||||
const nullRoleNodes = allNodes.filter(n => !n.role);
|
||||
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
|
||||
|
||||
let currentBytes = 1;
|
||||
function refreshHashViews(bytes) {
|
||||
currentBytes = bytes;
|
||||
hideMatrixTip();
|
||||
// Update selector button states
|
||||
document.querySelectorAll('.hash-byte-btn').forEach(b => {
|
||||
b.classList.toggle('active', Number(b.dataset.bytes) === bytes);
|
||||
});
|
||||
// Update titles and description
|
||||
const matrixTitle = document.getElementById('hashMatrixTitle');
|
||||
const matrixDesc = document.getElementById('hashMatrixDesc');
|
||||
const riskTitle = document.getElementById('collisionRiskTitle');
|
||||
if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`;
|
||||
if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`;
|
||||
if (matrixDesc) {
|
||||
if (bytes === 1) matrixDesc.textContent = 'Click a cell to see which nodes share that 1-byte prefix.';
|
||||
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
|
||||
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
|
||||
}
|
||||
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
|
||||
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
|
||||
const riskCard = document.getElementById('collisionRiskSection');
|
||||
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
|
||||
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
|
||||
}
|
||||
|
||||
// Wire up selector
|
||||
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes)));
|
||||
});
|
||||
|
||||
refreshHashViews(1);
|
||||
}
|
||||
|
||||
function renderHashTimeline(hourly) {
|
||||
@@ -1004,93 +1076,341 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
async function renderHashMatrix(topHops, allNodes) {
|
||||
// Shared hover tooltip for hash matrix cells.
|
||||
// Called once per container — reads content from data-tip on each <td>.
|
||||
// Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch
|
||||
let _matrixTip = null;
|
||||
function getMatrixTip() {
|
||||
if (!_matrixTip) {
|
||||
_matrixTip = document.createElement('div');
|
||||
_matrixTip.className = 'hash-matrix-tooltip';
|
||||
_matrixTip.style.display = 'none';
|
||||
document.body.appendChild(_matrixTip);
|
||||
}
|
||||
return _matrixTip;
|
||||
}
|
||||
function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; }
|
||||
|
||||
function initMatrixTooltip(el) {
|
||||
if (el._matrixTipInit) return;
|
||||
el._matrixTipInit = true;
|
||||
el.addEventListener('mouseover', e => {
|
||||
const td = e.target.closest('td[data-tip]');
|
||||
if (!td) return;
|
||||
const tip = getMatrixTip();
|
||||
tip.innerHTML = td.dataset.tip;
|
||||
tip.style.display = 'block';
|
||||
});
|
||||
el.addEventListener('mousemove', e => {
|
||||
if (!_matrixTip || _matrixTip.style.display === 'none') return;
|
||||
const x = e.clientX + 14, y = e.clientY + 14;
|
||||
_matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px';
|
||||
_matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px';
|
||||
});
|
||||
el.addEventListener('mouseout', e => {
|
||||
if (e.target.closest('td[data-tip]') && !e.relatedTarget?.closest('td[data-tip]')) hideMatrixTip();
|
||||
});
|
||||
el.addEventListener('mouseleave', hideMatrixTip);
|
||||
}
|
||||
|
||||
// Pure data helpers — extracted for testability
|
||||
|
||||
function buildOneBytePrefixMap(nodes) {
|
||||
const map = {};
|
||||
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
|
||||
for (const n of nodes) {
|
||||
const hex = n.public_key.slice(0, 2).toUpperCase();
|
||||
if (map[hex]) map[hex].push(n);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildTwoBytePrefixInfo(nodes) {
|
||||
const info = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const h = i.toString(16).padStart(2, '0').toUpperCase();
|
||||
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const firstHex = n.public_key.slice(0, 2).toUpperCase();
|
||||
const twoHex = n.public_key.slice(0, 4).toUpperCase();
|
||||
const entry = info[firstHex];
|
||||
if (!entry) continue;
|
||||
entry.groupNodes.push(n);
|
||||
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
|
||||
entry.twoByteMap[twoHex].push(n);
|
||||
}
|
||||
for (const entry of Object.values(info)) {
|
||||
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
|
||||
entry.collisionCount = collisions.length;
|
||||
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function buildCollisionHops(allNodes, bytes) {
|
||||
const map = {};
|
||||
for (const n of allNodes) {
|
||||
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
|
||||
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
|
||||
map[p].count++;
|
||||
}
|
||||
return Object.values(map).filter(h => h.count > 1);
|
||||
}
|
||||
|
||||
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
|
||||
bytes = bytes || 1;
|
||||
totalNodes = totalNodes || allNodes;
|
||||
const el = document.getElementById('hashMatrix');
|
||||
|
||||
// Build prefix → node count map
|
||||
const prefixNodes = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const hex = i.toString(16).padStart(2, '0').toUpperCase();
|
||||
prefixNodes[hex] = allNodes.filter(n => n.public_key.toUpperCase().startsWith(hex));
|
||||
// 3-byte: show a summary panel instead of a matrix
|
||||
if (bytes === 3) {
|
||||
const total = totalNodes.length;
|
||||
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
|
||||
const prefixMap = {};
|
||||
for (const n of nodesForByte) {
|
||||
const p = n.public_key.slice(0, 6).toUpperCase();
|
||||
if (!prefixMap[p]) prefixMap[p] = 0;
|
||||
prefixMap[p]++;
|
||||
}
|
||||
const uniquePrefixes = Object.keys(prefixMap).length;
|
||||
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
|
||||
const spaceSize = 16777216; // 2^24
|
||||
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 3-byte ID</div>
|
||||
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const nibbles = '0123456789ABCDEF'.split('');
|
||||
const cellSize = 36;
|
||||
const headerSize = 24;
|
||||
|
||||
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) {
|
||||
html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
if (bytes === 1) {
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
|
||||
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
|
||||
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
|
||||
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
|
||||
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
|
||||
const onePct = ((oneUsed / 256) * 100).toFixed(1);
|
||||
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const nodes = prefixNodes[hex] || [];
|
||||
const count = nodes.length;
|
||||
let bg, color;
|
||||
if (count === 0) {
|
||||
bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle
|
||||
} else if (count === 1) {
|
||||
bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision
|
||||
} else {
|
||||
// 2+ nodes: orange→red
|
||||
const t = Math.min((count - 2) / 4, 1);
|
||||
const r = Math.round(220 + 35 * t);
|
||||
const g = Math.round(120 * (1 - t));
|
||||
bg = `rgb(${r},${g},30)`; color = '#fff';
|
||||
}
|
||||
const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`;
|
||||
const cellText = count === 0 ? `<span style="font-size:11px">${hex}</span>` : count >= 2 ? `<strong>${count >= 3 ? '3+' : count}</strong>` : String(count);
|
||||
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:13px;font-weight:${count >= 2 ? '700' : '400'}" title="0x${hex}: ${status}">${cellText}</td>`;
|
||||
}
|
||||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 1-byte ID</div>
|
||||
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
|
||||
<span><span class="legend-swatch" style="background:var(--card-bg);border:1px solid var(--border)"></span> 0 — Available</span>
|
||||
<span><span class="legend-swatch" style="background:#dcfce7"></span> 1 — One node</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> 2 — Two nodes (collision)</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> 3+ — Three+ nodes (collision)</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
// Click handler for cells
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const matches = prefixNodes[hex] || [];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!matches.length) {
|
||||
detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`;
|
||||
return;
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const nodes = prefixNodes[hex] || [];
|
||||
const count = nodes.length;
|
||||
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
|
||||
const isCollision = count >= 2 && repeaterCount >= 2;
|
||||
const isPossible = count >= 2 && !isCollision;
|
||||
let cellClass, bgStyle;
|
||||
if (count === 0) { cellClass = 'hash-cell-empty'; bgStyle = ''; }
|
||||
else if (count === 1) { cellClass = 'hash-cell-taken'; bgStyle = ''; }
|
||||
else if (isPossible) { cellClass = 'hash-cell-possible'; bgStyle = ''; }
|
||||
else { const t = Math.min((count - 2) / 4, 1); bgStyle = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass = 'hash-cell-collision'; }
|
||||
const nodeLabel = m => `<div style="font-size:11px">${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' <span style="opacity:0.7">(unknown role)</span>' : ''}</div>`;
|
||||
const tip1 = count === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">Available</div>`
|
||||
: count === 1
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">One node — no collision</div><div class="hash-matrix-tooltip-nodes">${nodeLabel(nodes[0])}</div>`
|
||||
: isPossible
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
|
||||
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
|
||||
}
|
||||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||||
`<div style="margin-top:8px">${matches.map(m => {
|
||||
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
: '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
|
||||
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
|
||||
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
|
||||
}).join('')}</div>`;
|
||||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||||
td.classList.add('hash-selected');
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
|
||||
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
|
||||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
initMatrixTooltip(el);
|
||||
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const matches = prefixNodes[hex] || [];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
|
||||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||||
`<div style="margin-top:8px">${matches.map(m => {
|
||||
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>` : '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
|
||||
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
|
||||
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
|
||||
}).join('')}</div>`;
|
||||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||||
td.classList.add('hash-selected');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
} else if (bytes === 2) {
|
||||
// 2-byte mode: 16×16 grid of first-byte groups
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
|
||||
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
|
||||
|
||||
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
|
||||
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
|
||||
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
|
||||
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
|
||||
|
||||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 2-byte ID</div>
|
||||
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
|
||||
const nodeCount = info.groupNodes.length;
|
||||
const maxCol = info.maxCollision;
|
||||
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
|
||||
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
|
||||
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
|
||||
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
|
||||
let cellClass2, bgStyle2;
|
||||
if (nodeCount === 0) { cellClass2 = 'hash-cell-empty'; bgStyle2 = ''; }
|
||||
else if (maxCol === 0) { cellClass2 = 'hash-cell-taken'; bgStyle2 = ''; }
|
||||
else if (hasPossible) { cellClass2 = 'hash-cell-possible'; bgStyle2 = ''; }
|
||||
else { const t = Math.min((maxCol - 2) / 4, 1); bgStyle2 = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass2 = 'hash-cell-collision'; }
|
||||
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
|
||||
const tip2 = nodeCount === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
|
||||
: info.collisionCount === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
|
||||
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
|
||||
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
|
||||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const info = firstByteInfo[hex];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
|
||||
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
|
||||
if (info.collisionCount === 0) {
|
||||
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
|
||||
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
|
||||
const prefix = m.public_key.slice(0,4).toUpperCase();
|
||||
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
|
||||
}).join('')}</div>`;
|
||||
} else {
|
||||
dhtml += `<div style="margin-top:8px">`;
|
||||
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
|
||||
const isCollision = nodes.length > 1;
|
||||
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
|
||||
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
|
||||
dhtml += nodes.map(m => `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link" style="font-size:0.85em">${esc(m.name || m.public_key.slice(0,12))}</a>`).join(', ');
|
||||
dhtml += `</div>`;
|
||||
}
|
||||
dhtml += '</div>';
|
||||
}
|
||||
detail.innerHTML = dhtml;
|
||||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||||
td.classList.add('hash-selected');
|
||||
});
|
||||
});
|
||||
|
||||
initMatrixTooltip(el);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCollisions(topHops, allNodes) {
|
||||
async function renderCollisions(topHops, allNodes, bytes) {
|
||||
bytes = bytes || 1;
|
||||
const el = document.getElementById('collisionList');
|
||||
const oneByteHops = topHops.filter(h => h.size === 1);
|
||||
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
|
||||
const hopsForSize = topHops.filter(h => h.size === bytes);
|
||||
|
||||
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
|
||||
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
|
||||
|
||||
if (!hopsToCheck.length && bytes === 1) {
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nodes = allNodes;
|
||||
const collisions = [];
|
||||
for (const hop of oneByteHops) {
|
||||
for (const hop of hopsToCheck) {
|
||||
const prefix = hop.hex.toLowerCase();
|
||||
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
|
||||
if (matches.length > 1) {
|
||||
@@ -1116,14 +1436,27 @@
|
||||
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
|
||||
}
|
||||
}
|
||||
if (!collisions.length) { el.innerHTML = '<div class="text-muted" style="padding:8px">No collisions detected</div>'; return; }
|
||||
|
||||
if (!collisions.length) {
|
||||
const cleanMsg = bytes === 3
|
||||
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
|
||||
: `✅ No ${bytes}-byte collisions detected`;
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: local first (most likely to collide), then regional, distant, incomplete
|
||||
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
|
||||
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
|
||||
|
||||
const showAppearances = bytes < 3;
|
||||
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">Prefix</th>
|
||||
${showAppearances ? '<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') {
|
||||
@@ -1142,12 +1475,12 @@
|
||||
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
|
||||
return `<tr>
|
||||
<td class="mono">${c.hop}</td>
|
||||
<td>${c.count.toLocaleString()}</td>
|
||||
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
|
||||
<td>${distStr}</td>
|
||||
<td title="${tooltip}">${badge}</td>
|
||||
<td>${c.matches.map(m => {
|
||||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
|
||||
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
|
||||
}).join('<br>')}</td>
|
||||
@@ -1179,7 +1512,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 +1767,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 +1785,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 +1800,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 +1815,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 +1830,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 +1862,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 +1882,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 +1896,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>` : '—';
|
||||
@@ -1609,6 +1942,9 @@ function destroy() { _analyticsData = {}; _channelData = null; }
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
window._analyticsChannelTbodyHtml = channelTbodyHtml;
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
|
||||
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
|
||||
window._analyticsBuildCollisionHops = buildCollisionHops;
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
|
||||
+161
-12
@@ -1,4 +1,4 @@
|
||||
/* === MeshCore Analyzer — app.js === */
|
||||
/* === CoreScope — app.js === */
|
||||
'use strict';
|
||||
|
||||
// --- Route/Payload name maps ---
|
||||
@@ -88,11 +88,116 @@ window.apiPerf = function() {
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '—';
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
const ms = new Date(iso).getTime();
|
||||
if (!isFinite(ms)) return '—';
|
||||
const s = Math.floor((Date.now() - ms) / 1000);
|
||||
const abs = Math.abs(s);
|
||||
let value;
|
||||
let suffix;
|
||||
if (abs < 60) { value = abs; suffix = 's'; }
|
||||
else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; }
|
||||
else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; }
|
||||
else { value = Math.floor(abs / 86400); suffix = 'd'; }
|
||||
if (s < 0) return 'in ' + value + suffix;
|
||||
return value + suffix + ' ago';
|
||||
}
|
||||
|
||||
function getTimestampMode() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-mode');
|
||||
if (saved === 'ago' || saved === 'absolute') return saved;
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.defaultMode;
|
||||
return serverDefault === 'absolute' ? 'absolute' : 'ago';
|
||||
}
|
||||
|
||||
function getTimestampTimezone() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-timezone');
|
||||
if (saved === 'utc' || saved === 'local') return saved;
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.timezone;
|
||||
return serverDefault === 'utc' ? 'utc' : 'local';
|
||||
}
|
||||
|
||||
function getTimestampFormatPreset() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-format');
|
||||
if (saved === 'iso' || saved === 'iso-seconds' || saved === 'locale') return saved;
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.formatPreset;
|
||||
return (serverDefault === 'iso' || serverDefault === 'iso-seconds' || serverDefault === 'locale') ? serverDefault : 'iso';
|
||||
}
|
||||
|
||||
function getTimestampCustomFormat() {
|
||||
if (window.SITE_CONFIG?.timestamps?.allowCustomFormat !== true) return '';
|
||||
const saved = localStorage.getItem('meshcore-timestamp-custom-format');
|
||||
if (saved != null) return String(saved);
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.customFormat;
|
||||
return serverDefault == null ? '' : String(serverDefault);
|
||||
}
|
||||
|
||||
function pad2(v) { return String(v).padStart(2, '0'); }
|
||||
function pad3(v) { return String(v).padStart(3, '0'); }
|
||||
|
||||
function formatIsoLike(d, timezone, includeMs) {
|
||||
const useUtc = timezone === 'utc';
|
||||
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
|
||||
const month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1;
|
||||
const day = useUtc ? d.getUTCDate() : d.getDate();
|
||||
const hour = useUtc ? d.getUTCHours() : d.getHours();
|
||||
const minute = useUtc ? d.getUTCMinutes() : d.getMinutes();
|
||||
const second = useUtc ? d.getUTCSeconds() : d.getSeconds();
|
||||
const ms = useUtc ? d.getUTCMilliseconds() : d.getMilliseconds();
|
||||
let out = year + '-' + pad2(month) + '-' + pad2(day) + ' ' + pad2(hour) + ':' + pad2(minute) + ':' + pad2(second);
|
||||
if (includeMs) out += '.' + pad3(ms);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatTimestampCustom(d, formatString, timezone) {
|
||||
if (!/YYYY|MM|DD|HH|mm|ss|SSS|Z/.test(String(formatString))) return '';
|
||||
const useUtc = timezone === 'utc';
|
||||
const replacements = {
|
||||
YYYY: String(useUtc ? d.getUTCFullYear() : d.getFullYear()),
|
||||
MM: pad2((useUtc ? d.getUTCMonth() : d.getMonth()) + 1),
|
||||
DD: pad2(useUtc ? d.getUTCDate() : d.getDate()),
|
||||
HH: pad2(useUtc ? d.getUTCHours() : d.getHours()),
|
||||
mm: pad2(useUtc ? d.getUTCMinutes() : d.getMinutes()),
|
||||
ss: pad2(useUtc ? d.getUTCSeconds() : d.getSeconds()),
|
||||
SSS: pad3(useUtc ? d.getUTCMilliseconds() : d.getMilliseconds()),
|
||||
Z: (timezone === 'utc' ? 'UTC' : 'local')
|
||||
};
|
||||
return String(formatString).replace(/YYYY|MM|DD|HH|mm|ss|SSS|Z/g, token => replacements[token] || token);
|
||||
}
|
||||
|
||||
function formatAbsoluteTimestamp(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (!isFinite(d.getTime())) return '—';
|
||||
const timezone = getTimestampTimezone();
|
||||
const preset = getTimestampFormatPreset();
|
||||
const customFormat = getTimestampCustomFormat().trim();
|
||||
if (customFormat) {
|
||||
const customOut = formatTimestampCustom(d, customFormat, timezone);
|
||||
if (customOut && !/Invalid Date|NaN|undefined|null/.test(customOut)) return customOut;
|
||||
}
|
||||
if (preset === 'iso-seconds') return formatIsoLike(d, timezone, true);
|
||||
if (preset === 'locale') {
|
||||
if (timezone === 'utc') return d.toLocaleString([], { timeZone: 'UTC' });
|
||||
return d.toLocaleString();
|
||||
}
|
||||
return formatIsoLike(d, timezone, false);
|
||||
}
|
||||
|
||||
function formatTimestamp(isoString, mode) {
|
||||
return formatTimestampWithTooltip(isoString, mode).text;
|
||||
}
|
||||
|
||||
function formatTimestampWithTooltip(isoString, mode) {
|
||||
if (!isoString) return { text: '—', tooltip: '—', isFuture: false };
|
||||
const d = new Date(isoString);
|
||||
if (!isFinite(d.getTime())) return { text: '—', tooltip: '—', isFuture: false };
|
||||
const activeMode = mode === 'absolute' || mode === 'ago' ? mode : getTimestampMode();
|
||||
const isFuture = d.getTime() > Date.now();
|
||||
const absolute = formatAbsoluteTimestamp(isoString);
|
||||
const relative = timeAgo(isoString);
|
||||
const text = isFuture ? absolute : (activeMode === 'absolute' ? absolute : relative);
|
||||
const tooltip = isFuture ? relative : (activeMode === 'absolute' ? relative : absolute);
|
||||
return { text, tooltip, isFuture };
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
@@ -109,7 +214,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;
|
||||
@@ -347,6 +452,9 @@ window.addEventListener('theme-changed', () => {
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
}, 300);
|
||||
});
|
||||
window.addEventListener('timestamp-mode-changed', () => {
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
|
||||
@@ -523,21 +631,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 +655,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)
|
||||
|
||||
@@ -569,7 +711,14 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
window.SITE_CONFIG = cfg;
|
||||
window.SITE_CONFIG = cfg || {};
|
||||
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
|
||||
const tsCfg = window.SITE_CONFIG.timestamps;
|
||||
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
|
||||
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
|
||||
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
|
||||
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
|
||||
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
@@ -643,7 +792,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
|
||||
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
+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 () {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user