mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 13:27:21 +00:00
Compare commits
26 Commits
fix/remove
...
fix/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5516c282 | ||
|
|
206d9bd64a | ||
|
|
3f54632b07 | ||
|
|
609b12541e | ||
|
|
4369e58a3c | ||
|
|
8ef321bf70 | ||
|
|
bee705d5d8 | ||
|
|
cedf79ff83 | ||
|
|
9944d50e76 | ||
|
|
9b2ad91512 | ||
|
|
6740e53c18 | ||
|
|
b2e5b66f25 | ||
|
|
45b82ad390 | ||
|
|
d538d2f3e7 | ||
|
|
746f7f2733 | ||
|
|
a1a67e89fb | ||
|
|
91fcbc5adc | ||
|
|
5f5eae07b0 | ||
|
|
380b1b1e28 | ||
|
|
03cfd114da | ||
|
|
df90de77a7 | ||
|
|
7b97c532a1 | ||
|
|
e0c2d37041 | ||
|
|
f5d0ce066b | ||
|
|
1453fb6492 | ||
|
|
5cc6064e11 |
39
.env.example
39
.env.example
@@ -1,17 +1,44 @@
|
|||||||
# MeshCore Analyzer — Environment Configuration
|
# 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:
|
# Each environment keeps config + data together in one directory:
|
||||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||||
|
|
||||||
# --- Production ---
|
# --- Production ---
|
||||||
PROD_HTTP_PORT=80
|
# Data directory (database, theme, etc.)
|
||||||
PROD_HTTPS_PORT=443
|
# Default: ~/meshcore-data
|
||||||
PROD_MQTT_PORT=1883
|
# Used by: docker compose, manage.sh
|
||||||
PROD_DATA_DIR=~/meshcore-data
|
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 only, no HTTPS) ---
|
||||||
STAGING_HTTP_PORT=81
|
# Data directory
|
||||||
STAGING_MQTT_PORT=1884
|
# Default: ~/meshcore-staging-data
|
||||||
|
# Used by: docker compose
|
||||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
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
|
||||||
|
|||||||
26
.github/workflows/deploy.yml
vendored
26
.github/workflows/deploy.yml
vendored
@@ -17,7 +17,7 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy
|
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -41,10 +41,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Go 1.22
|
- name: Set up Go 1.22
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.22'
|
go-version: '1.22'
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Go coverage badges
|
- name: Upload Go coverage badges
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: go-badges
|
name: go-badges
|
||||||
path: .badges/go-*.json
|
path: .badges/go-*.json
|
||||||
@@ -139,12 +139,12 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Set up Node.js 22
|
- name: Set up Node.js 22
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Node.js test badges
|
- name: Upload Node.js test badges
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: node-badges
|
name: node-badges
|
||||||
path: .badges/
|
path: .badges/
|
||||||
@@ -282,10 +282,10 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Node.js 22
|
- name: Set up Node.js 22
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Start staging on port 82
|
- name: Start staging on port 82
|
||||||
run: |
|
run: |
|
||||||
@@ -352,18 +352,18 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download Go coverage badges
|
- name: Download Go coverage badges
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: go-badges
|
name: go-badges
|
||||||
path: .badges/
|
path: .badges/
|
||||||
|
|
||||||
- name: Download Node.js test badges
|
- name: Download Node.js test badges
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: node-badges
|
name: node-badges
|
||||||
path: .badges/
|
path: .badges/
|
||||||
|
|||||||
144
RELEASE-v3.1.0.md
Normal file
144
RELEASE-v3.1.0.md
Normal file
@@ -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)*
|
||||||
@@ -26,13 +26,14 @@ type MQTTLegacy struct {
|
|||||||
|
|
||||||
// Config holds the ingestor configuration, compatible with the Node.js config.json format.
|
// Config holds the ingestor configuration, compatible with the Node.js config.json format.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DBPath string `json:"dbPath"`
|
DBPath string `json:"dbPath"`
|
||||||
MQTT *MQTTLegacy `json:"mqtt,omitempty"`
|
MQTT *MQTTLegacy `json:"mqtt,omitempty"`
|
||||||
MQTTSources []MQTTSource `json:"mqttSources,omitempty"`
|
MQTTSources []MQTTSource `json:"mqttSources,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
|
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
|
||||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||||
Retention *RetentionConfig `json:"retention,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.
|
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||||
|
|||||||
@@ -512,34 +512,64 @@ func firstNonEmpty(vals ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deriveHashtagChannelKey derives an AES-128 key from a channel name.
|
||||||
|
// Same algorithm as Node.js: SHA-256(channelName) → first 32 hex chars (16 bytes).
|
||||||
|
func deriveHashtagChannelKey(channelName string) string {
|
||||||
|
h := sha256.Sum256([]byte(channelName))
|
||||||
|
return hex.EncodeToString(h[:16])
|
||||||
|
}
|
||||||
|
|
||||||
// loadChannelKeys loads channel decryption keys from config and/or a JSON file.
|
// loadChannelKeys loads channel decryption keys from config and/or a JSON file.
|
||||||
// Priority: CHANNEL_KEYS_PATH env var > cfg.ChannelKeysPath > channel-rainbow.json next to config.
|
// Merge priority: rainbow (lowest) → derived from hashChannels → explicit config (highest).
|
||||||
func loadChannelKeys(cfg *Config, configPath string) map[string]string {
|
func loadChannelKeys(cfg *Config, configPath string) map[string]string {
|
||||||
keys := make(map[string]string)
|
keys := make(map[string]string)
|
||||||
|
|
||||||
// Determine file path for rainbow keys
|
// 1. Rainbow table keys (lowest priority)
|
||||||
keysPath := os.Getenv("CHANNEL_KEYS_PATH")
|
keysPath := os.Getenv("CHANNEL_KEYS_PATH")
|
||||||
if keysPath == "" {
|
if keysPath == "" {
|
||||||
keysPath = cfg.ChannelKeysPath
|
keysPath = cfg.ChannelKeysPath
|
||||||
}
|
}
|
||||||
if keysPath == "" {
|
if keysPath == "" {
|
||||||
// Default: look for channel-rainbow.json next to config file
|
|
||||||
keysPath = filepath.Join(filepath.Dir(configPath), "channel-rainbow.json")
|
keysPath = filepath.Join(filepath.Dir(configPath), "channel-rainbow.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rainbowCount := 0
|
||||||
if data, err := os.ReadFile(keysPath); err == nil {
|
if data, err := os.ReadFile(keysPath); err == nil {
|
||||||
var fileKeys map[string]string
|
var fileKeys map[string]string
|
||||||
if err := json.Unmarshal(data, &fileKeys); err == nil {
|
if err := json.Unmarshal(data, &fileKeys); err == nil {
|
||||||
for k, v := range fileKeys {
|
for k, v := range fileKeys {
|
||||||
keys[k] = v
|
keys[k] = v
|
||||||
}
|
}
|
||||||
log.Printf("Loaded %d channel keys from %s", len(fileKeys), keysPath)
|
rainbowCount = len(fileKeys)
|
||||||
|
log.Printf("Loaded %d channel keys from %s", rainbowCount, keysPath)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Warning: failed to parse channel keys file %s: %v", keysPath, err)
|
log.Printf("Warning: failed to parse channel keys file %s: %v", keysPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge inline config keys (override file keys)
|
// 2. Derived keys from hashChannels (middle priority)
|
||||||
|
derivedCount := 0
|
||||||
|
for _, raw := range cfg.HashChannels {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channelName := trimmed
|
||||||
|
if !strings.HasPrefix(channelName, "#") {
|
||||||
|
channelName = "#" + channelName
|
||||||
|
}
|
||||||
|
// Skip if explicit config already has this key
|
||||||
|
if _, exists := cfg.ChannelKeys[channelName]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys[channelName] = deriveHashtagChannelKey(channelName)
|
||||||
|
derivedCount++
|
||||||
|
}
|
||||||
|
if derivedCount > 0 {
|
||||||
|
log.Printf("[channels] %d derived from hashChannels", derivedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Explicit config keys (highest priority — overrides rainbow + derived)
|
||||||
for k, v := range cfg.ChannelKeys {
|
for k, v := range cfg.ChannelKeys {
|
||||||
keys[k] = v
|
keys[k] = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ func setupTestDBv2(t *testing.T) *DB {
|
|||||||
observer_id TEXT, observer_name TEXT, direction TEXT,
|
observer_id TEXT, observer_name TEXT, direction TEXT,
|
||||||
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL
|
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE VIEW packets_v AS
|
|
||||||
SELECT o.id, t.raw_hex,
|
|
||||||
strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp,
|
|
||||||
o.observer_id, o.observer_name,
|
|
||||||
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
|
|
||||||
t.payload_type, t.payload_version, o.path_json, t.decoded_json, t.created_at
|
|
||||||
FROM observations o
|
|
||||||
JOIN transmissions t ON t.id = o.transmission_id;
|
|
||||||
`
|
`
|
||||||
if _, err := conn.Exec(schema); err != nil {
|
if _, err := conn.Exec(schema); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -551,8 +543,8 @@ func TestHandlePacketDetailNoStore(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/packets/abc123def4567890", nil)
|
req := httptest.NewRequest("GET", "/api/packets/abc123def4567890", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
if w.Code != 200 {
|
if w.Code != 404 {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 404 (no store), got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -560,8 +552,8 @@ func TestHandlePacketDetailNoStore(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/packets/1", nil)
|
req := httptest.NewRequest("GET", "/api/packets/1", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
if w.Code != 200 {
|
if w.Code != 404 {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 404 (no store), got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1475,8 +1467,8 @@ func TestHandleObserverAnalyticsNoStore(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
|
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
if w.Code != 200 {
|
if w.Code != 503 {
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 503, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3272,20 +3264,6 @@ func TestHandlePacketDetailWithStoreAllPaths(t *testing.T) {
|
|||||||
|
|
||||||
// --- Additional DB function coverage ---
|
// --- Additional DB function coverage ---
|
||||||
|
|
||||||
func TestDBGetTimestamps(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
ts, err := db.GetTimestamps("2000-01-01")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(ts) < 1 {
|
|
||||||
t.Error("expected >=1 timestamps")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDBGetNewTransmissionsSince(t *testing.T) {
|
func TestDBGetNewTransmissionsSince(t *testing.T) {
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|||||||
221
cmd/server/db.go
221
cmd/server/db.go
@@ -120,14 +120,14 @@ func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} {
|
|||||||
|
|
||||||
// Node represents a row from the nodes table.
|
// Node represents a row from the nodes table.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
Lat *float64 `json:"lat"`
|
Lat *float64 `json:"lat"`
|
||||||
Lon *float64 `json:"lon"`
|
Lon *float64 `json:"lon"`
|
||||||
LastSeen *string `json:"last_seen"`
|
LastSeen *string `json:"last_seen"`
|
||||||
FirstSeen *string `json:"first_seen"`
|
FirstSeen *string `json:"first_seen"`
|
||||||
AdvertCount int `json:"advert_count"`
|
AdvertCount int `json:"advert_count"`
|
||||||
BatteryMv *int `json:"battery_mv"`
|
BatteryMv *int `json:"battery_mv"`
|
||||||
TemperatureC *float64 `json:"temperature_c"`
|
TemperatureC *float64 `json:"temperature_c"`
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ type Transmission struct {
|
|||||||
CreatedAt *string `json:"created_at"`
|
CreatedAt *string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observation (from packets_v view).
|
// Observation (observation-level data).
|
||||||
type Observation struct {
|
type Observation struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
RawHex *string `json:"raw_hex"`
|
RawHex *string `json:"raw_hex"`
|
||||||
@@ -435,7 +435,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
|||||||
w = "WHERE " + strings.Join(where, " AND ")
|
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
|
var total int
|
||||||
if len(where) == 0 {
|
if len(where) == 0 {
|
||||||
db.conn.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&total)
|
db.conn.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&total)
|
||||||
@@ -628,18 +628,6 @@ func (db *DB) resolveNodePubkey(nodeIDOrName string) string {
|
|||||||
return pk
|
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.
|
// GetTransmissionByID fetches from transmissions table with observer data.
|
||||||
func (db *DB) GetTransmissionByID(id int) (map[string]interface{}, error) {
|
func (db *DB) GetTransmissionByID(id int) (map[string]interface{}, error) {
|
||||||
@@ -673,24 +661,6 @@ func (db *DB) GetPacketByHash(hash string) (map[string]interface{}, error) {
|
|||||||
return nil, nil
|
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.
|
// 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) {
|
func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortBy, region string) ([]map[string]interface{}, int, map[string]int, error) {
|
||||||
@@ -798,30 +768,6 @@ func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
|
|||||||
return nil, nil
|
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).
|
// 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) {
|
func (db *DB) GetRecentTransmissionsForNode(pubkey string, name string, limit int) ([]map[string]interface{}, error) {
|
||||||
@@ -1045,103 +991,6 @@ func (db *DB) GetDistinctIATAs() ([]string, error) {
|
|||||||
return codes, nil
|
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.
|
// GetNetworkStatus returns overall network health status.
|
||||||
func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]interface{}, error) {
|
func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]interface{}, error) {
|
||||||
@@ -1190,10 +1039,28 @@ func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]in
|
|||||||
}, nil
|
}, 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) {
|
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
|
var querySQL string
|
||||||
FROM packets_v WHERE hash = ? ORDER BY timestamp ASC`, strings.ToLower(hash))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1219,7 +1086,7 @@ func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetChannels returns channel list from GRP_TXT packets.
|
// 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
|
// duplicates that could cause stale lastMessage when an older message has
|
||||||
// a later re-observation timestamp.
|
// a later re-observation timestamp.
|
||||||
func (db *DB) GetChannels() ([]map[string]interface{}, error) {
|
func (db *DB) GetChannels() ([]map[string]interface{}, error) {
|
||||||
@@ -1435,31 +1302,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int) ([]map[s
|
|||||||
return messages, total, nil
|
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.
|
// GetNewTransmissionsSince returns new transmissions after a given ID for WebSocket polling.
|
||||||
func (db *DB) GetNewTransmissionsSince(lastID int, limit int) ([]map[string]interface{}, error) {
|
func (db *DB) GetNewTransmissionsSince(lastID int, limit int) ([]map[string]interface{}, error) {
|
||||||
|
|||||||
@@ -73,16 +73,6 @@ func setupTestDB(t *testing.T) *DB {
|
|||||||
timestamp INTEGER NOT NULL
|
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 {
|
if _, err := conn.Exec(schema); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -569,51 +559,6 @@ func TestGetNewTransmissionsSince(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetObservationsForHash(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
obs, err := db.GetObservationsForHash("abc123def4567890")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(obs) != 2 {
|
|
||||||
t.Errorf("expected 2 observations, got %d", len(obs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetPacketByIDFound(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
pkt, err := db.GetPacketByID(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if pkt == nil {
|
|
||||||
t.Fatal("expected packet, got nil")
|
|
||||||
}
|
|
||||||
if pkt["hash"] != "abc123def4567890" {
|
|
||||||
t.Errorf("expected hash abc123def4567890, got %v", pkt["hash"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetPacketByIDNotFound(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
pkt, err := db.GetPacketByID(9999)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if pkt != nil {
|
|
||||||
t.Error("expected nil for nonexistent packet ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetTransmissionByIDFound(t *testing.T) {
|
func TestGetTransmissionByIDFound(t *testing.T) {
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -656,34 +601,6 @@ func TestGetPacketByHashNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRecentPacketsForNode(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 20)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(packets) == 0 {
|
|
||||||
t.Error("expected packets for TestRepeater")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRecentPacketsForNodeDefaultLimit(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if packets == nil {
|
|
||||||
t.Error("expected non-nil result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetObserverIdsForRegion(t *testing.T) {
|
func TestGetObserverIdsForRegion(t *testing.T) {
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -733,46 +650,6 @@ func TestGetObserverIdsForRegion(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetNodeHealth(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
t.Run("found", func(t *testing.T) {
|
|
||||||
result, err := db.GetNodeHealth("aabbccdd11223344")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected result, got nil")
|
|
||||||
}
|
|
||||||
node, ok := result["node"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected node object")
|
|
||||||
}
|
|
||||||
if node["name"] != "TestRepeater" {
|
|
||||||
t.Errorf("expected TestRepeater, got %v", node["name"])
|
|
||||||
}
|
|
||||||
stats, ok := result["stats"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected stats object")
|
|
||||||
}
|
|
||||||
if stats["totalPackets"] == nil {
|
|
||||||
t.Error("expected totalPackets in stats")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("not found", func(t *testing.T) {
|
|
||||||
result, err := db.GetNodeHealth("nonexistent")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
t.Error("expected nil for nonexistent node")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetChannelMessages(t *testing.T) {
|
func TestGetChannelMessages(t *testing.T) {
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -815,48 +692,6 @@ func TestGetChannelMessages(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTimestamps(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
t.Run("with results", func(t *testing.T) {
|
|
||||||
ts, err := db.GetTimestamps("2020-01-01")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(ts) == 0 {
|
|
||||||
t.Error("expected timestamps")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no results", func(t *testing.T) {
|
|
||||||
ts, err := db.GetTimestamps("2099-01-01")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(ts) != 0 {
|
|
||||||
t.Errorf("expected 0 timestamps, got %d", len(ts))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetObservationCount(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
count := db.GetObservationCount("abc123def4567890")
|
|
||||||
if count != 2 {
|
|
||||||
t.Errorf("expected 2, got %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
count = db.GetObservationCount("nonexistent")
|
|
||||||
if count != 0 {
|
|
||||||
t.Errorf("expected 0 for nonexistent, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPacketWhereFilters(t *testing.T) {
|
func TestBuildPacketWhereFilters(t *testing.T) {
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -1280,29 +1115,6 @@ func TestOpenDBInvalidPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetNodeHealthNoName(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// Insert a node without a name
|
|
||||||
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
|
|
||||||
db.conn.Exec(`INSERT INTO nodes (public_key, role, last_seen, first_seen, advert_count)
|
|
||||||
VALUES ('deadbeef12345678', 'repeater', '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 5)`)
|
|
||||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
||||||
VALUES ('DDEE', 'deadbeefhash1234', '2026-01-15T10:05:00Z', 1, 4,
|
|
||||||
'{"pubKey":"deadbeef12345678","type":"ADVERT"}')`)
|
|
||||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
|
||||||
VALUES (1, 1, 11.0, -91, '["dd"]', 1736935500)`)
|
|
||||||
|
|
||||||
result, err := db.GetNodeHealth("deadbeef12345678")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected result, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetChannelMessagesObserverFallback(t *testing.T) {
|
func TestGetChannelMessagesObserverFallback(t *testing.T) {
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -1383,20 +1195,6 @@ func TestQueryGroupedPacketsWithFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTracesEmpty(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedTestData(t, db)
|
|
||||||
|
|
||||||
traces, err := db.GetTraces("nonexistenthash1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(traces) != 0 {
|
|
||||||
t.Errorf("expected 0 traces, got %d", len(traces))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNullHelpers(t *testing.T) {
|
func TestNullHelpers(t *testing.T) {
|
||||||
// nullStr
|
// nullStr
|
||||||
if nullStr(sql.NullString{Valid: false}) != nil {
|
if nullStr(sql.NullString{Valid: false}) != nil {
|
||||||
@@ -1474,9 +1272,11 @@ func TestNodeTelemetryFields(t *testing.T) {
|
|||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer db.Close()
|
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)
|
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)`)
|
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")
|
node, err := db.GetNodeByPubkey("pk_telem1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1491,6 +1291,7 @@ func TestNodeTelemetryFields(t *testing.T) {
|
|||||||
t.Errorf("temperature_c=%v, want 28.5", node["temperature_c"])
|
t.Errorf("temperature_c=%v, want 28.5", node["temperature_c"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test via GetNodes
|
||||||
nodes, _, _, err := db.GetNodes(50, 0, "sensor", "", "", "", "", "")
|
nodes, _, _, err := db.GetNodes(50, 0, "sensor", "", "", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1502,6 +1303,7 @@ func TestNodeTelemetryFields(t *testing.T) {
|
|||||||
t.Errorf("GetNodes battery_mv=%v, want 3700", nodes[0]["battery_mv"])
|
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)
|
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)`)
|
VALUES ('pk_notelem', 'PlainNode', 'repeater', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 3)`)
|
||||||
node2, _ := db.GetNodeByPubkey("pk_notelem")
|
node2, _ := db.GetNodeByPubkey("pk_notelem")
|
||||||
|
|||||||
1096
cmd/server/routes.go
1096
cmd/server/routes.go
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,9 @@ func setupTestServer(t *testing.T) (*Server, *mux.Router) {
|
|||||||
hub := NewHub()
|
hub := NewHub()
|
||||||
srv := NewServer(db, cfg, hub)
|
srv := NewServer(db, cfg, hub)
|
||||||
store := NewPacketStore(db)
|
store := NewPacketStore(db)
|
||||||
store.Load()
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store.Load failed: %v", err)
|
||||||
|
}
|
||||||
srv.store = store
|
srv.store = store
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
srv.RegisterRoutes(router)
|
||||||
@@ -722,6 +724,9 @@ func TestNodePathsFound(t *testing.T) {
|
|||||||
if body["paths"] == nil {
|
if body["paths"] == nil {
|
||||||
t.Error("expected paths in response")
|
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) {
|
func TestNodePathsNotFound(t *testing.T) {
|
||||||
@@ -832,6 +837,9 @@ func TestObserverAnalytics(t *testing.T) {
|
|||||||
if body["recentPackets"] == nil {
|
if body["recentPackets"] == nil {
|
||||||
t.Error("expected recentPackets")
|
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) {
|
t.Run("custom days", func(t *testing.T) {
|
||||||
@@ -1251,6 +1259,11 @@ func TestNodeAnalyticsNoNameNode(t *testing.T) {
|
|||||||
cfg := &Config{Port: 3000}
|
cfg := &Config{Port: 3000}
|
||||||
hub := NewHub()
|
hub := NewHub()
|
||||||
srv := NewServer(db, cfg, hub)
|
srv := NewServer(db, cfg, hub)
|
||||||
|
store := NewPacketStore(db)
|
||||||
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store.Load failed: %v", err)
|
||||||
|
}
|
||||||
|
srv.store = store
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
srv.RegisterRoutes(router)
|
||||||
|
|
||||||
@@ -1282,6 +1295,11 @@ func TestNodeHealthForNoNameNode(t *testing.T) {
|
|||||||
cfg := &Config{Port: 3000}
|
cfg := &Config{Port: 3000}
|
||||||
hub := NewHub()
|
hub := NewHub()
|
||||||
srv := NewServer(db, cfg, hub)
|
srv := NewServer(db, cfg, hub)
|
||||||
|
store := NewPacketStore(db)
|
||||||
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store.Load failed: %v", err)
|
||||||
|
}
|
||||||
|
srv.store = store
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
srv.RegisterRoutes(router)
|
||||||
|
|
||||||
@@ -1521,8 +1539,6 @@ func TestHandlerErrorPaths(t *testing.T) {
|
|||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
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) {
|
t.Run("stats error", func(t *testing.T) {
|
||||||
db.conn.Exec("DROP TABLE IF EXISTS transmissions")
|
db.conn.Exec("DROP TABLE IF EXISTS transmissions")
|
||||||
@@ -1563,7 +1579,7 @@ func TestHandlerErrorTraces(t *testing.T) {
|
|||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
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)
|
req := httptest.NewRequest("GET", "/api/traces/abc123def4567890", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -1697,13 +1713,12 @@ func TestHandlerErrorTimestamps(t *testing.T) {
|
|||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
srv.RegisterRoutes(router)
|
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)
|
req := httptest.NewRequest("GET", "/api/packets/timestamps?since=2020-01-01", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
if w.Code != 500 {
|
if w.Code != 200 {
|
||||||
t.Errorf("expected 500 for timestamps error, got %d", w.Code)
|
t.Errorf("expected 200 for timestamps without store, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,8 +1755,8 @@ func TestHandlerErrorBulkHealth(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/nodes/bulk-health", nil)
|
req := httptest.NewRequest("GET", "/api/nodes/bulk-health", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
if w.Code != 500 {
|
if w.Code != 200 {
|
||||||
t.Errorf("expected 500, got %d", w.Code)
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1876,7 +1891,9 @@ func TestGetNodeHashSizeInfoFlipFlop(t *testing.T) {
|
|||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
seedTestData(t, db)
|
seedTestData(t, db)
|
||||||
store := NewPacketStore(db)
|
store := NewPacketStore(db)
|
||||||
store.Load()
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store.Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
|
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
|
||||||
@@ -1934,7 +1951,17 @@ for _, field := range arrayFields {
|
|||||||
if body[field] == nil {
|
if body[field] == nil {
|
||||||
t.Errorf("field %q is null, expected []", field)
|
t.Errorf("field %q is null, expected []", field)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
func TestObserverAnalyticsNoStore(t *testing.T) {
|
||||||
|
_, router := setupNoStoreServer(t)
|
||||||
|
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatalf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ type StoreObs struct {
|
|||||||
type PacketStore struct {
|
type PacketStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
db *DB
|
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
|
byHash map[string]*StoreTx // hash → *StoreTx
|
||||||
byTxID map[int]*StoreTx // transmission_id → *StoreTx
|
byTxID map[int]*StoreTx // transmission_id → *StoreTx
|
||||||
byObsID map[int]*StoreObs // observation_id → *StoreObs
|
byObsID map[int]*StoreObs // observation_id → *StoreObs
|
||||||
@@ -98,6 +98,11 @@ type PacketStore struct {
|
|||||||
// computed during Load() and incrementally updated on ingest.
|
// computed during Load() and incrementally updated on ingest.
|
||||||
distHops []distHopRecord
|
distHops []distHopRecord
|
||||||
distPaths []distPathRecord
|
distPaths []distPathRecord
|
||||||
|
|
||||||
|
// Cached GetNodeHashSizeInfo result — recomputed at most once every 15s
|
||||||
|
hashSizeInfoMu sync.Mutex
|
||||||
|
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
||||||
|
hashSizeInfoAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precomputed distance records for fast analytics aggregation.
|
// Precomputed distance records for fast analytics aggregation.
|
||||||
@@ -176,7 +181,7 @@ func (s *PacketStore) Load() error {
|
|||||||
FROM transmissions t
|
FROM transmissions t
|
||||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
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 {
|
} else {
|
||||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||||
t.payload_type, t.payload_version, t.decoded_json,
|
t.payload_type, t.payload_version, t.decoded_json,
|
||||||
@@ -184,7 +189,7 @@ func (s *PacketStore) Load() error {
|
|||||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
||||||
FROM transmissions t
|
FROM transmissions t
|
||||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
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)
|
rows, err := s.db.conn.Query(loadSQL)
|
||||||
@@ -368,28 +373,32 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
|||||||
results := s.filterPackets(q)
|
results := s.filterPackets(q)
|
||||||
total := len(results)
|
total := len(results)
|
||||||
|
|
||||||
if q.Order == "ASC" {
|
// results is oldest-first (ASC). For DESC (default) read backwards from the tail;
|
||||||
sorted := make([]*StoreTx, len(results))
|
// for ASC read forwards. Both are O(page_size) — no sort copy needed.
|
||||||
copy(sorted, results)
|
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
|
||||||
return sorted[i].FirstSeen < sorted[j].FirstSeen
|
|
||||||
})
|
|
||||||
results = sorted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paginate
|
|
||||||
start := q.Offset
|
start := q.Offset
|
||||||
if start >= len(results) {
|
if start >= total {
|
||||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||||
}
|
}
|
||||||
end := start + q.Limit
|
pageSize := q.Limit
|
||||||
if end > len(results) {
|
if start+pageSize > total {
|
||||||
end = len(results)
|
pageSize = total - start
|
||||||
}
|
}
|
||||||
|
|
||||||
packets := make([]map[string]interface{}, 0, end-start)
|
packets := make([]map[string]interface{}, 0, pageSize)
|
||||||
for _, tx := range results[start:end] {
|
if q.Order == "ASC" {
|
||||||
packets = append(packets, txToMap(tx))
|
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}
|
return &PacketResult{Packets: packets, Total: total}
|
||||||
}
|
}
|
||||||
@@ -719,15 +728,16 @@ func (s *PacketStore) GetTimestamps(since string) []string {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
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
|
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 {
|
if tx.FirstSeen <= since {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
result = append(result, tx.FirstSeen)
|
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 {
|
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||||
result[i], result[j] = result[j], result[i]
|
result[i], result[j] = result[j], result[i]
|
||||||
}
|
}
|
||||||
@@ -777,23 +787,30 @@ func (s *PacketStore) QueryMultiNodePackets(pubkeys []string, limit, offset int,
|
|||||||
|
|
||||||
total := len(filtered)
|
total := len(filtered)
|
||||||
|
|
||||||
if order == "ASC" {
|
// filtered is oldest-first (built by iterating s.packets forward).
|
||||||
sort.Slice(filtered, func(i, j int) bool {
|
// Apply same DESC/ASC pagination logic as QueryPackets.
|
||||||
return filtered[i].FirstSeen < filtered[j].FirstSeen
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if offset >= total {
|
if offset >= total {
|
||||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||||
}
|
}
|
||||||
end := offset + limit
|
pageSize := limit
|
||||||
if end > total {
|
if offset+pageSize > total {
|
||||||
end = total
|
pageSize = total - offset
|
||||||
}
|
}
|
||||||
|
|
||||||
packets := make([]map[string]interface{}, 0, end-offset)
|
packets := make([]map[string]interface{}, 0, pageSize)
|
||||||
for _, tx := range filtered[offset:end] {
|
if order == "ASC" {
|
||||||
packets = append(packets, txToMap(tx))
|
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}
|
return &PacketResult{Packets: packets, Total: total}
|
||||||
}
|
}
|
||||||
@@ -926,15 +943,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
DecodedJSON: r.decodedJSON,
|
DecodedJSON: r.decodedJSON,
|
||||||
}
|
}
|
||||||
s.byHash[r.hash] = tx
|
s.byHash[r.hash] = tx
|
||||||
// Prepend (newest first)
|
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
||||||
s.packets = append([]*StoreTx{tx}, s.packets...)
|
|
||||||
s.byTxID[r.txID] = tx
|
s.byTxID[r.txID] = tx
|
||||||
s.indexByNode(tx)
|
s.indexByNode(tx)
|
||||||
if tx.PayloadType != nil {
|
if tx.PayloadType != nil {
|
||||||
pt := *tx.PayloadType
|
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
|
// 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 {
|
if _, exists := broadcastTxs[r.txID]; !exists {
|
||||||
@@ -1079,8 +1095,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
s.cacheMu.Unlock()
|
s.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[poller] IngestNewFromDB: found %d new txs, maxID %d->%d", len(result), sinceID, newMaxID)
|
|
||||||
|
|
||||||
return result, newMaxID
|
return result, newMaxID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,8 +1277,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
|||||||
s.subpathCache = make(map[string]*cachedResult)
|
s.subpathCache = make(map[string]*cachedResult)
|
||||||
s.cacheMu.Unlock()
|
s.cacheMu.Unlock()
|
||||||
|
|
||||||
log.Printf("[poller] IngestNewObservations: updated %d existing txs, maxObsID %d->%d",
|
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
||||||
len(updatedTxs), sinceObsID, newMaxObsID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMaxObsID
|
return newMaxObsID
|
||||||
@@ -1888,7 +1901,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int)
|
|||||||
msgMap := map[string]*msgEntry{}
|
msgMap := map[string]*msgEntry{}
|
||||||
var msgOrder []string
|
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 decodedMsg struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
@@ -1899,8 +1912,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
grpTxts := s.byPayloadType[5]
|
grpTxts := s.byPayloadType[5]
|
||||||
for i := len(grpTxts) - 1; i >= 0; i-- {
|
for _, tx := range grpTxts {
|
||||||
tx := grpTxts[i]
|
|
||||||
if tx.DecodedJSON == "" {
|
if tx.DecodedJSON == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -3715,8 +3727,26 @@ type hashSizeNodeInfo struct {
|
|||||||
Inconsistent bool
|
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 {
|
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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -4069,13 +4099,13 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
|
|||||||
lhVal = lastHeard
|
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
|
recentLimit := 20
|
||||||
if len(packets) < recentLimit {
|
if len(packets) < recentLimit {
|
||||||
recentLimit = len(packets)
|
recentLimit = len(packets)
|
||||||
}
|
}
|
||||||
recentPackets := make([]map[string]interface{}, 0, recentLimit)
|
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])
|
p := txToMap(packets[i])
|
||||||
delete(p, "observations")
|
delete(p, "observations")
|
||||||
recentPackets = append(recentPackets, p)
|
recentPackets = append(recentPackets, p)
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
# Volume paths unified with manage.sh — see manage.sh lines 9-12, 56-68, 98-113
|
# All container config lives here. manage.sh is just a wrapper around docker compose.
|
||||||
# Override defaults via .env or environment variables.
|
# 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:
|
services:
|
||||||
prod:
|
prod:
|
||||||
|
build: .
|
||||||
image: corescope:latest
|
image: corescope:latest
|
||||||
container_name: corescope-prod
|
container_name: corescope-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
|
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
|
||||||
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
|
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
|
||||||
@@ -24,9 +29,12 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
staging:
|
staging:
|
||||||
|
build: .
|
||||||
image: corescope:latest
|
image: corescope:latest
|
||||||
container_name: corescope-staging
|
container_name: corescope-staging
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "${STAGING_HTTP_PORT:-81}:${STAGING_HTTP_PORT:-81}"
|
- "${STAGING_HTTP_PORT:-81}:${STAGING_HTTP_PORT:-81}"
|
||||||
- "${STAGING_MQTT_PORT:-1884}:1883"
|
- "${STAGING_MQTT_PORT:-1884}:1883"
|
||||||
@@ -55,6 +63,8 @@ services:
|
|||||||
image: corescope-go:latest
|
image: corescope-go:latest
|
||||||
container_name: corescope-staging-go
|
container_name: corescope-staging-go
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
||||||
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
|
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
|
||||||
@@ -76,6 +86,7 @@ services:
|
|||||||
- staging-go
|
- staging-go
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-data-staging:
|
caddy-data-staging:
|
||||||
caddy-data-staging-go:
|
caddy-data-staging-go:
|
||||||
|
|||||||
101
docs/rename-migration.md
Normal file
101
docs/rename-migration.md
Normal file
@@ -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 |
|
||||||
542
manage.sh
542
manage.sh
@@ -2,26 +2,20 @@
|
|||||||
# CoreScope — Setup & Management Helper
|
# CoreScope — Setup & Management Helper
|
||||||
# Usage: ./manage.sh [command]
|
# Usage: ./manage.sh [command]
|
||||||
#
|
#
|
||||||
|
# All container management goes through docker compose.
|
||||||
|
# Container config lives in docker-compose.yml — this script is just a wrapper.
|
||||||
|
#
|
||||||
# Idempotent: safe to cancel and re-run at any point.
|
# Idempotent: safe to cancel and re-run at any point.
|
||||||
# Each step checks what's already done and skips it.
|
# Each step checks what's already done and skips it.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
CONTAINER_NAME="corescope"
|
|
||||||
IMAGE_NAME="corescope"
|
IMAGE_NAME="corescope"
|
||||||
DATA_VOLUME="meshcore-data"
|
|
||||||
CADDY_VOLUME="caddy-data"
|
|
||||||
STATE_FILE=".setup-state"
|
STATE_FILE=".setup-state"
|
||||||
|
|
||||||
# Source .env for port/path overrides (if present)
|
# Source .env for port/path overrides (same file docker compose reads)
|
||||||
[ -f .env ] && set -a && . ./.env && set +a
|
[ -f .env ] && set -a && . ./.env && set +a
|
||||||
|
|
||||||
# Docker Compose mode detection
|
# Resolved paths for prod/staging data (must match docker-compose.yml)
|
||||||
COMPOSE_MODE=false
|
|
||||||
if [ -f docker-compose.yml ]; then
|
|
||||||
COMPOSE_MODE=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolved paths for prod/staging data
|
|
||||||
PROD_DATA="${PROD_DATA_DIR:-$HOME/meshcore-data}"
|
PROD_DATA="${PROD_DATA_DIR:-$HOME/meshcore-data}"
|
||||||
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||||
|
|
||||||
@@ -51,83 +45,6 @@ is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null;
|
|||||||
|
|
||||||
# ─── Helpers ──────────────────────────────────────────────────────────────
|
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Determine the correct data volume/mount args for docker run.
|
|
||||||
# Detects existing host data directories and uses bind mounts if found.
|
|
||||||
get_data_mount_args() {
|
|
||||||
# Check for existing host data directories with a DB file
|
|
||||||
if [ -d "$HOME/meshcore-data" ] && [ -f "$HOME/meshcore-data/meshcore.db" ]; then
|
|
||||||
echo "-v $HOME/meshcore-data:/app/data"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if [ -d "$(pwd)/data" ] && [ -f "$(pwd)/data/meshcore.db" ]; then
|
|
||||||
echo "-v $(pwd)/data:/app/data"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
# Default: Docker named volume
|
|
||||||
echo "-v ${DATA_VOLUME}:/app/data"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine the required port mappings from Caddyfile
|
|
||||||
get_required_ports() {
|
|
||||||
local caddyfile_domain
|
|
||||||
caddyfile_domain=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
|
||||||
if echo "$caddyfile_domain" | grep -qE '^:[0-9]+$'; then
|
|
||||||
# HTTP-only on a specific port (e.g., :80, :8080)
|
|
||||||
echo "${caddyfile_domain#:}"
|
|
||||||
else
|
|
||||||
# Domain name — needs 80 + 443 for Caddy auto-TLS
|
|
||||||
echo "80 443"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get current container port mappings (just the host ports)
|
|
||||||
get_current_ports() {
|
|
||||||
docker inspect "$CONTAINER_NAME" 2>/dev/null | \
|
|
||||||
grep -oP '"HostPort":\s*"\K[0-9]+' | sort -u | tr '\n' ' ' | sed 's/ $//'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if container port mappings match what's needed.
|
|
||||||
# Returns 0 if they match, 1 if mismatch.
|
|
||||||
check_port_match() {
|
|
||||||
local required current
|
|
||||||
required=$(get_required_ports | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
|
||||||
current=$(get_current_ports | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
|
||||||
[ "$required" = "$current" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build the docker run command args (ports + volumes)
|
|
||||||
get_docker_run_args() {
|
|
||||||
local ports_arg=""
|
|
||||||
for port in $(get_required_ports); do
|
|
||||||
ports_arg="$ports_arg -p ${port}:${port}"
|
|
||||||
done
|
|
||||||
|
|
||||||
local data_mount
|
|
||||||
data_mount=$(get_data_mount_args)
|
|
||||||
|
|
||||||
echo "$ports_arg \
|
|
||||||
-v $(pwd)/config.json:/app/config.json:ro \
|
|
||||||
-v $(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro \
|
|
||||||
$data_mount \
|
|
||||||
-v ${CADDY_VOLUME}:/data/caddy"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Recreate the container with current settings
|
|
||||||
recreate_container() {
|
|
||||||
info "Stopping and removing old container..."
|
|
||||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
|
|
||||||
local run_args
|
|
||||||
run_args=$(get_docker_run_args)
|
|
||||||
|
|
||||||
eval docker run -d \
|
|
||||||
--name "$CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
$run_args \
|
|
||||||
"$IMAGE_NAME"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check config.json for placeholder values
|
# Check config.json for placeholder values
|
||||||
check_config_placeholders() {
|
check_config_placeholders() {
|
||||||
if [ -f config.json ]; then
|
if [ -f config.json ]; then
|
||||||
@@ -140,7 +57,7 @@ check_config_placeholders() {
|
|||||||
|
|
||||||
# Verify the running container is actually healthy
|
# Verify the running container is actually healthy
|
||||||
verify_health() {
|
verify_health() {
|
||||||
local base_url="http://localhost:3000"
|
local container="corescope-prod"
|
||||||
local use_https=false
|
local use_https=false
|
||||||
|
|
||||||
# Check if Caddyfile has a real domain (not :80)
|
# Check if Caddyfile has a real domain (not :80)
|
||||||
@@ -156,7 +73,7 @@ verify_health() {
|
|||||||
info "Waiting for server to respond..."
|
info "Waiting for server to respond..."
|
||||||
local healthy=false
|
local healthy=false
|
||||||
for i in $(seq 1 45); do
|
for i in $(seq 1 45); do
|
||||||
if docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
if docker exec "$container" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
||||||
healthy=true
|
healthy=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -172,7 +89,7 @@ verify_health() {
|
|||||||
|
|
||||||
# Check for MQTT errors in recent logs
|
# Check for MQTT errors in recent logs
|
||||||
local mqtt_errors
|
local mqtt_errors
|
||||||
mqtt_errors=$(docker logs "$CONTAINER_NAME" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
mqtt_errors=$(docker logs "$container" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||||
if [ -n "$mqtt_errors" ]; then
|
if [ -n "$mqtt_errors" ]; then
|
||||||
warn "MQTT errors detected in logs:"
|
warn "MQTT errors detected in logs:"
|
||||||
echo "$mqtt_errors" | head -5 | sed 's/^/ /'
|
echo "$mqtt_errors" | head -5 | sed 's/^/ /'
|
||||||
@@ -234,6 +151,13 @@ cmd_setup() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
|
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
|
||||||
|
|
||||||
|
# Check docker compose (separate check since it's a plugin/separate binary)
|
||||||
|
if ! docker compose version &>/dev/null; then
|
||||||
|
err "docker compose is required. Install Docker Desktop or docker-compose-plugin."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
mark_done "docker"
|
mark_done "docker"
|
||||||
|
|
||||||
# ── Step 2: Config ──
|
# ── Step 2: Config ──
|
||||||
@@ -371,12 +295,12 @@ cmd_setup() {
|
|||||||
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
|
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
|
||||||
log "Image already built."
|
log "Image already built."
|
||||||
if confirm "Rebuild? (only needed if you updated the code)"; then
|
if confirm "Rebuild? (only needed if you updated the code)"; then
|
||||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
docker compose build prod
|
||||||
log "Image rebuilt."
|
log "Image rebuilt."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
info "This takes 1-2 minutes the first time..."
|
info "This takes 1-2 minutes the first time..."
|
||||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
docker compose build prod
|
||||||
log "Image built."
|
log "Image built."
|
||||||
fi
|
fi
|
||||||
mark_done "build"
|
mark_done "build"
|
||||||
@@ -385,45 +309,15 @@ cmd_setup() {
|
|||||||
step 5 "Starting container"
|
step 5 "Starting container"
|
||||||
|
|
||||||
# Detect existing data directories
|
# Detect existing data directories
|
||||||
if [ -d "$HOME/meshcore-data" ] && [ -f "$HOME/meshcore-data/meshcore.db" ]; then
|
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||||
info "Found existing data at \$HOME/meshcore-data/ — will use bind mount."
|
info "Found existing data at $PROD_DATA/ — will use bind mount."
|
||||||
elif [ -d "$(pwd)/data" ] && [ -f "$(pwd)/data/meshcore.db" ]; then
|
|
||||||
info "Found existing data at ./data/ — will use bind mount."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
||||||
log "Container already running."
|
log "Container already running."
|
||||||
# Check port mappings match
|
|
||||||
if ! check_port_match; then
|
|
||||||
warn "Container port mappings don't match Caddyfile configuration."
|
|
||||||
warn "Current ports: $(get_current_ports)"
|
|
||||||
warn "Required ports: $(get_required_ports)"
|
|
||||||
if confirm "Recreate container with correct ports?"; then
|
|
||||||
recreate_container
|
|
||||||
log "Container recreated with correct ports."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
# Exists but stopped — check ports before starting
|
|
||||||
if ! check_port_match; then
|
|
||||||
warn "Stopped container has wrong port mappings."
|
|
||||||
warn "Current ports: $(get_current_ports)"
|
|
||||||
warn "Required ports: $(get_required_ports)"
|
|
||||||
if confirm "Recreate container with correct ports?"; then
|
|
||||||
recreate_container
|
|
||||||
log "Container recreated with correct ports."
|
|
||||||
else
|
|
||||||
info "Starting existing container (ports unchanged)..."
|
|
||||||
docker start "$CONTAINER_NAME"
|
|
||||||
log "Started (with old port mappings)."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
info "Container exists but is stopped. Starting..."
|
|
||||||
docker start "$CONTAINER_NAME"
|
|
||||||
log "Started."
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
recreate_container
|
mkdir -p "$PROD_DATA"
|
||||||
|
docker compose up -d prod
|
||||||
log "Container started."
|
log "Container started."
|
||||||
fi
|
fi
|
||||||
mark_done "container"
|
mark_done "container"
|
||||||
@@ -431,7 +325,7 @@ cmd_setup() {
|
|||||||
# ── Step 6: Verify ──
|
# ── Step 6: Verify ──
|
||||||
step 6 "Verifying"
|
step 6 "Verifying"
|
||||||
|
|
||||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
||||||
verify_health
|
verify_health
|
||||||
|
|
||||||
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
||||||
@@ -463,7 +357,7 @@ cmd_setup() {
|
|||||||
err "Container failed to start."
|
err "Container failed to start."
|
||||||
echo ""
|
echo ""
|
||||||
echo " Check what went wrong:"
|
echo " Check what went wrong:"
|
||||||
echo " docker logs ${CONTAINER_NAME}"
|
echo " docker compose logs prod"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Common fixes:"
|
echo " Common fixes:"
|
||||||
echo " • Invalid config.json — check JSON syntax"
|
echo " • Invalid config.json — check JSON syntax"
|
||||||
@@ -535,132 +429,72 @@ cmd_start() {
|
|||||||
WITH_STAGING=true
|
WITH_STAGING=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $COMPOSE_MODE; then
|
if $WITH_STAGING; then
|
||||||
if $WITH_STAGING; then
|
# Prepare staging data and config
|
||||||
# Prepare staging data and config
|
prepare_staging_db
|
||||||
prepare_staging_db
|
prepare_staging_config
|
||||||
prepare_staging_config
|
|
||||||
|
|
||||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||||
info "Starting staging container (corescope-staging) on port ${STAGING_HTTP_PORT:-81}..."
|
info "Starting staging container (corescope-staging) on port ${STAGING_HTTP_PORT:-81}..."
|
||||||
docker compose --profile staging up -d
|
docker compose --profile staging up -d
|
||||||
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
|
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
|
||||||
log "Staging started on port ${STAGING_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
|
log "Staging started on port ${STAGING_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
|
||||||
else
|
|
||||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
|
||||||
docker compose up -d prod
|
|
||||||
log "Production started. Staging NOT running (use --with-staging to start both)."
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
# Legacy single-container mode
|
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||||
if $WITH_STAGING; then
|
docker compose up -d prod
|
||||||
err "--with-staging requires docker-compose.yml. Run setup or add docker-compose.yml first."
|
log "Production started. Staging NOT running (use --with-staging to start both)."
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
warn "No docker-compose.yml found — using legacy single-container mode."
|
|
||||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
warn "Already running."
|
|
||||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
if ! check_port_match; then
|
|
||||||
warn "Container port mappings don't match Caddyfile configuration."
|
|
||||||
warn "Current ports: $(get_current_ports)"
|
|
||||||
warn "Required ports: $(get_required_ports)"
|
|
||||||
if confirm "Recreate container with correct ports?"; then
|
|
||||||
recreate_container
|
|
||||||
log "Container recreated and started with correct ports."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
docker start "$CONTAINER_NAME"
|
|
||||||
log "Started."
|
|
||||||
else
|
|
||||||
err "Container doesn't exist. Run './manage.sh setup' first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_stop() {
|
cmd_stop() {
|
||||||
local TARGET="${1:-all}"
|
local TARGET="${1:-all}"
|
||||||
|
|
||||||
if $COMPOSE_MODE; then
|
case "$TARGET" in
|
||||||
case "$TARGET" in
|
prod)
|
||||||
prod)
|
info "Stopping production container (corescope-prod)..."
|
||||||
info "Stopping production container (corescope-prod)..."
|
docker compose stop prod
|
||||||
docker compose stop prod
|
log "Production stopped."
|
||||||
log "Production stopped."
|
;;
|
||||||
;;
|
staging)
|
||||||
staging)
|
info "Stopping staging container (corescope-staging)..."
|
||||||
info "Stopping staging container (corescope-staging)..."
|
docker compose --profile staging stop staging
|
||||||
docker compose stop staging
|
log "Staging stopped."
|
||||||
log "Staging stopped."
|
;;
|
||||||
;;
|
all)
|
||||||
all)
|
info "Stopping all containers..."
|
||||||
info "Stopping all containers..."
|
docker compose --profile staging --profile staging-go down
|
||||||
docker compose --profile staging --profile staging-go down 2>/dev/null
|
log "All containers stopped."
|
||||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null
|
;;
|
||||||
log "All containers stopped."
|
*)
|
||||||
;;
|
err "Usage: ./manage.sh stop [prod|staging|all]"
|
||||||
*)
|
exit 1
|
||||||
err "Usage: ./manage.sh stop [prod|staging|all]"
|
;;
|
||||||
exit 1
|
esac
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# Legacy mode
|
|
||||||
docker stop "$CONTAINER_NAME" 2>/dev/null && log "Stopped." || warn "Not running."
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_restart() {
|
cmd_restart() {
|
||||||
if $COMPOSE_MODE; then
|
local TARGET="${1:-prod}"
|
||||||
local TARGET="${1:-prod}"
|
case "$TARGET" in
|
||||||
case "$TARGET" in
|
prod)
|
||||||
prod)
|
info "Restarting production container (corescope-prod)..."
|
||||||
info "Restarting production container (corescope-prod)..."
|
docker compose up -d --force-recreate prod
|
||||||
docker compose up -d --force-recreate prod
|
log "Production restarted."
|
||||||
log "Production restarted."
|
;;
|
||||||
;;
|
staging)
|
||||||
staging)
|
info "Restarting staging container (corescope-staging)..."
|
||||||
info "Restarting staging container (corescope-staging)..."
|
docker compose --profile staging up -d --force-recreate staging
|
||||||
docker compose --profile staging up -d --force-recreate staging
|
log "Staging restarted."
|
||||||
log "Staging restarted."
|
;;
|
||||||
;;
|
all)
|
||||||
all)
|
info "Restarting all containers..."
|
||||||
info "Restarting all containers..."
|
docker compose --profile staging up -d --force-recreate
|
||||||
docker compose --profile staging up -d --force-recreate
|
log "All containers restarted."
|
||||||
log "All containers restarted."
|
;;
|
||||||
;;
|
*)
|
||||||
*)
|
err "Usage: ./manage.sh restart [prod|staging|all]"
|
||||||
err "Usage: ./manage.sh restart [prod|staging|all]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# Legacy mode
|
|
||||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
if ! check_port_match; then
|
|
||||||
warn "Port mappings have changed. Recreating container..."
|
|
||||||
recreate_container
|
|
||||||
log "Container recreated with correct ports."
|
|
||||||
else
|
|
||||||
docker restart "$CONTAINER_NAME"
|
|
||||||
log "Restarted."
|
|
||||||
fi
|
|
||||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
if ! check_port_match; then
|
|
||||||
warn "Port mappings have changed. Recreating container..."
|
|
||||||
recreate_container
|
|
||||||
log "Container recreated with correct ports."
|
|
||||||
else
|
|
||||||
docker start "$CONTAINER_NAME"
|
|
||||||
log "Started."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
err "Not running. Use './manage.sh setup'."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
;;
|
||||||
fi
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Status ───────────────────────────────────────────────────────────────
|
# ─── Status ───────────────────────────────────────────────────────────────
|
||||||
@@ -695,143 +529,68 @@ show_container_status() {
|
|||||||
|
|
||||||
cmd_status() {
|
cmd_status() {
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " CoreScope Status"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
if $COMPOSE_MODE; then
|
# Production
|
||||||
echo "═══════════════════════════════════════"
|
show_container_status "corescope-prod" "Production"
|
||||||
echo " CoreScope Status (Compose)"
|
echo ""
|
||||||
echo "═══════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Production
|
|
||||||
show_container_status "corescope-prod" "Production"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Staging
|
|
||||||
if container_running "corescope-staging"; then
|
|
||||||
show_container_status "corescope-staging" "Staging"
|
|
||||||
else
|
|
||||||
info "Staging (corescope-staging): Not running (use --with-staging to start both)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Disk usage
|
|
||||||
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
|
||||||
local db_size
|
|
||||||
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
|
||||||
info "Production DB: ${db_size}"
|
|
||||||
fi
|
|
||||||
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
|
|
||||||
local staging_db_size
|
|
||||||
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
|
||||||
info "Staging DB: ${staging_db_size}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# Staging
|
||||||
|
if container_running "corescope-staging"; then
|
||||||
|
show_container_status "corescope-staging" "Staging"
|
||||||
else
|
else
|
||||||
# Legacy single-container status
|
info "Staging (corescope-staging): Not running (use --with-staging to start both)"
|
||||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
log "Container is running."
|
|
||||||
echo ""
|
|
||||||
docker ps --filter "name=${CONTAINER_NAME}" --format " Status: {{.Status}}"
|
|
||||||
docker ps --filter "name=${CONTAINER_NAME}" --format " Ports: {{.Ports}}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
info "Service health:"
|
|
||||||
# Server
|
|
||||||
if docker exec "$CONTAINER_NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then
|
|
||||||
STATS=$(docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null)
|
|
||||||
PACKETS=$(echo "$STATS" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?")
|
|
||||||
NODES=$(echo "$STATS" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?")
|
|
||||||
log " Server — ${PACKETS} packets, ${NODES} nodes"
|
|
||||||
else
|
|
||||||
err " Server — not responding"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Mosquitto
|
|
||||||
if docker exec "$CONTAINER_NAME" pgrep mosquitto &>/dev/null; then
|
|
||||||
log " Mosquitto — running"
|
|
||||||
else
|
|
||||||
err " Mosquitto — not running"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Caddy
|
|
||||||
if docker exec "$CONTAINER_NAME" pgrep caddy &>/dev/null; then
|
|
||||||
log " Caddy — running"
|
|
||||||
else
|
|
||||||
err " Caddy — not running"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for MQTT errors in recent logs
|
|
||||||
MQTT_ERRORS=$(docker logs "$CONTAINER_NAME" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
|
||||||
if [ -n "$MQTT_ERRORS" ]; then
|
|
||||||
echo ""
|
|
||||||
warn "MQTT errors in recent logs:"
|
|
||||||
echo "$MQTT_ERRORS" | head -3 | sed 's/^/ /'
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Port mapping check
|
|
||||||
if ! check_port_match; then
|
|
||||||
echo ""
|
|
||||||
warn "Port mappings don't match Caddyfile. Run './manage.sh restart' to fix."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Disk usage
|
|
||||||
DB_SIZE=$(docker exec "$CONTAINER_NAME" du -h /app/data/meshcore.db 2>/dev/null | cut -f1)
|
|
||||||
if [ -n "$DB_SIZE" ]; then
|
|
||||||
echo ""
|
|
||||||
info "Database size: ${DB_SIZE}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
err "Container is not running."
|
|
||||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
|
||||||
echo " Start with: ./manage.sh start"
|
|
||||||
else
|
|
||||||
echo " Set up with: ./manage.sh setup"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Disk usage
|
||||||
|
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||||
|
local db_size
|
||||||
|
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||||
|
info "Production DB: ${db_size}"
|
||||||
|
fi
|
||||||
|
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
|
||||||
|
local staging_db_size
|
||||||
|
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||||
|
info "Staging DB: ${staging_db_size}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Logs ─────────────────────────────────────────────────────────────────
|
# ─── Logs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd_logs() {
|
cmd_logs() {
|
||||||
if $COMPOSE_MODE; then
|
local TARGET="${1:-prod}"
|
||||||
local TARGET="${1:-prod}"
|
local LINES="${2:-100}"
|
||||||
local LINES="${2:-100}"
|
case "$TARGET" in
|
||||||
case "$TARGET" in
|
prod)
|
||||||
prod)
|
info "Tailing production logs..."
|
||||||
info "Tailing production logs..."
|
docker compose logs -f --tail="$LINES" prod
|
||||||
docker compose logs -f --tail="$LINES" prod
|
;;
|
||||||
;;
|
staging)
|
||||||
staging)
|
if container_running "corescope-staging"; then
|
||||||
if container_running "corescope-staging"; then
|
info "Tailing staging logs..."
|
||||||
info "Tailing staging logs..."
|
docker compose logs -f --tail="$LINES" staging
|
||||||
docker compose logs -f --tail="$LINES" staging
|
else
|
||||||
else
|
err "Staging container is not running."
|
||||||
err "Staging container is not running."
|
info "Start with: ./manage.sh start --with-staging"
|
||||||
info "Start with: ./manage.sh start --with-staging"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
fi
|
||||||
esac
|
;;
|
||||||
else
|
*)
|
||||||
# Legacy mode
|
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
||||||
docker logs -f "$CONTAINER_NAME" --tail "${1:-100}"
|
exit 1
|
||||||
fi
|
;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Promote ──────────────────────────────────────────────────────────────
|
# ─── Promote ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd_promote() {
|
cmd_promote() {
|
||||||
if ! $COMPOSE_MODE; then
|
|
||||||
err "Promotion requires Docker Compose setup (docker-compose.yml)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
info "Promotion Flow: Staging → Production"
|
info "Promotion Flow: Staging → Production"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -906,10 +665,10 @@ cmd_update() {
|
|||||||
git pull
|
git pull
|
||||||
|
|
||||||
info "Rebuilding image..."
|
info "Rebuilding image..."
|
||||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
docker compose build prod
|
||||||
|
|
||||||
info "Restarting with new image..."
|
info "Restarting with new image..."
|
||||||
recreate_container
|
docker compose up -d --force-recreate prod
|
||||||
|
|
||||||
log "Updated and restarted. Data preserved."
|
log "Updated and restarted. Data preserved."
|
||||||
}
|
}
|
||||||
@@ -924,12 +683,13 @@ cmd_backup() {
|
|||||||
info "Backing up to ${BACKUP_DIR}/"
|
info "Backing up to ${BACKUP_DIR}/"
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
|
# Always use bind mount path (from .env or default)
|
||||||
|
DB_PATH="$PROD_DATA/meshcore.db"
|
||||||
if [ -f "$DB_PATH" ]; then
|
if [ -f "$DB_PATH" ]; then
|
||||||
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
|
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
|
||||||
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
|
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
|
||||||
elif docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
elif container_running "corescope-prod"; then
|
||||||
docker cp "${CONTAINER_NAME}:/app/data/meshcore.db" "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
||||||
log "Database (via docker cp)" || warn "Could not backup database"
|
log "Database (via docker cp)" || warn "Could not backup database"
|
||||||
else
|
else
|
||||||
warn "Database not found (container not running?)"
|
warn "Database not found (container not running?)"
|
||||||
@@ -948,7 +708,8 @@ cmd_backup() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Theme
|
# Theme
|
||||||
THEME_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
|
# Always use bind mount path (from .env or default)
|
||||||
|
THEME_PATH="$PROD_DATA/theme.json"
|
||||||
if [ -f "$THEME_PATH" ]; then
|
if [ -f "$THEME_PATH" ]; then
|
||||||
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
|
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
|
||||||
log "theme.json"
|
log "theme.json"
|
||||||
@@ -1021,15 +782,12 @@ cmd_restore() {
|
|||||||
info "Backing up current state..."
|
info "Backing up current state..."
|
||||||
cmd_backup "./backups/corescope-pre-restore-$(date +%Y%m%d-%H%M%S)"
|
cmd_backup "./backups/corescope-pre-restore-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
docker compose stop prod 2>/dev/null || true
|
||||||
|
|
||||||
# Restore database
|
# Restore database
|
||||||
DEST_DB=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
|
mkdir -p "$PROD_DATA"
|
||||||
if [ -d "$(dirname "$DEST_DB")" ]; then
|
DEST_DB="$PROD_DATA/meshcore.db"
|
||||||
cp "$DB_FILE" "$DEST_DB"
|
cp "$DB_FILE" "$DEST_DB"
|
||||||
else
|
|
||||||
docker cp "$DB_FILE" "${CONTAINER_NAME}:/app/data/meshcore.db"
|
|
||||||
fi
|
|
||||||
log "Database restored"
|
log "Database restored"
|
||||||
|
|
||||||
# Restore config if present
|
# Restore config if present
|
||||||
@@ -1047,27 +805,25 @@ cmd_restore() {
|
|||||||
|
|
||||||
# Restore theme if present
|
# Restore theme if present
|
||||||
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
|
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
|
||||||
DEST_THEME=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
|
DEST_THEME="$PROD_DATA/theme.json"
|
||||||
if [ -d "$(dirname "$DEST_THEME")" ]; then
|
cp "$THEME_FILE" "$DEST_THEME"
|
||||||
cp "$THEME_FILE" "$DEST_THEME"
|
|
||||||
fi
|
|
||||||
log "theme.json restored"
|
log "theme.json restored"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker start "$CONTAINER_NAME"
|
docker compose up -d prod
|
||||||
log "Restored and restarted."
|
log "Restored and restarted."
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── MQTT Test ────────────────────────────────────────────────────────────
|
# ─── MQTT Test ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd_mqtt_test() {
|
cmd_mqtt_test() {
|
||||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
if ! container_running "corescope-prod"; then
|
||||||
err "Container not running. Start with: ./manage.sh start"
|
err "Container not running. Start with: ./manage.sh start"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Listening for MQTT messages (10 second timeout)..."
|
info "Listening for MQTT messages (10 second timeout)..."
|
||||||
MSG=$(docker exec "$CONTAINER_NAME" mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
MSG=$(docker exec corescope-prod mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
||||||
if [ -n "$MSG" ]; then
|
if [ -n "$MSG" ]; then
|
||||||
log "Received MQTT message:"
|
log "Received MQTT message:"
|
||||||
echo " $MSG" | head -c 200
|
echo " $MSG" | head -c 200
|
||||||
@@ -1084,21 +840,19 @@ cmd_mqtt_test() {
|
|||||||
|
|
||||||
cmd_reset() {
|
cmd_reset() {
|
||||||
echo ""
|
echo ""
|
||||||
warn "This will remove the container, image, and setup state."
|
warn "This will remove all containers, images, and setup state."
|
||||||
warn "Your config.json, Caddyfile, and data volume are NOT deleted."
|
warn "Your config.json, Caddyfile, and data directory are NOT deleted."
|
||||||
echo ""
|
echo ""
|
||||||
if ! confirm "Continue?"; then
|
if ! confirm "Continue?"; then
|
||||||
echo " Aborted."
|
echo " Aborted."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
docker compose --profile staging --profile staging-go down --rmi local 2>/dev/null || true
|
||||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
docker rmi "$IMAGE_NAME" 2>/dev/null || true
|
|
||||||
rm -f "$STATE_FILE"
|
rm -f "$STATE_FILE"
|
||||||
|
|
||||||
log "Reset complete. Run './manage.sh setup' to start over."
|
log "Reset complete. Run './manage.sh setup' to start over."
|
||||||
echo " Data volume preserved. To delete it: docker volume rm ${DATA_VOLUME}"
|
echo " Data directory: $PROD_DATA (not removed)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Help ─────────────────────────────────────────────────────────────────
|
# ─── Help ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1128,11 +882,7 @@ cmd_help() {
|
|||||||
echo " restore <d> Restore from backup dir or .db file"
|
echo " restore <d> Restore from backup dir or .db file"
|
||||||
echo " mqtt-test Check if MQTT data is flowing"
|
echo " mqtt-test Check if MQTT data is flowing"
|
||||||
echo ""
|
echo ""
|
||||||
if $COMPOSE_MODE; then
|
echo "All commands use docker compose with docker-compose.yml."
|
||||||
info "Docker Compose mode detected (docker-compose.yml present)."
|
|
||||||
else
|
|
||||||
warn "Legacy mode (no docker-compose.yml). Some commands unavailable."
|
|
||||||
fi
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user