mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 15:55:49 +00:00
Compare commits
23 Commits
fix/hashch
...
optimize/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1191cfc017 | ||
|
|
68bdaaca99 | ||
|
|
074f3d3760 | ||
|
|
206d9bd64a | ||
|
|
3f54632b07 | ||
|
|
609b12541e | ||
|
|
4369e58a3c | ||
|
|
8ef321bf70 | ||
|
|
bee705d5d8 | ||
|
|
9b2ad91512 | ||
|
|
6740e53c18 | ||
|
|
b2e5b66f25 | ||
|
|
45b82ad390 | ||
|
|
d538d2f3e7 | ||
|
|
746f7f2733 | ||
|
|
a1a67e89fb | ||
|
|
91fcbc5adc | ||
|
|
5f5eae07b0 | ||
|
|
380b1b1e28 | ||
|
|
03cfd114da | ||
|
|
df90de77a7 | ||
|
|
1453fb6492 | ||
|
|
5cc6064e11 |
39
.env.example
39
.env.example
@@ -1,17 +1,44 @@
|
||||
# MeshCore Analyzer — Environment Configuration
|
||||
# Copy to .env and customize. All values have sensible defaults in docker-compose.yml.
|
||||
# Copy to .env and customize. All values have sensible defaults.
|
||||
#
|
||||
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
|
||||
# Each environment keeps config + data together in one directory:
|
||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||
|
||||
# --- Production ---
|
||||
PROD_HTTP_PORT=80
|
||||
PROD_HTTPS_PORT=443
|
||||
PROD_MQTT_PORT=1883
|
||||
# Data directory (database, theme, etc.)
|
||||
# Default: ~/meshcore-data
|
||||
# Used by: docker compose, manage.sh
|
||||
PROD_DATA_DIR=~/meshcore-data
|
||||
|
||||
# HTTP port for web UI
|
||||
# Default: 80
|
||||
# Used by: docker compose
|
||||
PROD_HTTP_PORT=80
|
||||
|
||||
# HTTPS port for web UI (TLS via Caddy)
|
||||
# Default: 443
|
||||
# Used by: docker compose
|
||||
PROD_HTTPS_PORT=443
|
||||
|
||||
# MQTT port for observer connections
|
||||
# Default: 1883
|
||||
# Used by: docker compose
|
||||
PROD_MQTT_PORT=1883
|
||||
|
||||
# --- Staging (HTTP only, no HTTPS) ---
|
||||
STAGING_HTTP_PORT=81
|
||||
STAGING_MQTT_PORT=1884
|
||||
# Data directory
|
||||
# Default: ~/meshcore-staging-data
|
||||
# Used by: docker compose
|
||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
||||
|
||||
# HTTP port
|
||||
# Default: 81
|
||||
# Used by: docker compose
|
||||
STAGING_HTTP_PORT=81
|
||||
|
||||
# MQTT port
|
||||
# Default: 1884
|
||||
# Used by: docker compose
|
||||
STAGING_MQTT_PORT=1884
|
||||
|
||||
87
.github/workflows/deploy.yml
vendored
87
.github/workflows/deploy.yml
vendored
@@ -17,7 +17,7 @@ on:
|
||||
- 'docs/**'
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: |
|
||||
@@ -122,9 +122,16 @@ jobs:
|
||||
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer ${{ github.token }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
|
||||
|
||||
- name: Upload Go coverage badges
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/go-*.json
|
||||
@@ -136,15 +143,15 @@ jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
node-test:
|
||||
name: "🧪 Node.js Tests"
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -190,7 +197,11 @@ jobs:
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: npx playwright install chromium --with-deps 2>/dev/null || true
|
||||
run: |
|
||||
# Install chromium (skips download if already cached on self-hosted runner)
|
||||
npx playwright install chromium 2>/dev/null || true
|
||||
# Install system deps only if missing (apt-get is slow)
|
||||
npx playwright install-deps chromium 2>/dev/null || true
|
||||
|
||||
- name: Instrument frontend JS for coverage
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
@@ -220,19 +231,32 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
- name: Run Playwright E2E + coverage collection concurrently
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
run: |
|
||||
# Run E2E tests and coverage collection in parallel — both use the same server
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt &
|
||||
E2E_PID=$!
|
||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt &
|
||||
COV_PID=$!
|
||||
|
||||
- name: Collect frontend coverage report
|
||||
# Wait for both — E2E must pass, coverage is best-effort
|
||||
E2E_EXIT=0
|
||||
wait $E2E_PID || E2E_EXIT=$?
|
||||
wait $COV_PID || true
|
||||
|
||||
# Fail if E2E failed
|
||||
[ $E2E_EXIT -ne 0 ] && exit $E2E_EXIT
|
||||
true
|
||||
|
||||
- name: Generate frontend coverage badges
|
||||
if: always() && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
|
||||
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
||||
|
||||
mkdir -p .badges
|
||||
if [ -f .nyc_output/frontend-coverage.json ]; then
|
||||
# Merge E2E + coverage collector data if both exist
|
||||
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
||||
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
||||
FE_COVERAGE=${FE_COVERAGE:-0}
|
||||
@@ -259,13 +283,24 @@ jobs:
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
PORT=13581 node server.js &
|
||||
SERVER_PID=$!
|
||||
sleep 5
|
||||
# Wait for server to be ready (up to 15s)
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer ${{ github.token }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
|
||||
|
||||
- name: Upload Node.js test badges
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: node-badges
|
||||
path: .badges/
|
||||
@@ -278,14 +313,14 @@ jobs:
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
if: github.event_name == 'push'
|
||||
needs: [go-test]
|
||||
runs-on: self-hosted
|
||||
needs: [go-test, node-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -304,10 +339,10 @@ jobs:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Start staging on port 82
|
||||
run: |
|
||||
@@ -349,21 +384,21 @@ jobs:
|
||||
name: "📝 Publish Badges & Summary"
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Go coverage badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Download Node.js test badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: node-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)*
|
||||
@@ -33,6 +33,11 @@ type Server struct {
|
||||
memStatsMu sync.Mutex
|
||||
memStatsCache runtime.MemStats
|
||||
memStatsCachedAt time.Time
|
||||
|
||||
// Cached /api/stats response — recomputed at most once every 10s
|
||||
statsMu sync.Mutex
|
||||
statsCache *StatsResponse
|
||||
statsCachedAt time.Time
|
||||
}
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
@@ -380,6 +385,17 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
const statsTTL = 10 * time.Second
|
||||
|
||||
s.statsMu.Lock()
|
||||
if s.statsCache != nil && time.Since(s.statsCachedAt) < statsTTL {
|
||||
cached := s.statsCache
|
||||
s.statsMu.Unlock()
|
||||
writeJSON(w, cached)
|
||||
return
|
||||
}
|
||||
s.statsMu.Unlock()
|
||||
|
||||
var stats *Stats
|
||||
var err error
|
||||
if s.store != nil {
|
||||
@@ -392,7 +408,7 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
counts := s.db.GetRoleCounts()
|
||||
writeJSON(w, StatsResponse{
|
||||
resp := &StatsResponse{
|
||||
TotalPackets: stats.TotalPackets,
|
||||
TotalTransmissions: &stats.TotalTransmissions,
|
||||
TotalObservations: stats.TotalObservations,
|
||||
@@ -411,7 +427,14 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
Companions: counts["companions"],
|
||||
Sensors: counts["sensors"],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
s.statsMu.Lock()
|
||||
s.statsCache = resp
|
||||
s.statsCachedAt = time.Now()
|
||||
s.statsMu.Unlock()
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -62,7 +62,7 @@ type StoreObs struct {
|
||||
type PacketStore struct {
|
||||
mu sync.RWMutex
|
||||
db *DB
|
||||
packets []*StoreTx // sorted by first_seen DESC
|
||||
packets []*StoreTx // sorted by first_seen ASC (oldest first; newest at tail)
|
||||
byHash map[string]*StoreTx // hash → *StoreTx
|
||||
byTxID map[int]*StoreTx // transmission_id → *StoreTx
|
||||
byObsID map[int]*StoreObs // observation_id → *StoreObs
|
||||
@@ -98,6 +98,11 @@ type PacketStore struct {
|
||||
// computed during Load() and incrementally updated on ingest.
|
||||
distHops []distHopRecord
|
||||
distPaths []distPathRecord
|
||||
|
||||
// Cached GetNodeHashSizeInfo result — recomputed at most once every 15s
|
||||
hashSizeInfoMu sync.Mutex
|
||||
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
||||
hashSizeInfoAt time.Time
|
||||
}
|
||||
|
||||
// Precomputed distance records for fast analytics aggregation.
|
||||
@@ -176,7 +181,7 @@ func (s *PacketStore) Load() error {
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC`
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
} else {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
@@ -184,7 +189,7 @@ func (s *PacketStore) Load() error {
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC`
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
}
|
||||
|
||||
rows, err := s.db.conn.Query(loadSQL)
|
||||
@@ -368,28 +373,32 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||||
results := s.filterPackets(q)
|
||||
total := len(results)
|
||||
|
||||
if q.Order == "ASC" {
|
||||
sorted := make([]*StoreTx, len(results))
|
||||
copy(sorted, results)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].FirstSeen < sorted[j].FirstSeen
|
||||
})
|
||||
results = sorted
|
||||
}
|
||||
|
||||
// Paginate
|
||||
// results is oldest-first (ASC). For DESC (default) read backwards from the tail;
|
||||
// for ASC read forwards. Both are O(page_size) — no sort copy needed.
|
||||
start := q.Offset
|
||||
if start >= len(results) {
|
||||
if start >= total {
|
||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||
}
|
||||
end := start + q.Limit
|
||||
if end > len(results) {
|
||||
end = len(results)
|
||||
pageSize := q.Limit
|
||||
if start+pageSize > total {
|
||||
pageSize = total - start
|
||||
}
|
||||
|
||||
packets := make([]map[string]interface{}, 0, end-start)
|
||||
for _, tx := range results[start:end] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
packets := make([]map[string]interface{}, 0, pageSize)
|
||||
if q.Order == "ASC" {
|
||||
for _, tx := range results[start : start+pageSize] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
}
|
||||
} else {
|
||||
// DESC: newest items are at the tail; page 0 = last pageSize items reversed
|
||||
endIdx := total - start
|
||||
startIdx := endIdx - pageSize
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
for i := endIdx - 1; i >= startIdx; i-- {
|
||||
packets = append(packets, txToMap(results[i]))
|
||||
}
|
||||
}
|
||||
return &PacketResult{Packets: packets, Total: total}
|
||||
}
|
||||
@@ -719,15 +728,16 @@ func (s *PacketStore) GetTimestamps(since string) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// packets sorted newest first — scan from start until older than since
|
||||
// packets sorted oldest-first — scan from tail until we reach items older than since
|
||||
var result []string
|
||||
for _, tx := range s.packets {
|
||||
for i := len(s.packets) - 1; i >= 0; i-- {
|
||||
tx := s.packets[i]
|
||||
if tx.FirstSeen <= since {
|
||||
break
|
||||
}
|
||||
result = append(result, tx.FirstSeen)
|
||||
}
|
||||
// Reverse to get ASC order
|
||||
// result is currently newest-first; reverse to return ASC order
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
@@ -777,23 +787,30 @@ func (s *PacketStore) QueryMultiNodePackets(pubkeys []string, limit, offset int,
|
||||
|
||||
total := len(filtered)
|
||||
|
||||
if order == "ASC" {
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].FirstSeen < filtered[j].FirstSeen
|
||||
})
|
||||
}
|
||||
|
||||
// filtered is oldest-first (built by iterating s.packets forward).
|
||||
// Apply same DESC/ASC pagination logic as QueryPackets.
|
||||
if offset >= total {
|
||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
pageSize := limit
|
||||
if offset+pageSize > total {
|
||||
pageSize = total - offset
|
||||
}
|
||||
|
||||
packets := make([]map[string]interface{}, 0, end-offset)
|
||||
for _, tx := range filtered[offset:end] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
packets := make([]map[string]interface{}, 0, pageSize)
|
||||
if order == "ASC" {
|
||||
for _, tx := range filtered[offset : offset+pageSize] {
|
||||
packets = append(packets, txToMap(tx))
|
||||
}
|
||||
} else {
|
||||
endIdx := total - offset
|
||||
startIdx := endIdx - pageSize
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
for i := endIdx - 1; i >= startIdx; i-- {
|
||||
packets = append(packets, txToMap(filtered[i]))
|
||||
}
|
||||
}
|
||||
return &PacketResult{Packets: packets, Total: total}
|
||||
}
|
||||
@@ -926,15 +943,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
DecodedJSON: r.decodedJSON,
|
||||
}
|
||||
s.byHash[r.hash] = tx
|
||||
// Prepend (newest first)
|
||||
s.packets = append([]*StoreTx{tx}, s.packets...)
|
||||
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
||||
s.byTxID[r.txID] = tx
|
||||
s.indexByNode(tx)
|
||||
if tx.PayloadType != nil {
|
||||
pt := *tx.PayloadType
|
||||
// Prepend to maintain newest-first order (matches Load ordering)
|
||||
// Append to maintain oldest-first order (matches Load ordering)
|
||||
// so GetChannelMessages reverse iteration stays correct
|
||||
s.byPayloadType[pt] = append([]*StoreTx{tx}, s.byPayloadType[pt]...)
|
||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||
}
|
||||
|
||||
if _, exists := broadcastTxs[r.txID]; !exists {
|
||||
@@ -1079,8 +1095,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
log.Printf("[poller] IngestNewFromDB: found %d new txs, maxID %d->%d", len(result), sinceID, newMaxID)
|
||||
|
||||
return result, newMaxID
|
||||
}
|
||||
|
||||
@@ -1263,8 +1277,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
log.Printf("[poller] IngestNewObservations: updated %d existing txs, maxObsID %d->%d",
|
||||
len(updatedTxs), sinceObsID, newMaxObsID)
|
||||
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
||||
}
|
||||
|
||||
return newMaxObsID
|
||||
@@ -1888,7 +1901,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int)
|
||||
msgMap := map[string]*msgEntry{}
|
||||
var msgOrder []string
|
||||
|
||||
// Iterate type-5 packets oldest-first (byPayloadType is in load order = newest first)
|
||||
// Iterate type-5 packets oldest-first (byPayloadType is ASC = oldest first)
|
||||
type decodedMsg struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
@@ -1899,8 +1912,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int)
|
||||
}
|
||||
|
||||
grpTxts := s.byPayloadType[5]
|
||||
for i := len(grpTxts) - 1; i >= 0; i-- {
|
||||
tx := grpTxts[i]
|
||||
for _, tx := range grpTxts {
|
||||
if tx.DecodedJSON == "" {
|
||||
continue
|
||||
}
|
||||
@@ -3715,8 +3727,26 @@ type hashSizeNodeInfo struct {
|
||||
Inconsistent bool
|
||||
}
|
||||
|
||||
// GetNodeHashSizeInfo scans advert packets to compute per-node hash size data.
|
||||
// GetNodeHashSizeInfo returns cached per-node hash size data, recomputing at most every 15s.
|
||||
func (s *PacketStore) GetNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
const ttl = 15 * time.Second
|
||||
s.hashSizeInfoMu.Lock()
|
||||
if s.hashSizeInfoCache != nil && time.Since(s.hashSizeInfoAt) < ttl {
|
||||
cached := s.hashSizeInfoCache
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
result := s.computeNodeHashSizeInfo()
|
||||
s.hashSizeInfoMu.Lock()
|
||||
s.hashSizeInfoCache = result
|
||||
s.hashSizeInfoAt = time.Now()
|
||||
s.hashSizeInfoMu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
// computeNodeHashSizeInfo scans advert packets to compute per-node hash size data.
|
||||
func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -4069,13 +4099,13 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
|
||||
lhVal = lastHeard
|
||||
}
|
||||
|
||||
// Recent packets (up to 20, newest first — packets are already sorted DESC)
|
||||
// Recent packets (up to 20, newest first — read from tail of oldest-first slice)
|
||||
recentLimit := 20
|
||||
if len(packets) < recentLimit {
|
||||
recentLimit = len(packets)
|
||||
}
|
||||
recentPackets := make([]map[string]interface{}, 0, recentLimit)
|
||||
for i := 0; i < recentLimit; i++ {
|
||||
for i := len(packets) - 1; i >= len(packets)-recentLimit; i-- {
|
||||
p := txToMap(packets[i])
|
||||
delete(p, "observations")
|
||||
recentPackets = append(recentPackets, p)
|
||||
|
||||
@@ -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.
|
||||
# CRITICAL: All data mounts use bind mounts (~/path), NOT named volumes.
|
||||
# This ensures the DB and theme are visible on the host filesystem for backup.
|
||||
|
||||
services:
|
||||
prod:
|
||||
build: .
|
||||
image: corescope:latest
|
||||
container_name: corescope-prod
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
|
||||
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
|
||||
@@ -24,9 +29,12 @@ services:
|
||||
retries: 3
|
||||
|
||||
staging:
|
||||
build: .
|
||||
image: corescope:latest
|
||||
container_name: corescope-staging
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${STAGING_HTTP_PORT:-81}:${STAGING_HTTP_PORT:-81}"
|
||||
- "${STAGING_MQTT_PORT:-1884}:1883"
|
||||
@@ -55,6 +63,8 @@ services:
|
||||
image: corescope-go:latest
|
||||
container_name: corescope-staging-go
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
||||
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
|
||||
@@ -76,6 +86,7 @@ services:
|
||||
- staging-go
|
||||
|
||||
volumes:
|
||||
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
|
||||
caddy-data:
|
||||
caddy-data-staging:
|
||||
caddy-data-staging-go:
|
||||
|
||||
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
|
||||
# Usage: ./manage.sh [command]
|
||||
#
|
||||
# All container management goes through docker compose.
|
||||
# Container config lives in docker-compose.yml — this script is just a wrapper.
|
||||
#
|
||||
# Idempotent: safe to cancel and re-run at any point.
|
||||
# Each step checks what's already done and skips it.
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="corescope"
|
||||
IMAGE_NAME="corescope"
|
||||
DATA_VOLUME="meshcore-data"
|
||||
CADDY_VOLUME="caddy-data"
|
||||
STATE_FILE=".setup-state"
|
||||
|
||||
# Source .env for port/path overrides (if present)
|
||||
# Source .env for port/path overrides (same file docker compose reads)
|
||||
[ -f .env ] && set -a && . ./.env && set +a
|
||||
|
||||
# Docker Compose mode detection
|
||||
COMPOSE_MODE=false
|
||||
if [ -f docker-compose.yml ]; then
|
||||
COMPOSE_MODE=true
|
||||
fi
|
||||
|
||||
# Resolved paths for prod/staging data
|
||||
# Resolved paths for prod/staging data (must match docker-compose.yml)
|
||||
PROD_DATA="${PROD_DATA_DIR:-$HOME/meshcore-data}"
|
||||
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
|
||||
@@ -51,83 +45,6 @@ is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null;
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Determine the correct data volume/mount args for docker run.
|
||||
# Detects existing host data directories and uses bind mounts if found.
|
||||
get_data_mount_args() {
|
||||
# Check for existing host data directories with a DB file
|
||||
if [ -d "$HOME/meshcore-data" ] && [ -f "$HOME/meshcore-data/meshcore.db" ]; then
|
||||
echo "-v $HOME/meshcore-data:/app/data"
|
||||
return
|
||||
fi
|
||||
if [ -d "$(pwd)/data" ] && [ -f "$(pwd)/data/meshcore.db" ]; then
|
||||
echo "-v $(pwd)/data:/app/data"
|
||||
return
|
||||
fi
|
||||
# Default: Docker named volume
|
||||
echo "-v ${DATA_VOLUME}:/app/data"
|
||||
}
|
||||
|
||||
# Determine the required port mappings from Caddyfile
|
||||
get_required_ports() {
|
||||
local caddyfile_domain
|
||||
caddyfile_domain=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
||||
if echo "$caddyfile_domain" | grep -qE '^:[0-9]+$'; then
|
||||
# HTTP-only on a specific port (e.g., :80, :8080)
|
||||
echo "${caddyfile_domain#:}"
|
||||
else
|
||||
# Domain name — needs 80 + 443 for Caddy auto-TLS
|
||||
echo "80 443"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get current container port mappings (just the host ports)
|
||||
get_current_ports() {
|
||||
docker inspect "$CONTAINER_NAME" 2>/dev/null | \
|
||||
grep -oP '"HostPort":\s*"\K[0-9]+' | sort -u | tr '\n' ' ' | sed 's/ $//'
|
||||
}
|
||||
|
||||
# Check if container port mappings match what's needed.
|
||||
# Returns 0 if they match, 1 if mismatch.
|
||||
check_port_match() {
|
||||
local required current
|
||||
required=$(get_required_ports | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
||||
current=$(get_current_ports | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')
|
||||
[ "$required" = "$current" ]
|
||||
}
|
||||
|
||||
# Build the docker run command args (ports + volumes)
|
||||
get_docker_run_args() {
|
||||
local ports_arg=""
|
||||
for port in $(get_required_ports); do
|
||||
ports_arg="$ports_arg -p ${port}:${port}"
|
||||
done
|
||||
|
||||
local data_mount
|
||||
data_mount=$(get_data_mount_args)
|
||||
|
||||
echo "$ports_arg \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
-v $(pwd)/caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
$data_mount \
|
||||
-v ${CADDY_VOLUME}:/data/caddy"
|
||||
}
|
||||
|
||||
# Recreate the container with current settings
|
||||
recreate_container() {
|
||||
info "Stopping and removing old container..."
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
local run_args
|
||||
run_args=$(get_docker_run_args)
|
||||
|
||||
eval docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
$run_args \
|
||||
"$IMAGE_NAME"
|
||||
}
|
||||
|
||||
# Check config.json for placeholder values
|
||||
check_config_placeholders() {
|
||||
if [ -f config.json ]; then
|
||||
@@ -140,7 +57,7 @@ check_config_placeholders() {
|
||||
|
||||
# Verify the running container is actually healthy
|
||||
verify_health() {
|
||||
local base_url="http://localhost:3000"
|
||||
local container="corescope-prod"
|
||||
local use_https=false
|
||||
|
||||
# Check if Caddyfile has a real domain (not :80)
|
||||
@@ -156,7 +73,7 @@ verify_health() {
|
||||
info "Waiting for server to respond..."
|
||||
local healthy=false
|
||||
for i in $(seq 1 45); do
|
||||
if docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
||||
if docker exec "$container" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
@@ -172,7 +89,7 @@ verify_health() {
|
||||
|
||||
# Check for MQTT errors in recent logs
|
||||
local mqtt_errors
|
||||
mqtt_errors=$(docker logs "$CONTAINER_NAME" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||
mqtt_errors=$(docker logs "$container" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||
if [ -n "$mqtt_errors" ]; then
|
||||
warn "MQTT errors detected in logs:"
|
||||
echo "$mqtt_errors" | head -5 | sed 's/^/ /'
|
||||
@@ -234,6 +151,13 @@ cmd_setup() {
|
||||
fi
|
||||
|
||||
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
|
||||
|
||||
# Check docker compose (separate check since it's a plugin/separate binary)
|
||||
if ! docker compose version &>/dev/null; then
|
||||
err "docker compose is required. Install Docker Desktop or docker-compose-plugin."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mark_done "docker"
|
||||
|
||||
# ── Step 2: Config ──
|
||||
@@ -371,12 +295,12 @@ cmd_setup() {
|
||||
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
|
||||
log "Image already built."
|
||||
if confirm "Rebuild? (only needed if you updated the code)"; then
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
||||
docker compose build prod
|
||||
log "Image rebuilt."
|
||||
fi
|
||||
else
|
||||
info "This takes 1-2 minutes the first time..."
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
||||
docker compose build prod
|
||||
log "Image built."
|
||||
fi
|
||||
mark_done "build"
|
||||
@@ -385,45 +309,15 @@ cmd_setup() {
|
||||
step 5 "Starting container"
|
||||
|
||||
# Detect existing data directories
|
||||
if [ -d "$HOME/meshcore-data" ] && [ -f "$HOME/meshcore-data/meshcore.db" ]; then
|
||||
info "Found existing data at \$HOME/meshcore-data/ — will use bind mount."
|
||||
elif [ -d "$(pwd)/data" ] && [ -f "$(pwd)/data/meshcore.db" ]; then
|
||||
info "Found existing data at ./data/ — will use bind mount."
|
||||
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||
info "Found existing data at $PROD_DATA/ — will use bind mount."
|
||||
fi
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
||||
log "Container already running."
|
||||
# Check port mappings match
|
||||
if ! check_port_match; then
|
||||
warn "Container port mappings don't match Caddyfile configuration."
|
||||
warn "Current ports: $(get_current_ports)"
|
||||
warn "Required ports: $(get_required_ports)"
|
||||
if confirm "Recreate container with correct ports?"; then
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
fi
|
||||
fi
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
# Exists but stopped — check ports before starting
|
||||
if ! check_port_match; then
|
||||
warn "Stopped container has wrong port mappings."
|
||||
warn "Current ports: $(get_current_ports)"
|
||||
warn "Required ports: $(get_required_ports)"
|
||||
if confirm "Recreate container with correct ports?"; then
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
else
|
||||
info "Starting existing container (ports unchanged)..."
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started (with old port mappings)."
|
||||
fi
|
||||
else
|
||||
info "Container exists but is stopped. Starting..."
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started."
|
||||
fi
|
||||
else
|
||||
recreate_container
|
||||
mkdir -p "$PROD_DATA"
|
||||
docker compose up -d prod
|
||||
log "Container started."
|
||||
fi
|
||||
mark_done "container"
|
||||
@@ -431,7 +325,7 @@ cmd_setup() {
|
||||
# ── Step 6: Verify ──
|
||||
step 6 "Verifying"
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
||||
verify_health
|
||||
|
||||
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
||||
@@ -463,7 +357,7 @@ cmd_setup() {
|
||||
err "Container failed to start."
|
||||
echo ""
|
||||
echo " Check what went wrong:"
|
||||
echo " docker logs ${CONTAINER_NAME}"
|
||||
echo " docker compose logs prod"
|
||||
echo ""
|
||||
echo " Common fixes:"
|
||||
echo " • Invalid config.json — check JSON syntax"
|
||||
@@ -535,132 +429,72 @@ cmd_start() {
|
||||
WITH_STAGING=true
|
||||
fi
|
||||
|
||||
if $COMPOSE_MODE; then
|
||||
if $WITH_STAGING; then
|
||||
# Prepare staging data and config
|
||||
prepare_staging_db
|
||||
prepare_staging_config
|
||||
if $WITH_STAGING; then
|
||||
# Prepare staging data and config
|
||||
prepare_staging_db
|
||||
prepare_staging_config
|
||||
|
||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
info "Starting staging container (corescope-staging) on port ${STAGING_HTTP_PORT:-81}..."
|
||||
docker compose --profile staging up -d
|
||||
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
|
||||
log "Staging started on port ${STAGING_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
|
||||
else
|
||||
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
|
||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
info "Starting staging container (corescope-staging) on port ${STAGING_HTTP_PORT:-81}..."
|
||||
docker compose --profile staging up -d
|
||||
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
|
||||
log "Staging started on port ${STAGING_HTTP_PORT:-81} (MQTT: ${STAGING_MQTT_PORT:-1884})"
|
||||
else
|
||||
# Legacy single-container mode
|
||||
if $WITH_STAGING; then
|
||||
err "--with-staging requires docker-compose.yml. Run setup or add docker-compose.yml first."
|
||||
exit 1
|
||||
fi
|
||||
warn "No docker-compose.yml found — using legacy single-container mode."
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
warn "Already running."
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! check_port_match; then
|
||||
warn "Container port mappings don't match Caddyfile configuration."
|
||||
warn "Current ports: $(get_current_ports)"
|
||||
warn "Required ports: $(get_required_ports)"
|
||||
if confirm "Recreate container with correct ports?"; then
|
||||
recreate_container
|
||||
log "Container recreated and started with correct ports."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started."
|
||||
else
|
||||
err "Container doesn't exist. Run './manage.sh setup' first."
|
||||
exit 1
|
||||
fi
|
||||
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
||||
docker compose up -d prod
|
||||
log "Production started. Staging NOT running (use --with-staging to start both)."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local TARGET="${1:-all}"
|
||||
|
||||
if $COMPOSE_MODE; then
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Stopping production container (corescope-prod)..."
|
||||
docker compose stop prod
|
||||
log "Production stopped."
|
||||
;;
|
||||
staging)
|
||||
info "Stopping staging container (corescope-staging)..."
|
||||
docker compose stop staging
|
||||
log "Staging stopped."
|
||||
;;
|
||||
all)
|
||||
info "Stopping all containers..."
|
||||
docker compose --profile staging --profile staging-go down 2>/dev/null
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null
|
||||
log "All containers stopped."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh stop [prod|staging|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Legacy mode
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null && log "Stopped." || warn "Not running."
|
||||
fi
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Stopping production container (corescope-prod)..."
|
||||
docker compose stop prod
|
||||
log "Production stopped."
|
||||
;;
|
||||
staging)
|
||||
info "Stopping staging container (corescope-staging)..."
|
||||
docker compose --profile staging stop staging
|
||||
log "Staging stopped."
|
||||
;;
|
||||
all)
|
||||
info "Stopping all containers..."
|
||||
docker compose --profile staging --profile staging-go down
|
||||
log "All containers stopped."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh stop [prod|staging|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
if $COMPOSE_MODE; then
|
||||
local TARGET="${1:-prod}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Restarting production container (corescope-prod)..."
|
||||
docker compose up -d --force-recreate prod
|
||||
log "Production restarted."
|
||||
;;
|
||||
staging)
|
||||
info "Restarting staging container (corescope-staging)..."
|
||||
docker compose --profile staging up -d --force-recreate staging
|
||||
log "Staging restarted."
|
||||
;;
|
||||
all)
|
||||
info "Restarting all containers..."
|
||||
docker compose --profile staging up -d --force-recreate
|
||||
log "All containers restarted."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh restart [prod|staging|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Legacy mode
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! check_port_match; then
|
||||
warn "Port mappings have changed. Recreating container..."
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
else
|
||||
docker restart "$CONTAINER_NAME"
|
||||
log "Restarted."
|
||||
fi
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! check_port_match; then
|
||||
warn "Port mappings have changed. Recreating container..."
|
||||
recreate_container
|
||||
log "Container recreated with correct ports."
|
||||
else
|
||||
docker start "$CONTAINER_NAME"
|
||||
log "Started."
|
||||
fi
|
||||
else
|
||||
err "Not running. Use './manage.sh setup'."
|
||||
local TARGET="${1:-prod}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Restarting production container (corescope-prod)..."
|
||||
docker compose up -d --force-recreate prod
|
||||
log "Production restarted."
|
||||
;;
|
||||
staging)
|
||||
info "Restarting staging container (corescope-staging)..."
|
||||
docker compose --profile staging up -d --force-recreate staging
|
||||
log "Staging restarted."
|
||||
;;
|
||||
all)
|
||||
info "Restarting all containers..."
|
||||
docker compose --profile staging up -d --force-recreate
|
||||
log "All containers restarted."
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh restart [prod|staging|all]"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Status ───────────────────────────────────────────────────────────────
|
||||
@@ -695,143 +529,68 @@ show_container_status() {
|
||||
|
||||
cmd_status() {
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " CoreScope Status"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
if $COMPOSE_MODE; then
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " CoreScope Status (Compose)"
|
||||
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
|
||||
# Production
|
||||
show_container_status "corescope-prod" "Production"
|
||||
echo ""
|
||||
|
||||
# Staging
|
||||
if container_running "corescope-staging"; then
|
||||
show_container_status "corescope-staging" "Staging"
|
||||
else
|
||||
# Legacy single-container status
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
log "Container is running."
|
||||
echo ""
|
||||
docker ps --filter "name=${CONTAINER_NAME}" --format " Status: {{.Status}}"
|
||||
docker ps --filter "name=${CONTAINER_NAME}" --format " Ports: {{.Ports}}"
|
||||
echo ""
|
||||
|
||||
info "Service health:"
|
||||
# Server
|
||||
if docker exec "$CONTAINER_NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then
|
||||
STATS=$(docker exec "$CONTAINER_NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null)
|
||||
PACKETS=$(echo "$STATS" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?")
|
||||
NODES=$(echo "$STATS" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?")
|
||||
log " Server — ${PACKETS} packets, ${NODES} nodes"
|
||||
else
|
||||
err " Server — not responding"
|
||||
fi
|
||||
|
||||
# Mosquitto
|
||||
if docker exec "$CONTAINER_NAME" pgrep mosquitto &>/dev/null; then
|
||||
log " Mosquitto — running"
|
||||
else
|
||||
err " Mosquitto — not running"
|
||||
fi
|
||||
|
||||
# Caddy
|
||||
if docker exec "$CONTAINER_NAME" pgrep caddy &>/dev/null; then
|
||||
log " Caddy — running"
|
||||
else
|
||||
err " Caddy — not running"
|
||||
fi
|
||||
|
||||
# Check for MQTT errors in recent logs
|
||||
MQTT_ERRORS=$(docker logs "$CONTAINER_NAME" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
||||
if [ -n "$MQTT_ERRORS" ]; then
|
||||
echo ""
|
||||
warn "MQTT errors in recent logs:"
|
||||
echo "$MQTT_ERRORS" | head -3 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# Port mapping check
|
||||
if ! check_port_match; then
|
||||
echo ""
|
||||
warn "Port mappings don't match Caddyfile. Run './manage.sh restart' to fix."
|
||||
fi
|
||||
|
||||
# Disk usage
|
||||
DB_SIZE=$(docker exec "$CONTAINER_NAME" du -h /app/data/meshcore.db 2>/dev/null | cut -f1)
|
||||
if [ -n "$DB_SIZE" ]; then
|
||||
echo ""
|
||||
info "Database size: ${DB_SIZE}"
|
||||
fi
|
||||
else
|
||||
err "Container is not running."
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo " Start with: ./manage.sh start"
|
||||
else
|
||||
echo " Set up with: ./manage.sh setup"
|
||||
fi
|
||||
fi
|
||||
info "Staging (corescope-staging): Not running (use --with-staging to start both)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Disk usage
|
||||
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
||||
local db_size
|
||||
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||
info "Production DB: ${db_size}"
|
||||
fi
|
||||
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
|
||||
local staging_db_size
|
||||
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
||||
info "Staging DB: ${staging_db_size}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_logs() {
|
||||
if $COMPOSE_MODE; then
|
||||
local TARGET="${1:-prod}"
|
||||
local LINES="${2:-100}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Tailing production logs..."
|
||||
docker compose logs -f --tail="$LINES" prod
|
||||
;;
|
||||
staging)
|
||||
if container_running "corescope-staging"; then
|
||||
info "Tailing staging logs..."
|
||||
docker compose logs -f --tail="$LINES" staging
|
||||
else
|
||||
err "Staging container is not running."
|
||||
info "Start with: ./manage.sh start --with-staging"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
||||
local TARGET="${1:-prod}"
|
||||
local LINES="${2:-100}"
|
||||
case "$TARGET" in
|
||||
prod)
|
||||
info "Tailing production logs..."
|
||||
docker compose logs -f --tail="$LINES" prod
|
||||
;;
|
||||
staging)
|
||||
if container_running "corescope-staging"; then
|
||||
info "Tailing staging logs..."
|
||||
docker compose logs -f --tail="$LINES" staging
|
||||
else
|
||||
err "Staging container is not running."
|
||||
info "Start with: ./manage.sh start --with-staging"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Legacy mode
|
||||
docker logs -f "$CONTAINER_NAME" --tail "${1:-100}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Promote ──────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_promote() {
|
||||
if ! $COMPOSE_MODE; then
|
||||
err "Promotion requires Docker Compose setup (docker-compose.yml)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Promotion Flow: Staging → Production"
|
||||
echo ""
|
||||
@@ -906,10 +665,10 @@ cmd_update() {
|
||||
git pull
|
||||
|
||||
info "Rebuilding image..."
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t "$IMAGE_NAME" .
|
||||
docker compose build prod
|
||||
|
||||
info "Restarting with new image..."
|
||||
recreate_container
|
||||
docker compose up -d --force-recreate prod
|
||||
|
||||
log "Updated and restarted. Data preserved."
|
||||
}
|
||||
@@ -924,12 +683,13 @@ cmd_backup() {
|
||||
info "Backing up to ${BACKUP_DIR}/"
|
||||
|
||||
# Database
|
||||
DB_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
|
||||
# Always use bind mount path (from .env or default)
|
||||
DB_PATH="$PROD_DATA/meshcore.db"
|
||||
if [ -f "$DB_PATH" ]; then
|
||||
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
|
||||
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
|
||||
elif docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
docker cp "${CONTAINER_NAME}:/app/data/meshcore.db" "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
||||
elif container_running "corescope-prod"; then
|
||||
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
||||
log "Database (via docker cp)" || warn "Could not backup database"
|
||||
else
|
||||
warn "Database not found (container not running?)"
|
||||
@@ -948,7 +708,8 @@ cmd_backup() {
|
||||
fi
|
||||
|
||||
# Theme
|
||||
THEME_PATH=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
|
||||
# Always use bind mount path (from .env or default)
|
||||
THEME_PATH="$PROD_DATA/theme.json"
|
||||
if [ -f "$THEME_PATH" ]; then
|
||||
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
|
||||
log "theme.json"
|
||||
@@ -1021,15 +782,12 @@ cmd_restore() {
|
||||
info "Backing up current state..."
|
||||
cmd_backup "./backups/corescope-pre-restore-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker compose stop prod 2>/dev/null || true
|
||||
|
||||
# Restore database
|
||||
DEST_DB=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/meshcore.db
|
||||
if [ -d "$(dirname "$DEST_DB")" ]; then
|
||||
cp "$DB_FILE" "$DEST_DB"
|
||||
else
|
||||
docker cp "$DB_FILE" "${CONTAINER_NAME}:/app/data/meshcore.db"
|
||||
fi
|
||||
mkdir -p "$PROD_DATA"
|
||||
DEST_DB="$PROD_DATA/meshcore.db"
|
||||
cp "$DB_FILE" "$DEST_DB"
|
||||
log "Database restored"
|
||||
|
||||
# Restore config if present
|
||||
@@ -1047,27 +805,25 @@ cmd_restore() {
|
||||
|
||||
# Restore theme if present
|
||||
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
|
||||
DEST_THEME=$(docker volume inspect "$DATA_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null)/theme.json
|
||||
if [ -d "$(dirname "$DEST_THEME")" ]; then
|
||||
cp "$THEME_FILE" "$DEST_THEME"
|
||||
fi
|
||||
DEST_THEME="$PROD_DATA/theme.json"
|
||||
cp "$THEME_FILE" "$DEST_THEME"
|
||||
log "theme.json restored"
|
||||
fi
|
||||
|
||||
docker start "$CONTAINER_NAME"
|
||||
docker compose up -d prod
|
||||
log "Restored and restarted."
|
||||
}
|
||||
|
||||
# ─── MQTT Test ────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_mqtt_test() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if ! container_running "corescope-prod"; then
|
||||
err "Container not running. Start with: ./manage.sh start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Listening for MQTT messages (10 second timeout)..."
|
||||
MSG=$(docker exec "$CONTAINER_NAME" mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
||||
MSG=$(docker exec corescope-prod mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
||||
if [ -n "$MSG" ]; then
|
||||
log "Received MQTT message:"
|
||||
echo " $MSG" | head -c 200
|
||||
@@ -1084,21 +840,19 @@ cmd_mqtt_test() {
|
||||
|
||||
cmd_reset() {
|
||||
echo ""
|
||||
warn "This will remove the container, image, and setup state."
|
||||
warn "Your config.json, Caddyfile, and data volume are NOT deleted."
|
||||
warn "This will remove all containers, images, and setup state."
|
||||
warn "Your config.json, Caddyfile, and data directory are NOT deleted."
|
||||
echo ""
|
||||
if ! confirm "Continue?"; then
|
||||
echo " Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rmi "$IMAGE_NAME" 2>/dev/null || true
|
||||
docker compose --profile staging --profile staging-go down --rmi local 2>/dev/null || true
|
||||
rm -f "$STATE_FILE"
|
||||
|
||||
log "Reset complete. Run './manage.sh setup' to start over."
|
||||
echo " Data volume preserved. To delete it: docker volume rm ${DATA_VOLUME}"
|
||||
echo " Data directory: $PROD_DATA (not removed)"
|
||||
}
|
||||
|
||||
# ─── Help ─────────────────────────────────────────────────────────────────
|
||||
@@ -1128,11 +882,7 @@ cmd_help() {
|
||||
echo " restore <d> Restore from backup dir or .db file"
|
||||
echo " mqtt-test Check if MQTT data is flowing"
|
||||
echo ""
|
||||
if $COMPOSE_MODE; then
|
||||
info "Docker Compose mode detected (docker-compose.yml present)."
|
||||
else
|
||||
warn "Legacy mode (no docker-compose.yml). Some commands unavailable."
|
||||
fi
|
||||
echo "All commands use docker compose with docker-compose.yml."
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ async function collectCoverage() {
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Home page — chooser...');
|
||||
// Clear localStorage to get chooser
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Click "I'm new"
|
||||
await safeClick('#chooseNew');
|
||||
@@ -105,7 +105,7 @@ async function collectCoverage() {
|
||||
|
||||
// Switch to experienced mode
|
||||
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#chooseExp');
|
||||
|
||||
// Interact with experienced home page
|
||||
@@ -120,7 +120,7 @@ async function collectCoverage() {
|
||||
// NODES PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Nodes page...');
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Sort by EVERY column
|
||||
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
|
||||
@@ -168,7 +168,7 @@ async function collectCoverage() {
|
||||
try {
|
||||
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
|
||||
if (firstNodeKey) {
|
||||
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Click tabs on detail page
|
||||
await clickAll('.tab-btn, [data-tab]', 10);
|
||||
@@ -191,7 +191,7 @@ async function collectCoverage() {
|
||||
try {
|
||||
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
|
||||
if (firstKey) {
|
||||
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -199,7 +199,7 @@ async function collectCoverage() {
|
||||
// PACKETS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Packets page...');
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Open filter bar
|
||||
await safeClick('#filterToggleBtn');
|
||||
@@ -285,13 +285,13 @@ async function collectCoverage() {
|
||||
} catch {}
|
||||
|
||||
// Navigate to specific packet by hash
|
||||
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// MAP PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Map page...');
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Toggle controls panel
|
||||
await safeClick('#mapControlsToggle');
|
||||
@@ -345,7 +345,7 @@ async function collectCoverage() {
|
||||
// ANALYTICS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Analytics page...');
|
||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Click EVERY analytics tab
|
||||
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
|
||||
@@ -383,7 +383,7 @@ async function collectCoverage() {
|
||||
|
||||
// Deep-link to each analytics tab via URL
|
||||
for (const tab of analyticsTabs) {
|
||||
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// Region filter on analytics
|
||||
@@ -396,7 +396,7 @@ async function collectCoverage() {
|
||||
// CUSTOMIZE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Customizer...');
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#customizeToggle');
|
||||
|
||||
// Click EVERY customizer tab
|
||||
@@ -503,7 +503,7 @@ async function collectCoverage() {
|
||||
// CHANNELS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Channels page...');
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
// Click channel rows/items
|
||||
await clickAll('.channel-item, .channel-row, .channel-card', 3);
|
||||
await clickAll('table tbody tr', 3);
|
||||
@@ -512,7 +512,7 @@ async function collectCoverage() {
|
||||
try {
|
||||
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
|
||||
if (channelHash) {
|
||||
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -520,7 +520,7 @@ async function collectCoverage() {
|
||||
// LIVE PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Live page...');
|
||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// VCR controls
|
||||
await safeClick('#vcrPauseBtn');
|
||||
@@ -603,14 +603,14 @@ async function collectCoverage() {
|
||||
// TRACES PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Traces page...');
|
||||
await page.goto(`${BASE}/#/traces`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/traces`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await clickAll('table tbody tr', 3);
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// OBSERVERS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Observers page...');
|
||||
await page.goto(`${BASE}/#/observers`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
// Click observer rows
|
||||
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
|
||||
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
|
||||
@@ -631,7 +631,7 @@ async function collectCoverage() {
|
||||
// PERF PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Perf page...');
|
||||
await page.goto(`${BASE}/#/perf`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/perf`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#perfRefresh');
|
||||
await safeClick('#perfReset');
|
||||
|
||||
@@ -641,14 +641,14 @@ async function collectCoverage() {
|
||||
console.log(' [coverage] App.js — router + global...');
|
||||
|
||||
// Navigate to bad route to trigger error/404
|
||||
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Navigate to every route via hash
|
||||
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
|
||||
for (const route of allRoutes) {
|
||||
try {
|
||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||
await page.waitForLoadState('networkidle').catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -788,14 +788,14 @@ async function collectCoverage() {
|
||||
console.log(' [coverage] Region filter...');
|
||||
try {
|
||||
// Open region filter on nodes page
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#nodesRegionFilter');
|
||||
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
|
||||
} catch {}
|
||||
|
||||
// Region filter on packets
|
||||
try {
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#packetsRegionFilter');
|
||||
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
|
||||
} catch {}
|
||||
@@ -807,7 +807,7 @@ async function collectCoverage() {
|
||||
for (const route of allRoutes) {
|
||||
try {
|
||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||
await page.waitForLoadState('networkidle').catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -909,6 +909,19 @@ async function run() {
|
||||
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
||||
});
|
||||
|
||||
// Extract frontend coverage if instrumented server is running
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
if (coverage) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const outDir = path.join(__dirname, '.nyc_output');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'e2e-coverage.json'), JSON.stringify(coverage));
|
||||
console.log(`Frontend coverage from E2E: ${Object.keys(coverage).length} files`);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Summary
|
||||
|
||||
Reference in New Issue
Block a user