mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 20:55:09 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c233c14156 | |||
| 65482ff6f6 | |||
| 7af91f7ef6 | |||
| f95aa49804 | |||
| 45623672d9 | |||
| 4a7e20a8cb | |||
| 7e0b904d09 | |||
| e893a1b3c4 | |||
| fcba2a9f3d | |||
| c6a0f91b07 | |||
| ef8bce5002 | |||
| 922ebe54e7 | |||
| 26c47df814 | |||
| bc22dbdb14 | |||
| 9917d50622 | |||
| 2e1a4a2e0d | |||
| fcad49594b | |||
| a1e1e0bd2f | |||
| 34e7366d7c | |||
| 111b03cea1 | |||
| 34c56d203e | |||
| cc9f25e5c8 | |||
| 2e33eb7050 | |||
| 6dd0957507 | |||
| e22ee3f0ad | |||
| f7f1bb08d0 | |||
| 84da4d962d | |||
| ad0a10c009 | |||
| c1f268d3b9 | |||
| f5d25f75c6 | |||
| cde62166cb | |||
| 5606bc639e | |||
| 1373106b50 | |||
| 68a4628edf | |||
| 00953207fb | |||
| 16a72b66a9 | |||
| e0e9aaa324 | |||
| 22bf33700e | |||
| b8e9b04a97 | |||
| 7d71dc857b | |||
| 088b4381c3 | |||
| 1ff094b852 | |||
| 144e98bcdf | |||
| bd54707987 | |||
| 1033555d00 | |||
| 37be3dcd1f | |||
| 2bff89a546 |
@@ -68,6 +68,17 @@ jobs:
|
||||
echo "--- Go Ingestor Coverage ---"
|
||||
go tool cover -func=ingestor-coverage.out | tail -1
|
||||
|
||||
- name: Build and test channel library + decrypt CLI
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd internal/channel
|
||||
go test ./...
|
||||
echo "--- Channel library tests passed ---"
|
||||
cd ../../cmd/decrypt
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o corescope-decrypt .
|
||||
go test ./...
|
||||
echo "--- Decrypt CLI tests passed ---"
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
set -e
|
||||
@@ -275,10 +286,6 @@ jobs:
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE"
|
||||
echo "Built Go staging image ✅"
|
||||
|
||||
- name: Set up QEMU
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -310,7 +317,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
build-args: |
|
||||
@@ -321,7 +328,43 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 4. Deploy Staging (master only)
|
||||
# 4. Release Artifacts (tags only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
release-artifacts:
|
||||
name: "📦 Release Artifacts"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [go-test]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Build corescope-decrypt (static, linux/amd64)
|
||||
run: |
|
||||
cd cmd/decrypt
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" -o ../../corescope-decrypt-linux-amd64 .
|
||||
|
||||
- name: Build corescope-decrypt (static, linux/arm64)
|
||||
run: |
|
||||
cd cmd/decrypt
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" -o ../../corescope-decrypt-linux-arm64 .
|
||||
|
||||
- name: Upload release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
corescope-decrypt-linux-amd64
|
||||
corescope-decrypt-linux-arm64
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 4b. Deploy Staging (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: "🚀 Deploy Staging"
|
||||
|
||||
+12
-1
@@ -10,6 +10,7 @@ ARG BUILD_TIME=unknown
|
||||
WORKDIR /build/server
|
||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||
COPY internal/geofilter/ ../../internal/geofilter/
|
||||
COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||
@@ -17,10 +18,20 @@ RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMI
|
||||
# Build ingestor
|
||||
WORKDIR /build/ingestor
|
||||
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
|
||||
COPY internal/geofilter/ ../../internal/geofilter/
|
||||
COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN go build -o /corescope-ingestor .
|
||||
|
||||
# Build decrypt CLI
|
||||
WORKDIR /build/decrypt
|
||||
COPY cmd/decrypt/go.mod cmd/decrypt/go.sum ./
|
||||
COPY internal/channel/ ../../internal/channel/
|
||||
RUN go mod download
|
||||
COPY cmd/decrypt/ ./
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /corescope-decrypt .
|
||||
|
||||
# Runtime image
|
||||
FROM alpine:3.20
|
||||
|
||||
@@ -29,7 +40,7 @@ RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy wget
|
||||
WORKDIR /app
|
||||
|
||||
# Go binaries
|
||||
COPY --from=builder /corescope-server /corescope-ingestor /app/
|
||||
COPY --from=builder /corescope-server /corescope-ingestor /corescope-decrypt /app/
|
||||
|
||||
# Frontend assets + config
|
||||
COPY public/ ./public/
|
||||
|
||||
@@ -80,15 +80,26 @@ No build step required — just run:
|
||||
|
||||
```bash
|
||||
docker run -d --name corescope \
|
||||
-p 80:80 \
|
||||
-v corescope-data:/app/data \
|
||||
--restart=unless-stopped \
|
||||
-p 80:80 -p 1883:1883 \
|
||||
-v /your/data:/app/data \
|
||||
ghcr.io/kpa-clawbot/corescope:latest
|
||||
```
|
||||
|
||||
Open `http://localhost` — done. No config file needed; CoreScope starts with sensible defaults.
|
||||
|
||||
See [DEPLOY.md](DEPLOY.md) for image tags, Docker Compose, and migration from `manage.sh`.
|
||||
See [docs/deployment.md](docs/deployment.md) for the full deployment guide — MQTT setup, HTTPS options, backups, monitoring, and troubleshooting.
|
||||
For HTTPS with a custom domain, add `-p 443:443` and mount your Caddyfile:
|
||||
```bash
|
||||
docker run -d --name corescope \
|
||||
--restart=unless-stopped \
|
||||
-p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v /your/data:/app/data \
|
||||
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
-v /your/caddy-data:/data/caddy \
|
||||
ghcr.io/kpa-clawbot/corescope:latest
|
||||
```
|
||||
|
||||
Disable built-in services with `-e DISABLE_MOSQUITTO=true` or `-e DISABLE_CADDY=true`, or drop a `.env` file in your data volume. See [docs/deployment.md](docs/deployment.md) for the full reference.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -254,6 +265,8 @@ Contributions welcome. Please read [AGENTS.md](AGENTS.md) for coding conventions
|
||||
|
||||
**Live instance:** [analyzer.00id.net](https://analyzer.00id.net) — all API endpoints are public, no auth required.
|
||||
|
||||
**API Documentation:** CoreScope auto-generates an OpenAPI 3.0 spec. Browse the interactive Swagger UI at [`/api/docs`](https://analyzer.00id.net/api/docs) or fetch the machine-readable spec at [`/api/spec`](https://analyzer.00id.net/api/spec).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# corescope-decrypt
|
||||
|
||||
Standalone CLI tool to decrypt and export MeshCore hashtag channel messages from a CoreScope SQLite database.
|
||||
|
||||
## Why
|
||||
|
||||
MeshCore hashtag channels use symmetric encryption where the key is derived deterministically from the channel name. The CoreScope ingestor stores **all** `GRP_TXT` packets in the database, including those it cannot decrypt at ingest time.
|
||||
|
||||
This tool enables:
|
||||
|
||||
- **Retroactive decryption** — decrypt historical messages for any channel whose name you learn after the fact
|
||||
- **Forensics & analysis** — export channel traffic for offline review
|
||||
- **Bulk export** — dump an entire channel's history as JSON, HTML, or plain text
|
||||
|
||||
## Installation
|
||||
|
||||
### From Docker image
|
||||
|
||||
The binary is included in the CoreScope Docker image at `/app/corescope-decrypt`:
|
||||
|
||||
```bash
|
||||
docker exec corescope-prod /app/corescope-decrypt --channel "#wardriving" --db /app/data/meshcore.db
|
||||
```
|
||||
|
||||
### From GitHub release
|
||||
|
||||
Download the static binary from the [Releases](https://github.com/Kpa-clawbot/CoreScope/releases) page:
|
||||
|
||||
```bash
|
||||
# Linux amd64
|
||||
curl -LO https://github.com/Kpa-clawbot/CoreScope/releases/latest/download/corescope-decrypt-linux-amd64
|
||||
chmod +x corescope-decrypt-linux-amd64
|
||||
./corescope-decrypt-linux-amd64 --help
|
||||
```
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
cd cmd/decrypt
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o corescope-decrypt .
|
||||
```
|
||||
|
||||
The binary is statically linked — no dependencies, runs on any Linux.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
corescope-decrypt --channel NAME --db PATH [--format FORMAT] [--output FILE]
|
||||
```
|
||||
|
||||
Run `corescope-decrypt --help` for full flag documentation.
|
||||
|
||||
### JSON output (default)
|
||||
|
||||
Machine-readable, includes all metadata (observers, path hops, raw hex):
|
||||
|
||||
```bash
|
||||
corescope-decrypt --channel "#wardriving" --db meshcore.db
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"hash": "a1b2c3...",
|
||||
"timestamp": "2026-04-12T17:19:09Z",
|
||||
"sender": "XMD Tag 1",
|
||||
"message": "@[MapperBot] 37.76985, -122.40525 [0.3w]",
|
||||
"channel": "#wardriving",
|
||||
"raw_hex": "150206...",
|
||||
"path": ["A3", "B0"],
|
||||
"observers": [
|
||||
{"name": "Observer1", "snr": 9.5, "rssi": -56, "timestamp": "2026-04-12T17:19:10Z"}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### HTML output
|
||||
|
||||
Self-contained interactive viewer — search, sortable columns, expandable detail rows:
|
||||
|
||||
```bash
|
||||
corescope-decrypt --channel "#wardriving" --db meshcore.db --format html --output wardriving.html
|
||||
open wardriving.html
|
||||
```
|
||||
|
||||
No external dependencies. The JSON data is embedded directly in the HTML file.
|
||||
|
||||
### IRC / log output
|
||||
|
||||
Plain-text, one line per message — ideal for `grep`, `awk`, and piping:
|
||||
|
||||
```bash
|
||||
corescope-decrypt --channel "#wardriving" --db meshcore.db --format irc
|
||||
```
|
||||
|
||||
```
|
||||
[2026-04-12 17:19:09] <XMD Tag 1> @[MapperBot] 37.76985, -122.40525 [0.3w]
|
||||
[2026-04-12 17:20:25] <XMD Tag 1> @[MapperBot] 37.78075, -122.39774 [0.3w]
|
||||
[2026-04-12 17:25:30] <mk 🤠> @[MapperBot] 35.32444, -120.62077
|
||||
```
|
||||
|
||||
```bash
|
||||
# Find all messages from a specific sender
|
||||
corescope-decrypt --channel "#wardriving" --db meshcore.db --format irc | grep "KE6QR"
|
||||
```
|
||||
|
||||
## How channel encryption works
|
||||
|
||||
MeshCore hashtag channels derive their encryption key from the channel name:
|
||||
|
||||
1. **Key derivation**: `AES-128 key = SHA-256("#channelname")[:16]` (first 16 bytes)
|
||||
2. **Channel hash**: `SHA-256(key)[0]` — 1-byte identifier in the packet header, used for fast filtering
|
||||
3. **Encryption**: AES-128-ECB
|
||||
4. **MAC**: HMAC-SHA256 with a 32-byte secret (key + 16 zero bytes), truncated to 2 bytes
|
||||
5. **Plaintext format**: `timestamp(4 LE) + flags(1) + "sender: message\0"`
|
||||
|
||||
See the firmware source at `firmware/src/helpers/BaseChatMesh.cpp` for the canonical implementation.
|
||||
|
||||
## Testing against the fixture DB
|
||||
|
||||
```bash
|
||||
cd cmd/decrypt
|
||||
go test ./...
|
||||
|
||||
# Manual test with the real fixture:
|
||||
go run . --channel "#wardriving" --db ../../test-fixtures/e2e-fixture.db --format irc
|
||||
```
|
||||
|
||||
The shared crypto library also has independent tests:
|
||||
|
||||
```bash
|
||||
cd internal/channel
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Hashtag channels only.** Only channels where the key is derived from `SHA-256("#name")` are supported. Custom PSK channels require the raw key (not implemented).
|
||||
- **No DM decryption.** Direct messages (`TXT_MSG`) use per-peer asymmetric encryption and cannot be decrypted by this tool.
|
||||
- **Read-only.** The tool opens the database in read-only mode and never modifies it.
|
||||
- **Timestamps are UTC.** The sender's embedded timestamp is used when available, displayed in UTC.
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/corescope/decrypt
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/meshcore-analyzer/channel v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/channel => ../../internal/channel
|
||||
@@ -0,0 +1,43 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,467 @@
|
||||
// corescope-decrypt decrypts and exports hashtag channel messages from a CoreScope SQLite database.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// corescope-decrypt --channel "#wardriving" --db meshcore.db [--format json|html] [--output file]
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/channel"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Version info (set via ldflags).
|
||||
var version = "dev"
|
||||
|
||||
// ChannelMessage is a single decrypted channel message with metadata.
|
||||
type ChannelMessage struct {
|
||||
Hash string `json:"hash"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Sender string `json:"sender"`
|
||||
Message string `json:"message"`
|
||||
Channel string `json:"channel"`
|
||||
RawHex string `json:"raw_hex"`
|
||||
Path []string `json:"path"`
|
||||
Observers []Observer `json:"observers"`
|
||||
}
|
||||
|
||||
// Observer is a single observation of the transmission.
|
||||
type Observer struct {
|
||||
Name string `json:"name"`
|
||||
SNR float64 `json:"snr"`
|
||||
RSSI float64 `json:"rssi"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
channelName := flag.String("channel", "", "Channel name (e.g. \"#wardriving\")")
|
||||
dbPath := flag.String("db", "", "Path to CoreScope SQLite database")
|
||||
format := flag.String("format", "json", "Output format: json, html, irc (or log)")
|
||||
output := flag.String("output", "", "Output file (default: stdout)")
|
||||
showVersion := flag.Bool("version", false, "Print version and exit")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `corescope-decrypt — Decrypt and export MeshCore hashtag channel messages
|
||||
|
||||
USAGE
|
||||
corescope-decrypt --channel NAME --db PATH [--format FORMAT] [--output FILE]
|
||||
|
||||
FLAGS
|
||||
--channel NAME Channel name to decrypt (e.g. "#wardriving", "wardriving")
|
||||
The "#" prefix is added automatically if missing.
|
||||
--db PATH Path to a CoreScope SQLite database file (read-only access).
|
||||
--format FORMAT Output format (default: json):
|
||||
json — Machine-readable JSON array with full metadata
|
||||
html — Self-contained HTML viewer with search and sorting
|
||||
irc — Plain-text IRC-style log, one line per message
|
||||
log — Alias for irc
|
||||
--output FILE Write output to FILE instead of stdout.
|
||||
--version Print version and exit.
|
||||
|
||||
EXAMPLES
|
||||
# Export #wardriving messages as JSON
|
||||
corescope-decrypt --channel "#wardriving" --db /app/data/meshcore.db
|
||||
|
||||
# Generate an interactive HTML viewer
|
||||
corescope-decrypt --channel wardriving --db meshcore.db --format html --output wardriving.html
|
||||
|
||||
# Greppable IRC log
|
||||
corescope-decrypt --channel "#MeshCore" --db meshcore.db --format irc --output meshcore.log
|
||||
grep "KE6QR" meshcore.log
|
||||
|
||||
# From the Docker container
|
||||
docker exec corescope-prod /app/corescope-decrypt --channel "#wardriving" --db /app/data/meshcore.db
|
||||
|
||||
RETROACTIVE DECRYPTION
|
||||
MeshCore hashtag channels use symmetric encryption — the key is derived from the
|
||||
channel name. The CoreScope ingestor stores ALL GRP_TXT packets in the database,
|
||||
even those it cannot decrypt at ingest time. This tool lets you retroactively
|
||||
decrypt messages for any channel whose name you know, even if the ingestor was
|
||||
never configured with that channel's key.
|
||||
|
||||
This means you can recover historical messages by simply knowing the channel name.
|
||||
|
||||
LIMITATIONS
|
||||
- Only hashtag channels (shared-secret, name-derived key) are supported.
|
||||
- Direct messages (TXT_MSG) use per-peer encryption and cannot be decrypted.
|
||||
- Custom PSK channels (non-hashtag) require the raw key, not a channel name.
|
||||
`)
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("corescope-decrypt", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *channelName == "" || *dbPath == "" {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Normalize channel name
|
||||
ch := *channelName
|
||||
if !strings.HasPrefix(ch, "#") {
|
||||
ch = "#" + ch
|
||||
}
|
||||
|
||||
key := channel.DeriveKey(ch)
|
||||
chHash := channel.ChannelHash(key)
|
||||
|
||||
db, err := sql.Open("sqlite", *dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query all GRP_TXT packets
|
||||
rows, err := db.Query(`SELECT id, hash, raw_hex, first_seen FROM transmissions WHERE payload_type = 5`)
|
||||
if err != nil {
|
||||
log.Fatalf("Query failed: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []ChannelMessage
|
||||
decrypted, total := 0, 0
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var txHash, rawHex, firstSeen string
|
||||
if err := rows.Scan(&id, &txHash, &rawHex, &firstSeen); err != nil {
|
||||
log.Printf("Scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
total++
|
||||
|
||||
payload, err := extractGRPPayload(rawHex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(payload) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check channel hash byte
|
||||
if payload[0] != chHash {
|
||||
continue
|
||||
}
|
||||
|
||||
mac := payload[1:3]
|
||||
ciphertext := payload[3:]
|
||||
if len(ciphertext) < 5 || len(ciphertext)%16 != 0 {
|
||||
// Pad ciphertext to block boundary for decryption attempt
|
||||
if len(ciphertext) < 16 {
|
||||
continue
|
||||
}
|
||||
// Truncate to block boundary
|
||||
ciphertext = ciphertext[:len(ciphertext)/16*16]
|
||||
}
|
||||
|
||||
plaintext, ok := channel.Decrypt(key, mac, ciphertext)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ts, sender, msg, err := channel.ParsePlaintext(plaintext)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
decrypted++
|
||||
|
||||
// Convert MeshCore timestamp
|
||||
timestamp := time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
|
||||
|
||||
// Get path from decoded_json
|
||||
path := getPathFromDB(db, id)
|
||||
|
||||
// Get observers
|
||||
observers := getObservers(db, id)
|
||||
|
||||
messages = append(messages, ChannelMessage{
|
||||
Hash: txHash,
|
||||
Timestamp: timestamp,
|
||||
Sender: sender,
|
||||
Message: msg,
|
||||
Channel: ch,
|
||||
RawHex: rawHex,
|
||||
Path: path,
|
||||
Observers: observers,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Timestamp < messages[j].Timestamp
|
||||
})
|
||||
|
||||
log.Printf("Scanned %d GRP_TXT packets, decrypted %d for channel %s", total, decrypted, ch)
|
||||
|
||||
// Generate output
|
||||
var out []byte
|
||||
switch *format {
|
||||
case "json":
|
||||
out, err = json.MarshalIndent(messages, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("JSON marshal: %v", err)
|
||||
}
|
||||
out = append(out, '\n')
|
||||
case "html":
|
||||
out = renderHTML(messages, ch)
|
||||
case "irc", "log":
|
||||
out = renderIRC(messages)
|
||||
default:
|
||||
log.Fatalf("Unknown format: %s (use json, html, irc, or log)", *format)
|
||||
}
|
||||
|
||||
if *output != "" {
|
||||
if err := os.WriteFile(*output, out, 0644); err != nil {
|
||||
log.Fatalf("Write file: %v", err)
|
||||
}
|
||||
log.Printf("Written to %s", *output)
|
||||
} else {
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
}
|
||||
|
||||
// extractGRPPayload parses a raw hex packet and returns the GRP_TXT payload bytes.
|
||||
func extractGRPPayload(rawHex string) ([]byte, error) {
|
||||
buf, err := hex.DecodeString(strings.TrimSpace(rawHex))
|
||||
if err != nil || len(buf) < 2 {
|
||||
return nil, fmt.Errorf("invalid hex")
|
||||
}
|
||||
|
||||
// Header byte
|
||||
header := buf[0]
|
||||
payloadType := int((header >> 2) & 0x0F)
|
||||
if payloadType != 5 { // GRP_TXT
|
||||
return nil, fmt.Errorf("not GRP_TXT")
|
||||
}
|
||||
|
||||
routeType := int(header & 0x03)
|
||||
offset := 1
|
||||
|
||||
// Transport codes (2 codes × 2 bytes) come BEFORE path for transport routes
|
||||
if routeType == 0 || routeType == 3 {
|
||||
offset += 4
|
||||
}
|
||||
|
||||
// Path byte
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("too short for path")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
offset += hashSize * hashCount
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("too short for payload")
|
||||
}
|
||||
|
||||
return buf[offset:], nil
|
||||
}
|
||||
|
||||
func getPathFromDB(db *sql.DB, txID int) []string {
|
||||
var decodedJSON sql.NullString
|
||||
err := db.QueryRow(`SELECT decoded_json FROM transmissions WHERE id = ?`, txID).Scan(&decodedJSON)
|
||||
if err != nil || !decodedJSON.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Path struct {
|
||||
Hops []string `json:"hops"`
|
||||
} `json:"path"`
|
||||
}
|
||||
if json.Unmarshal([]byte(decodedJSON.String), &decoded) == nil {
|
||||
return decoded.Path.Hops
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getObservers(db *sql.DB, txID int) []Observer {
|
||||
rows, err := db.Query(`
|
||||
SELECT o.name, obs.snr, obs.rssi, obs.timestamp
|
||||
FROM observations obs
|
||||
LEFT JOIN observers o ON o.id = CAST(obs.observer_idx AS TEXT)
|
||||
WHERE obs.transmission_id = ?
|
||||
ORDER BY obs.timestamp
|
||||
`, txID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var observers []Observer
|
||||
for rows.Next() {
|
||||
var name sql.NullString
|
||||
var snr, rssi sql.NullFloat64
|
||||
var ts int64
|
||||
if err := rows.Scan(&name, &snr, &rssi, &ts); err != nil {
|
||||
continue
|
||||
}
|
||||
obs := Observer{
|
||||
Timestamp: time.Unix(ts, 0).UTC().Format(time.RFC3339),
|
||||
}
|
||||
if name.Valid {
|
||||
obs.Name = name.String
|
||||
}
|
||||
if snr.Valid {
|
||||
obs.SNR = snr.Float64
|
||||
}
|
||||
if rssi.Valid {
|
||||
obs.RSSI = rssi.Float64
|
||||
}
|
||||
observers = append(observers, obs)
|
||||
}
|
||||
return observers
|
||||
}
|
||||
|
||||
func renderIRC(messages []ChannelMessage) []byte {
|
||||
var b strings.Builder
|
||||
for _, m := range messages {
|
||||
sender := m.Sender
|
||||
if sender == "" {
|
||||
sender = "???"
|
||||
}
|
||||
// Parse RFC3339 timestamp into a compact format
|
||||
t, err := time.Parse(time.RFC3339, m.Timestamp)
|
||||
if err != nil {
|
||||
b.WriteString(fmt.Sprintf("[%s] <%s> %s\n", m.Timestamp, sender, m.Message))
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("[%s] <%s> %s\n", t.Format("2006-01-02 15:04:05"), sender, m.Message))
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func renderHTML(messages []ChannelMessage, channelName string) []byte {
|
||||
jsonData, _ := json.Marshal(messages)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CoreScope Channel Export — ` + html.EscapeString(channelName) + `</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0d1117;color:#c9d1d9;padding:20px}
|
||||
h1{color:#58a6ff;margin-bottom:16px;font-size:1.5em}
|
||||
.stats{color:#8b949e;margin-bottom:16px;font-size:0.9em}
|
||||
input[type=text]{width:100%;max-width:500px;padding:8px 12px;background:#161b22;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-size:14px;margin-bottom:16px}
|
||||
input[type=text]:focus{outline:none;border-color:#58a6ff}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
th{background:#161b22;color:#8b949e;text-align:left;padding:8px 12px;border-bottom:2px solid #30363d;cursor:pointer;user-select:none;white-space:nowrap}
|
||||
th:hover{color:#58a6ff}
|
||||
th.sorted-asc::after{content:" ▲"}
|
||||
th.sorted-desc::after{content:" ▼"}
|
||||
td{padding:8px 12px;border-bottom:1px solid #21262d;vertical-align:top}
|
||||
tr:hover{background:#161b22}
|
||||
tr.expanded{background:#161b22}
|
||||
.detail-row td{padding:12px 24px;background:#0d1117;border-bottom:1px solid #21262d}
|
||||
.detail-row pre{background:#161b22;padding:12px;border-radius:6px;overflow-x:auto;font-size:12px;color:#8b949e}
|
||||
.detail-row .label{color:#58a6ff;font-weight:600;margin-top:8px;display:block}
|
||||
.observer-tag{display:inline-block;background:#1f6feb22;color:#58a6ff;padding:2px 8px;border-radius:4px;margin:2px;font-size:12px}
|
||||
.no-results{color:#8b949e;text-align:center;padding:40px;font-size:16px}
|
||||
.sender{color:#d2a8ff;font-weight:600}
|
||||
.timestamp{color:#8b949e;font-family:monospace;font-size:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>` + html.EscapeString(channelName) + ` — Channel Messages</h1>
|
||||
<div class="stats" id="stats"></div>
|
||||
<input type="text" id="search" placeholder="Search messages..." autocomplete="off">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="timestamp">Timestamp</th>
|
||||
<th data-col="sender">Sender</th>
|
||||
<th data-col="message">Message</th>
|
||||
<th data-col="observers">Observers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
<div class="no-results" id="no-results" style="display:none">No matching messages</div>
|
||||
<script>
|
||||
var DATA=` + string(jsonData) + `;
|
||||
var sortCol="timestamp",sortAsc=true,expandedHash=null;
|
||||
function init(){
|
||||
document.getElementById("stats").textContent=DATA.length+" messages";
|
||||
document.getElementById("search").addEventListener("input",render);
|
||||
document.querySelectorAll("th[data-col]").forEach(function(th){
|
||||
th.addEventListener("click",function(){
|
||||
var col=th.dataset.col;
|
||||
if(sortCol===col)sortAsc=!sortAsc;
|
||||
else{sortCol=col;sortAsc=true}
|
||||
render();
|
||||
});
|
||||
});
|
||||
render();
|
||||
}
|
||||
function render(){
|
||||
var q=document.getElementById("search").value.toLowerCase();
|
||||
var filtered=DATA.filter(function(m){
|
||||
if(!q)return true;
|
||||
return(m.message||"").toLowerCase().indexOf(q)>=0||(m.sender||"").toLowerCase().indexOf(q)>=0;
|
||||
});
|
||||
filtered.sort(function(a,b){
|
||||
var va=a[sortCol]||"",vb=b[sortCol]||"";
|
||||
if(sortCol==="observers"){va=a.observers?a.observers.length:0;vb=b.observers?b.observers.length:0}
|
||||
if(va<vb)return sortAsc?-1:1;
|
||||
if(va>vb)return sortAsc?1:-1;
|
||||
return 0;
|
||||
});
|
||||
document.querySelectorAll("th[data-col]").forEach(function(th){
|
||||
th.className=th.dataset.col===sortCol?(sortAsc?"sorted-asc":"sorted-desc"):"";
|
||||
});
|
||||
var tb=document.getElementById("tbody");
|
||||
tb.innerHTML="";
|
||||
document.getElementById("no-results").style.display=filtered.length?"none":"block";
|
||||
filtered.forEach(function(m){
|
||||
var tr=document.createElement("tr");
|
||||
tr.innerHTML='<td class="timestamp">'+esc(m.timestamp)+'</td><td class="sender">'+esc(m.sender||"—")+'</td><td>'+esc(m.message)+'</td><td>'+
|
||||
(m.observers?m.observers.map(function(o){return'<span class="observer-tag">'+esc(o.name||"?")+" SNR:"+o.snr.toFixed(1)+'</span>'}).join(""):"—")+'</td>';
|
||||
tr.style.cursor="pointer";
|
||||
tr.addEventListener("click",function(){
|
||||
expandedHash=expandedHash===m.hash?null:m.hash;
|
||||
render();
|
||||
});
|
||||
tb.appendChild(tr);
|
||||
if(expandedHash===m.hash){
|
||||
tr.className="expanded";
|
||||
var dr=document.createElement("tr");
|
||||
dr.className="detail-row";
|
||||
dr.innerHTML='<td colspan="4"><span class="label">Hash</span><pre>'+esc(m.hash)+'</pre>'+
|
||||
'<span class="label">Raw Hex</span><pre>'+esc(m.raw_hex)+'</pre>'+
|
||||
(m.path&&m.path.length?'<span class="label">Path</span><pre>'+esc(m.path.join(" → "))+'</pre>':'')+
|
||||
'<span class="label">Observers</span><pre>'+esc(JSON.stringify(m.observers,null,2))+'</pre></td>';
|
||||
tb.appendChild(dr);
|
||||
}
|
||||
});
|
||||
}
|
||||
function esc(s){var d=document.createElement("div");d.textContent=s;return d.innerHTML}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/meshcore-analyzer/channel"
|
||||
)
|
||||
|
||||
func TestExtractGRPPayload(t *testing.T) {
|
||||
// Build a minimal GRP_TXT packet: header(1) + path(1) + payload
|
||||
// header: route=FLOOD(1), payload=GRP_TXT(5), version=0 → (5<<2)|1 = 0x15
|
||||
// path: 0 hops, hash_size=1 → 0x00
|
||||
payload := []byte{0x81, 0x12, 0x34} // channel_hash + mac + data
|
||||
pkt := append([]byte{0x15, 0x00}, payload...)
|
||||
rawHex := hex.EncodeToString(pkt)
|
||||
|
||||
result, err := extractGRPPayload(rawHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 3 || result[0] != 0x81 {
|
||||
t.Fatalf("payload mismatch: %x", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGRPPayloadTransport(t *testing.T) {
|
||||
// Transport flood: route=0, 4 bytes transport codes BEFORE path byte
|
||||
// header: (5<<2)|0 = 0x14
|
||||
payload := []byte{0xAA, 0xBB, 0xCC}
|
||||
// header + 4 transport bytes + path(0 hops) + payload
|
||||
pkt := append([]byte{0x14, 0xFF, 0xFF, 0xFF, 0xFF, 0x00}, payload...)
|
||||
rawHex := hex.EncodeToString(pkt)
|
||||
|
||||
result, err := extractGRPPayload(rawHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result[0] != 0xAA {
|
||||
t.Fatalf("expected AA, got %02X", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGRPPayloadNotGRP(t *testing.T) {
|
||||
// payload type = ADVERT (4): (4<<2)|1 = 0x11
|
||||
rawHex := hex.EncodeToString([]byte{0x11, 0x00, 0x01, 0x02})
|
||||
_, err := extractGRPPayload(rawHex)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-GRP_TXT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyDerivationConsistency(t *testing.T) {
|
||||
// Verify key derivation matches what the ingestor expects
|
||||
key := channel.DeriveKey("#wardriving")
|
||||
if len(key) != 16 {
|
||||
t.Fatalf("key len %d", len(key))
|
||||
}
|
||||
ch := channel.ChannelHash(key)
|
||||
if ch != 0x81 {
|
||||
// We know from fixture data that #wardriving has channelHashHex "81"
|
||||
t.Fatalf("channel hash %02X, expected 81", ch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIRC(t *testing.T) {
|
||||
msgs := []ChannelMessage{
|
||||
{Timestamp: "2026-04-12T03:45:12Z", Sender: "NodeA", Message: "Hello"},
|
||||
{Timestamp: "2026-04-12T03:46:01Z", Sender: "", Message: "No sender"},
|
||||
}
|
||||
out := string(renderIRC(msgs))
|
||||
if !strings.Contains(out, "[2026-04-12 03:45:12] <NodeA> Hello") {
|
||||
t.Fatalf("IRC output missing expected line: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "<???> No sender") {
|
||||
t.Fatalf("IRC output should use ??? for empty sender: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTMLValid(t *testing.T) {
|
||||
msgs := []ChannelMessage{
|
||||
{Hash: "abc", Timestamp: "2026-04-12T00:00:00Z", Sender: "X", Message: "test", Channel: "#test"},
|
||||
}
|
||||
out := string(renderHTML(msgs, "#test"))
|
||||
if !strings.Contains(out, "<!DOCTYPE html>") {
|
||||
t.Fatal("not valid HTML")
|
||||
}
|
||||
if !strings.Contains(out, "#test") {
|
||||
t.Fatal("channel name missing")
|
||||
}
|
||||
if !strings.Contains(out, "</html>") {
|
||||
t.Fatal("HTML not closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONOutputParseable(t *testing.T) {
|
||||
msgs := []ChannelMessage{
|
||||
{Hash: "abc", Timestamp: "2026-04-12T00:00:00Z", Sender: "X", Message: "hi", Channel: "#test"},
|
||||
}
|
||||
data, err := json.MarshalIndent(msgs, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed []ChannelMessage
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("JSON not parseable: %v", err)
|
||||
}
|
||||
if len(parsed) != 1 || parsed[0].Sender != "X" {
|
||||
t.Fatalf("parsed mismatch: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test against fixture DB (skipped if DB not found)
|
||||
func TestFixtureDecrypt(t *testing.T) {
|
||||
dbPath := "../../test-fixtures/e2e-fixture.db"
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Skip("fixture DB not found")
|
||||
}
|
||||
|
||||
// We know the fixture has #wardriving messages with channelHash 0x81
|
||||
key := channel.DeriveKey("#wardriving")
|
||||
ch := channel.ChannelHash(key)
|
||||
if ch != 0x81 {
|
||||
t.Fatalf("unexpected channel hash: %02X", ch)
|
||||
}
|
||||
}
|
||||
@@ -203,21 +203,13 @@ func TestHandleMessageChannelMessage(t *testing.T) {
|
||||
t.Errorf("direction=%v, want rx", direction)
|
||||
}
|
||||
|
||||
// Should create sender node
|
||||
// Sender node should NOT be created (see issue #665: synthetic "sender-" keys
|
||||
// are unreachable from the claiming/health flow)
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("nodes count=%d, want 1 (sender node)", count)
|
||||
}
|
||||
|
||||
// Verify sender node name
|
||||
var nodeName string
|
||||
if err := store.db.QueryRow("SELECT name FROM nodes LIMIT 1").Scan(&nodeName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nodeName != "Alice" {
|
||||
t.Errorf("node name=%s, want Alice", nodeName)
|
||||
if count != 0 {
|
||||
t.Errorf("nodes count=%d, want 0 (no phantom sender node)", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +453,7 @@ func TestDecodeAdvertLocationTruncated(t *testing.T) {
|
||||
buf[100] = 0x11
|
||||
// Only 4 bytes after flags — not enough for full location (needs 8)
|
||||
|
||||
p := decodeAdvert(buf[:105])
|
||||
p := decodeAdvert(buf[:105], false)
|
||||
if p.Error != "" {
|
||||
t.Fatalf("error: %s", p.Error)
|
||||
}
|
||||
@@ -483,7 +475,7 @@ func TestDecodeAdvertFeat1Truncated(t *testing.T) {
|
||||
buf[100] = 0x21
|
||||
// Only 1 byte after flags — not enough for feat1 (needs 2)
|
||||
|
||||
p := decodeAdvert(buf[:102])
|
||||
p := decodeAdvert(buf[:102], false)
|
||||
if p.Feat1 != nil {
|
||||
t.Error("feat1 should be nil with truncated data")
|
||||
}
|
||||
@@ -504,7 +496,7 @@ func TestDecodeAdvertFeat2Truncated(t *testing.T) {
|
||||
buf[102] = 0x00
|
||||
// Only 1 byte left — not enough for feat2
|
||||
|
||||
p := decodeAdvert(buf[:104])
|
||||
p := decodeAdvert(buf[:104], false)
|
||||
if p.Feat1 == nil {
|
||||
t.Error("feat1 should be set")
|
||||
}
|
||||
@@ -544,7 +536,7 @@ func TestDecodeAdvertSensorBadTelemetry(t *testing.T) {
|
||||
buf[105] = 0x20
|
||||
buf[106] = 0x4E
|
||||
|
||||
p := decodeAdvert(buf[:107])
|
||||
p := decodeAdvert(buf[:107], false)
|
||||
if p.BatteryMv != nil {
|
||||
t.Error("battery_mv=0 should be nil")
|
||||
}
|
||||
@@ -740,7 +732,7 @@ func TestDecodeAdvertSensorNoName(t *testing.T) {
|
||||
buf[103] = 0xC4
|
||||
buf[104] = 0x09
|
||||
|
||||
p := decodeAdvert(buf[:105])
|
||||
p := decodeAdvert(buf[:105], false)
|
||||
if p.Error != "" {
|
||||
t.Fatalf("error: %s", p.Error)
|
||||
}
|
||||
@@ -835,7 +827,7 @@ func TestDecodePacketNoPathByteAfterHeader(t *testing.T) {
|
||||
// Non-transport route, but only header byte (no path byte)
|
||||
// Actually 0A alone = 1 byte, but we need >= 2
|
||||
// Header + exactly at offset boundary
|
||||
_, err := DecodePacket("0A", nil)
|
||||
_, err := DecodePacket("0A", nil, false)
|
||||
if err == nil {
|
||||
t.Error("should error - too short")
|
||||
}
|
||||
@@ -856,7 +848,7 @@ func TestDecodeAdvertNameNoNull(t *testing.T) {
|
||||
// Name without null terminator — goes to end of buffer
|
||||
copy(buf[101:], []byte("LongNameNoNull"))
|
||||
|
||||
p := decodeAdvert(buf[:115])
|
||||
p := decodeAdvert(buf[:115], false)
|
||||
if p.Name != "LongNameNoNull" {
|
||||
t.Errorf("name=%q, want LongNameNoNull", p.Name)
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ func TestEndToEndIngest(t *testing.T) {
|
||||
// Simulate full pipeline: decode + insert
|
||||
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
||||
|
||||
decoded, err := DecodePacket(rawHex, nil)
|
||||
decoded, err := DecodePacket(rawHex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -764,7 +764,7 @@ func TestInsertTransmissionNilSNRRSSI(t *testing.T) {
|
||||
|
||||
func TestBuildPacketData(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
decoded, err := DecodePacket(rawHex, nil)
|
||||
decoded, err := DecodePacket(rawHex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -818,7 +818,7 @@ func TestBuildPacketData(t *testing.T) {
|
||||
func TestBuildPacketDataWithHops(t *testing.T) {
|
||||
// A packet with actual hops in the path
|
||||
raw := "0505AABBCCDDEE" + strings.Repeat("00", 10)
|
||||
decoded, err := DecodePacket(raw, nil)
|
||||
decoded, err := DecodePacket(raw, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -834,7 +834,7 @@ func TestBuildPacketDataWithHops(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildPacketDataNilSNRRSSI(t *testing.T) {
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil)
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false)
|
||||
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
|
||||
pkt := BuildPacketData(msg, decoded, "", "")
|
||||
|
||||
@@ -1624,7 +1624,7 @@ func TestObsTimestampIndexMigration(t *testing.T) {
|
||||
|
||||
func TestBuildPacketDataScoreAndDirection(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
decoded, err := DecodePacket(rawHex, nil)
|
||||
decoded, err := DecodePacket(rawHex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1647,7 +1647,7 @@ func TestBuildPacketDataScoreAndDirection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildPacketDataNilScoreDirection(t *testing.T) {
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil)
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false)
|
||||
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
|
||||
pkt := BuildPacketData(msg, decoded, "", "")
|
||||
|
||||
|
||||
+28
-5
@@ -11,6 +11,8 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/meshcore-analyzer/sigvalidate"
|
||||
)
|
||||
|
||||
// Route type constants (header bits 1-0)
|
||||
@@ -109,6 +111,7 @@ type Payload struct {
|
||||
Timestamp uint32 `json:"timestamp,omitempty"`
|
||||
TimestampISO string `json:"timestampISO,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
SignatureValid *bool `json:"signatureValid,omitempty"`
|
||||
Flags *AdvertFlags `json:"flags,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
@@ -215,7 +218,7 @@ func decodeAck(buf []byte) Payload {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAdvert(buf []byte) Payload {
|
||||
func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
if len(buf) < 100 {
|
||||
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
@@ -233,6 +236,16 @@ func decodeAdvert(buf []byte) Payload {
|
||||
Signature: signature,
|
||||
}
|
||||
|
||||
if validateSignatures {
|
||||
valid, err := sigvalidate.ValidateAdvert(buf[0:32], buf[36:100], timestamp, appdata)
|
||||
if err != nil {
|
||||
f := false
|
||||
p.SignatureValid = &f
|
||||
} else {
|
||||
p.SignatureValid = &valid
|
||||
}
|
||||
}
|
||||
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
@@ -506,7 +519,7 @@ func decodeTrace(buf []byte) Payload {
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string, validateSignatures bool) Payload {
|
||||
switch payloadType {
|
||||
case PayloadREQ:
|
||||
return decodeEncryptedPayload("REQ", buf)
|
||||
@@ -517,7 +530,7 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) P
|
||||
case PayloadACK:
|
||||
return decodeAck(buf)
|
||||
case PayloadADVERT:
|
||||
return decodeAdvert(buf)
|
||||
return decodeAdvert(buf, validateSignatures)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf, channelKeys)
|
||||
case PayloadANON_REQ:
|
||||
@@ -532,7 +545,7 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) P
|
||||
}
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
|
||||
func DecodePacket(hexString string, channelKeys map[string]string, validateSignatures bool) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
@@ -570,7 +583,7 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys, validateSignatures)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
@@ -587,6 +600,16 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-hop direct packets have hash_count=0 (lower 6 bits of pathByte),
|
||||
// which makes the generic formula yield a bogus hashSize. Reset to 0
|
||||
// (unknown) so API consumers get correct data. We mask with 0x3F to check
|
||||
// only hash_count, matching the JS frontend approach — the upper hash_size
|
||||
// bits are meaningless when there are no hops. Skip TRACE packets — they
|
||||
// use hashSize to parse hops from the payload above.
|
||||
if (header.RouteType == RouteDirect || header.RouteType == RouteTransportDirect) && pathByte&0x3F == 0 && header.PayloadType != PayloadTRACE {
|
||||
path.HashSize = 0
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
|
||||
+230
-37
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/ed25519"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/meshcore-analyzer/sigvalidate"
|
||||
)
|
||||
|
||||
func TestDecodeHeaderRoutTypes(t *testing.T) {
|
||||
@@ -55,7 +58,7 @@ func TestDecodeHeaderPayloadTypes(t *testing.T) {
|
||||
|
||||
func TestDecodePathZeroHops(t *testing.T) {
|
||||
// 0x00: 0 hops, 1-byte hashes
|
||||
pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil)
|
||||
pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -72,7 +75,7 @@ func TestDecodePathZeroHops(t *testing.T) {
|
||||
|
||||
func TestDecodePath1ByteHashes(t *testing.T) {
|
||||
// 0x05: 5 hops, 1-byte hashes → 5 path bytes
|
||||
pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil)
|
||||
pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,7 +98,7 @@ func TestDecodePath1ByteHashes(t *testing.T) {
|
||||
|
||||
func TestDecodePath2ByteHashes(t *testing.T) {
|
||||
// 0x45: 5 hops, 2-byte hashes
|
||||
pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil)
|
||||
pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,7 +115,7 @@ func TestDecodePath2ByteHashes(t *testing.T) {
|
||||
|
||||
func TestDecodePath3ByteHashes(t *testing.T) {
|
||||
// 0x8A: 10 hops, 3-byte hashes
|
||||
pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil)
|
||||
pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -131,7 +134,7 @@ func TestTransportCodes(t *testing.T) {
|
||||
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
|
||||
// Firmware order: header + transport_codes(4) + path_len + path + payload
|
||||
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
|
||||
pkt, err := DecodePacket(hex, nil)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -149,7 +152,7 @@ func TestTransportCodes(t *testing.T) {
|
||||
}
|
||||
|
||||
// Route type 1 (FLOOD) should NOT have transport codes
|
||||
pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil)
|
||||
pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -169,7 +172,7 @@ func TestDecodeAdvertFull(t *testing.T) {
|
||||
name := "546573744E6F6465" // "TestNode"
|
||||
|
||||
hex := "1200" + pubkey + timestamp + signature + flags + lat + lon + name
|
||||
pkt, err := DecodePacket(hex, nil)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -227,7 +230,7 @@ func TestDecodeAdvertTypeEnums(t *testing.T) {
|
||||
makeAdvert := func(flagsByte byte) *DecodedPacket {
|
||||
hex := "1200" + strings.Repeat("AA", 32) + "00000000" + strings.Repeat("BB", 64) +
|
||||
strings.ToUpper(string([]byte{hexDigit(flagsByte>>4), hexDigit(flagsByte & 0x0f)}))
|
||||
pkt, err := DecodePacket(hex, nil)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -272,7 +275,7 @@ func hexDigit(v byte) byte {
|
||||
|
||||
func TestDecodeAdvertNoLocationNoName(t *testing.T) {
|
||||
hex := "1200" + strings.Repeat("CC", 32) + "00000000" + strings.Repeat("DD", 64) + "02"
|
||||
pkt, err := DecodePacket(hex, nil)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -291,7 +294,7 @@ func TestDecodeAdvertNoLocationNoName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGoldenFixtureTxtMsg(t *testing.T) {
|
||||
pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil)
|
||||
pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -314,7 +317,7 @@ func TestGoldenFixtureTxtMsg(t *testing.T) {
|
||||
|
||||
func TestGoldenFixtureAdvert(t *testing.T) {
|
||||
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
||||
pkt, err := DecodePacket(rawHex, nil)
|
||||
pkt, err := DecodePacket(rawHex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -337,7 +340,7 @@ func TestGoldenFixtureAdvert(t *testing.T) {
|
||||
|
||||
func TestGoldenFixtureUnicodeAdvert(t *testing.T) {
|
||||
rawHex := "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3"
|
||||
pkt, err := DecodePacket(rawHex, nil)
|
||||
pkt, err := DecodePacket(rawHex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -354,14 +357,14 @@ func TestGoldenFixtureUnicodeAdvert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePacketTooShort(t *testing.T) {
|
||||
_, err := DecodePacket("FF", nil)
|
||||
_, err := DecodePacket("FF", nil, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for 1-byte packet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacketInvalidHex(t *testing.T) {
|
||||
_, err := DecodePacket("ZZZZ", nil)
|
||||
_, err := DecodePacket("ZZZZ", nil, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid hex")
|
||||
}
|
||||
@@ -568,7 +571,7 @@ func TestDecodeTracePathParsing(t *testing.T) {
|
||||
// Packet from issue #276: 260001807dca00000000007d547d
|
||||
// Path byte 0x00 → hashSize=1, hops in payload at buf[9:] = 7d 54 7d
|
||||
// Expected path: ["7D", "54", "7D"]
|
||||
pkt, err := DecodePacket("260001807dca00000000007d547d", nil)
|
||||
pkt, err := DecodePacket("260001807dca00000000007d547d", nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
@@ -590,7 +593,7 @@ func TestDecodeTracePathParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodeAdvertShort(t *testing.T) {
|
||||
p := decodeAdvert(make([]byte, 50))
|
||||
p := decodeAdvert(make([]byte, 50), false)
|
||||
if p.Error != "too short for advert" {
|
||||
t.Errorf("expected 'too short for advert' error, got %q", p.Error)
|
||||
}
|
||||
@@ -628,7 +631,7 @@ func TestDecodeEncryptedPayloadValid(t *testing.T) {
|
||||
|
||||
func TestDecodePayloadGRPData(t *testing.T) {
|
||||
buf := []byte{0x01, 0x02, 0x03}
|
||||
p := decodePayload(PayloadGRP_DATA, buf, nil)
|
||||
p := decodePayload(PayloadGRP_DATA, buf, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("type=%s, want UNKNOWN", p.Type)
|
||||
}
|
||||
@@ -639,7 +642,7 @@ func TestDecodePayloadGRPData(t *testing.T) {
|
||||
|
||||
func TestDecodePayloadRAWCustom(t *testing.T) {
|
||||
buf := []byte{0xFF, 0xFE}
|
||||
p := decodePayload(PayloadRAW_CUSTOM, buf, nil)
|
||||
p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("type=%s, want UNKNOWN", p.Type)
|
||||
}
|
||||
@@ -647,49 +650,49 @@ func TestDecodePayloadRAWCustom(t *testing.T) {
|
||||
|
||||
func TestDecodePayloadAllTypes(t *testing.T) {
|
||||
// REQ
|
||||
p := decodePayload(PayloadREQ, make([]byte, 10), nil)
|
||||
p := decodePayload(PayloadREQ, make([]byte, 10), nil, false)
|
||||
if p.Type != "REQ" {
|
||||
t.Errorf("REQ: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// RESPONSE
|
||||
p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil)
|
||||
p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil, false)
|
||||
if p.Type != "RESPONSE" {
|
||||
t.Errorf("RESPONSE: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// TXT_MSG
|
||||
p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil)
|
||||
p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil, false)
|
||||
if p.Type != "TXT_MSG" {
|
||||
t.Errorf("TXT_MSG: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// ACK
|
||||
p = decodePayload(PayloadACK, make([]byte, 10), nil)
|
||||
p = decodePayload(PayloadACK, make([]byte, 10), nil, false)
|
||||
if p.Type != "ACK" {
|
||||
t.Errorf("ACK: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// GRP_TXT
|
||||
p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil)
|
||||
p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil, false)
|
||||
if p.Type != "GRP_TXT" {
|
||||
t.Errorf("GRP_TXT: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// ANON_REQ
|
||||
p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil)
|
||||
p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil, false)
|
||||
if p.Type != "ANON_REQ" {
|
||||
t.Errorf("ANON_REQ: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// PATH
|
||||
p = decodePayload(PayloadPATH, make([]byte, 10), nil)
|
||||
p = decodePayload(PayloadPATH, make([]byte, 10), nil, false)
|
||||
if p.Type != "PATH" {
|
||||
t.Errorf("PATH: type=%s", p.Type)
|
||||
}
|
||||
|
||||
// TRACE
|
||||
p = decodePayload(PayloadTRACE, make([]byte, 20), nil)
|
||||
p = decodePayload(PayloadTRACE, make([]byte, 20), nil, false)
|
||||
if p.Type != "TRACE" {
|
||||
t.Errorf("TRACE: type=%s", p.Type)
|
||||
}
|
||||
@@ -925,7 +928,7 @@ func TestComputeContentHashLongFallback(t *testing.T) {
|
||||
|
||||
func TestDecodePacketWithWhitespace(t *testing.T) {
|
||||
raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76"
|
||||
pkt, err := DecodePacket(raw, nil)
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -936,7 +939,7 @@ func TestDecodePacketWithWhitespace(t *testing.T) {
|
||||
|
||||
func TestDecodePacketWithNewlines(t *testing.T) {
|
||||
raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
pkt, err := DecodePacket(raw, nil)
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -947,7 +950,7 @@ func TestDecodePacketWithNewlines(t *testing.T) {
|
||||
|
||||
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
|
||||
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes
|
||||
_, err := DecodePacket("1400", nil)
|
||||
_, err := DecodePacket("1400", nil, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for transport route with too-short buffer")
|
||||
}
|
||||
@@ -1007,7 +1010,7 @@ func TestDecodeHeaderUnknownTypes(t *testing.T) {
|
||||
|
||||
func TestDecodePayloadMultipart(t *testing.T) {
|
||||
// MULTIPART (0x0A) falls through to default → UNKNOWN
|
||||
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil)
|
||||
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type)
|
||||
}
|
||||
@@ -1015,7 +1018,7 @@ func TestDecodePayloadMultipart(t *testing.T) {
|
||||
|
||||
func TestDecodePayloadControl(t *testing.T) {
|
||||
// CONTROL (0x0B) falls through to default → UNKNOWN
|
||||
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil)
|
||||
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type)
|
||||
}
|
||||
@@ -1039,7 +1042,7 @@ func TestDecodePathTruncatedBuffer(t *testing.T) {
|
||||
func TestDecodeFloodAdvert5Hops(t *testing.T) {
|
||||
// From test-decoder.js Test 1
|
||||
raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172"
|
||||
pkt, err := DecodePacket(raw, nil)
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1410,7 +1413,7 @@ func TestDecodeAdvertWithTelemetry(t *testing.T) {
|
||||
name + nullTerm +
|
||||
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
|
||||
|
||||
pkt, err := DecodePacket(hexStr, nil)
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1449,7 +1452,7 @@ func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) {
|
||||
name + nullTerm +
|
||||
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
|
||||
|
||||
pkt, err := DecodePacket(hexStr, nil)
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1476,7 +1479,7 @@ func TestDecodeAdvertWithoutTelemetry(t *testing.T) {
|
||||
name := hex.EncodeToString([]byte("Node1"))
|
||||
|
||||
hexStr := "1200" + pubkey + timestamp + signature + flags + name
|
||||
pkt, err := DecodePacket(hexStr, nil)
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1503,7 +1506,7 @@ func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) {
|
||||
extraBytes := "B40ED403" // battery-like and temp-like bytes
|
||||
|
||||
hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes
|
||||
pkt, err := DecodePacket(hexStr, nil)
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1531,7 +1534,7 @@ func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) {
|
||||
name + nullTerm +
|
||||
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
|
||||
|
||||
pkt, err := DecodePacket(hexStr, nil)
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1542,3 +1545,193 @@ func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) {
|
||||
t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatHex(byteHex string, n int) string {
|
||||
s := ""
|
||||
for i := 0; i < n; i++ {
|
||||
s += byteHex
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestZeroHopDirectHashSize(t *testing.T) {
|
||||
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
||||
// pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0
|
||||
hex := "02" + "00" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
||||
// pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0
|
||||
hex := "02" + "40" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) {
|
||||
// FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01
|
||||
// pathByte=0x00 → non-DIRECT should keep HashSize=1
|
||||
hex := "01" + "00" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 1 {
|
||||
t.Errorf("FLOOD zero pathByte: want HashSize=1, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectNonZeroHopKeepsHashSize(t *testing.T) {
|
||||
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
||||
// pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1
|
||||
hex := "02" + "01" + repeatHex("BB", 21)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 1 {
|
||||
t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSize(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0x00 → hash_count=0 → should get HashSize=0
|
||||
hex := "03" + "11223344" + "00" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
|
||||
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdvertSignature(t *testing.T) {
|
||||
// Generate a real ed25519 key pair
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var timestamp uint32 = 1234567890
|
||||
appdata := []byte{0x02, 0x11, 0x22} // flags + some data
|
||||
|
||||
// Build the signed message: pubKey + timestamp(LE) + appdata
|
||||
message := make([]byte, 32+4+len(appdata))
|
||||
copy(message[0:32], pub)
|
||||
binary.LittleEndian.PutUint32(message[32:36], timestamp)
|
||||
copy(message[36:], appdata)
|
||||
|
||||
sig := ed25519.Sign(priv, message)
|
||||
|
||||
// Valid signature
|
||||
valid, err := sigvalidate.ValidateAdvert([]byte(pub), sig, timestamp, appdata)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Error("expected valid signature")
|
||||
}
|
||||
|
||||
// Tampered appdata → invalid
|
||||
badAppdata := []byte{0x03, 0x11, 0x22}
|
||||
valid, err = sigvalidate.ValidateAdvert([]byte(pub), sig, timestamp, badAppdata)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if valid {
|
||||
t.Error("expected invalid signature with tampered appdata")
|
||||
}
|
||||
|
||||
// Wrong timestamp → invalid
|
||||
valid, err = sigvalidate.ValidateAdvert([]byte(pub), sig, timestamp+1, appdata)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if valid {
|
||||
t.Error("expected invalid signature with wrong timestamp")
|
||||
}
|
||||
|
||||
// Wrong length pubkey
|
||||
_, err = sigvalidate.ValidateAdvert([]byte{0xAA, 0xBB}, sig, timestamp, appdata)
|
||||
if err == nil {
|
||||
t.Error("expected error for short pubkey")
|
||||
}
|
||||
|
||||
// Wrong length signature
|
||||
_, err = sigvalidate.ValidateAdvert([]byte(pub), []byte{0xAA, 0xBB}, timestamp, appdata)
|
||||
if err == nil {
|
||||
t.Error("expected error for short signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAdvertWithSignatureValidation(t *testing.T) {
|
||||
// Generate key pair
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var timestamp uint32 = 1000000
|
||||
appdata := []byte{0x02} // repeater type, no location
|
||||
|
||||
// Build signed message
|
||||
message := make([]byte, 32+4+len(appdata))
|
||||
copy(message[0:32], pub)
|
||||
binary.LittleEndian.PutUint32(message[32:36], timestamp)
|
||||
copy(message[36:], appdata)
|
||||
sig := ed25519.Sign(priv, message)
|
||||
|
||||
// Build advert buffer: pubkey(32) + timestamp(4) + signature(64) + appdata
|
||||
buf := make([]byte, 0, 101)
|
||||
buf = append(buf, pub...)
|
||||
ts := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(ts, timestamp)
|
||||
buf = append(buf, ts...)
|
||||
buf = append(buf, sig...)
|
||||
buf = append(buf, appdata...)
|
||||
|
||||
// With validation enabled
|
||||
p := decodeAdvert(buf, true)
|
||||
if p.Error != "" {
|
||||
t.Fatalf("decode error: %s", p.Error)
|
||||
}
|
||||
if p.SignatureValid == nil {
|
||||
t.Fatal("SignatureValid should be set when validation enabled")
|
||||
}
|
||||
if !*p.SignatureValid {
|
||||
t.Error("expected valid signature")
|
||||
}
|
||||
|
||||
// Without validation
|
||||
p2 := decodeAdvert(buf, false)
|
||||
if p2.SignatureValid != nil {
|
||||
t.Error("SignatureValid should be nil when validation disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ go 1.22
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||
github.com/meshcore-analyzer/sigvalidate v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||
|
||||
replace github.com/meshcore-analyzer/sigvalidate => ../../internal/sigvalidate
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -248,7 +248,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
// Format 1: Raw packet (meshcoretomqtt / Cisien format)
|
||||
rawHex, _ := msg["raw"].(string)
|
||||
if rawHex != "" {
|
||||
decoded, err := DecodePacket(rawHex, channelKeys)
|
||||
decoded, err := DecodePacket(rawHex, channelKeys, false)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] decode error: %v", tag, err)
|
||||
return
|
||||
@@ -446,13 +446,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
log.Printf("MQTT [%s] channel insert error: %v", tag, err)
|
||||
}
|
||||
|
||||
// Upsert sender as a companion node
|
||||
if sender != "" {
|
||||
senderKey := "sender-" + strings.ToLower(sender)
|
||||
if err := store.UpsertNode(senderKey, sender, "companion", nil, nil, now); err != nil {
|
||||
log.Printf("MQTT [%s] sender node upsert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
// Note: we intentionally do NOT create a node entry for channel message senders.
|
||||
// Channel messages don't carry the sender's real pubkey, so any entry we create
|
||||
// would use a synthetic key ("sender-<name>") that doesn't match the real pubkey
|
||||
// used for claiming/health lookups. The node will get a proper entry when it
|
||||
// sends an advert. See issue #665.
|
||||
|
||||
log.Printf("MQTT [%s] channel message: ch%s from %s", tag, channelIdx, firstNonEmpty(sender, "unknown"))
|
||||
return
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
func newTestStore(t *testing.T) *PacketStore {
|
||||
t.Helper()
|
||||
return &PacketStore{
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
invCooldown: 10 * time.Second,
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
invCooldown: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ func populateAllCaches(s *PacketStore) {
|
||||
s.rfCache["global"] = dummy
|
||||
s.topoCache["global"] = dummy
|
||||
s.hashCache["global"] = dummy
|
||||
s.collisionCache["global"] = dummy
|
||||
s.chanCache["global"] = dummy
|
||||
s.distCache["global"] = dummy
|
||||
s.subpathCache["global"] = dummy
|
||||
@@ -39,12 +41,13 @@ func cachePopulated(s *PacketStore) map[string]bool {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
return map[string]bool{
|
||||
"rf": len(s.rfCache) > 0,
|
||||
"topo": len(s.topoCache) > 0,
|
||||
"hash": len(s.hashCache) > 0,
|
||||
"chan": len(s.chanCache) > 0,
|
||||
"dist": len(s.distCache) > 0,
|
||||
"subpath": len(s.subpathCache) > 0,
|
||||
"rf": len(s.rfCache) > 0,
|
||||
"topo": len(s.topoCache) > 0,
|
||||
"hash": len(s.hashCache) > 0,
|
||||
"collision": len(s.collisionCache) > 0,
|
||||
"chan": len(s.chanCache) > 0,
|
||||
"dist": len(s.distCache) > 0,
|
||||
"subpath": len(s.subpathCache) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +93,8 @@ func TestInvalidateCachesFor_NewTransmissionsOnly(t *testing.T) {
|
||||
if pop["hash"] {
|
||||
t.Error("hash cache should be cleared on new transmissions")
|
||||
}
|
||||
for _, name := range []string{"rf", "topo", "chan", "dist", "subpath"} {
|
||||
// collisionCache should NOT be cleared by transmissions alone (only by hasNewNodes)
|
||||
for _, name := range []string{"rf", "topo", "collision", "chan", "dist", "subpath"} {
|
||||
if !pop[name] {
|
||||
t.Errorf("%s cache should NOT be cleared on transmission-only ingest", name)
|
||||
}
|
||||
@@ -331,3 +335,180 @@ func BenchmarkCacheHitDuringIngestion(b *testing.B) {
|
||||
}
|
||||
b.ReportMetric(float64(hits)/float64(hits+misses)*100, "hit%")
|
||||
}
|
||||
|
||||
// TestInvCooldownFromConfig verifies that invalidationDebounce from config
|
||||
// is wired to invCooldown on PacketStore.
|
||||
func TestInvCooldownFromConfig(t *testing.T) {
|
||||
// Default without config
|
||||
ps := NewPacketStore(nil, nil)
|
||||
if ps.invCooldown != 300*time.Second {
|
||||
t.Errorf("default invCooldown = %v, want 300s", ps.invCooldown)
|
||||
}
|
||||
|
||||
// With config override
|
||||
ct := map[string]interface{}{"invalidationDebounce": float64(60)}
|
||||
ps2 := NewPacketStore(nil, nil, ct)
|
||||
if ps2.invCooldown != 60*time.Second {
|
||||
t.Errorf("configured invCooldown = %v, want 60s", ps2.invCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollisionCacheNotClearedByTransmissions verifies that collisionCache
|
||||
// is only cleared by hasNewNodes, not hasNewTransmissions (fixes #720).
|
||||
func TestCollisionCacheNotClearedByTransmissions(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewTransmissions: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
if !pop["collision"] {
|
||||
t.Error("collisionCache should NOT be cleared by hasNewTransmissions alone")
|
||||
}
|
||||
if pop["hash"] {
|
||||
t.Error("hashCache should be cleared by hasNewTransmissions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollisionCacheClearedByNewNodes verifies that collisionCache IS cleared
|
||||
// when genuinely new nodes are discovered.
|
||||
func TestCollisionCacheClearedByNewNodes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewNodes: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
if pop["collision"] {
|
||||
t.Error("collisionCache should be cleared by hasNewNodes")
|
||||
}
|
||||
// Other caches should survive
|
||||
for _, name := range []string{"rf", "topo", "hash", "chan", "dist", "subpath"} {
|
||||
if !pop[name] {
|
||||
t.Errorf("%s cache should NOT be cleared on new-nodes-only ingest", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheSurvivesMultipleIngestCyclesWithinCooldown verifies that caches
|
||||
// survive repeated ingest cycles during the cooldown period.
|
||||
func TestCacheSurvivesMultipleIngestCyclesWithinCooldown(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.invCooldown = 200 * time.Millisecond
|
||||
|
||||
// First invalidation goes through (starts cooldown)
|
||||
populateAllCaches(s)
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||
pop := cachePopulated(s)
|
||||
if pop["rf"] {
|
||||
t.Error("rf should be cleared on first invalidation")
|
||||
}
|
||||
|
||||
// Repopulate and simulate 5 rapid ingest cycles
|
||||
populateAllCaches(s)
|
||||
for i := 0; i < 5; i++ {
|
||||
s.invalidateCachesFor(cacheInvalidation{
|
||||
hasNewObservations: true,
|
||||
hasNewTransmissions: true,
|
||||
hasNewPaths: true,
|
||||
})
|
||||
}
|
||||
|
||||
// All caches should survive during cooldown
|
||||
pop = cachePopulated(s)
|
||||
for name, has := range pop {
|
||||
if !has {
|
||||
t.Errorf("%s cache should survive during cooldown period (ingest cycle %d)", name, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewNodesAccumulatedDuringCooldown verifies that hasNewNodes flags
|
||||
// accumulated during cooldown are applied when cooldown expires.
|
||||
func TestNewNodesAccumulatedDuringCooldown(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.invCooldown = 100 * time.Millisecond
|
||||
|
||||
// First call starts cooldown
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||
|
||||
// During cooldown, accumulate hasNewNodes
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewNodes: true})
|
||||
|
||||
// Verify accumulated
|
||||
s.cacheMu.Lock()
|
||||
if s.pendingInv == nil || !s.pendingInv.hasNewNodes {
|
||||
t.Error("hasNewNodes should be accumulated in pendingInv")
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Wait for cooldown
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Trigger flush
|
||||
populateAllCaches(s)
|
||||
s.invalidateCachesFor(cacheInvalidation{})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
if pop["collision"] {
|
||||
t.Error("collisionCache should be cleared after pending hasNewNodes is flushed")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkAnalyticsLatencyCacheHitVsMiss benchmarks cache hit vs miss
|
||||
// for analytics endpoints to demonstrate the performance impact.
|
||||
func BenchmarkAnalyticsLatencyCacheHitVsMiss(b *testing.B) {
|
||||
s := &PacketStore{
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 1800 * time.Second,
|
||||
invCooldown: 300 * time.Second,
|
||||
}
|
||||
|
||||
// Pre-populate cache
|
||||
s.cacheMu.Lock()
|
||||
s.rfCache["global"] = &cachedResult{
|
||||
data: map[string]interface{}{"bins": make([]int, 100)},
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Trigger initial invalidation to start cooldown
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||
|
||||
var hits, misses int64
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Re-populate (simulates query filling cache)
|
||||
s.cacheMu.Lock()
|
||||
if len(s.rfCache) == 0 {
|
||||
s.rfCache["global"] = &cachedResult{
|
||||
data: map[string]interface{}{"bins": make([]int, 100)},
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Simulate ingest (rate-limited)
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||
|
||||
// Check hit
|
||||
s.cacheMu.Lock()
|
||||
if len(s.rfCache) > 0 {
|
||||
hits++
|
||||
} else {
|
||||
misses++
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
hitRate := float64(hits) / float64(hits+misses) * 100
|
||||
b.ReportMetric(hitRate, "hit%")
|
||||
if hitRate < 50 {
|
||||
b.Errorf("hit rate %.1f%% is below 50%% target", hitRate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2198,6 +2198,53 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHashSizesDistributionByRepeatersFiltersRole(t *testing.T) {
|
||||
db := setupRichTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsHashSizes("")
|
||||
|
||||
// distributionByRepeaters should only count repeater nodes.
|
||||
// Rich test DB: aabbccdd11223344 = repeater (hash size 2), eeff00112233aabb = companion (hash size 3).
|
||||
dbr, ok := result["distributionByRepeaters"].(map[string]int)
|
||||
if !ok {
|
||||
t.Fatal("expected distributionByRepeaters map")
|
||||
}
|
||||
// Only the repeater node should be counted.
|
||||
if dbr["3"] != 0 {
|
||||
t.Errorf("distributionByRepeaters[3] = %d, want 0 (companion should be excluded)", dbr["3"])
|
||||
}
|
||||
if dbr["2"] != 1 {
|
||||
t.Errorf("distributionByRepeaters[2] = %d, want 1 (repeater)", dbr["2"])
|
||||
}
|
||||
|
||||
// multiByteNodes should include role field for frontend filtering.
|
||||
mbn, ok := result["multiByteNodes"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected multiByteNodes slice")
|
||||
}
|
||||
for _, node := range mbn {
|
||||
if _, hasRole := node["role"]; !hasRole {
|
||||
t.Errorf("multiByteNodes entry missing 'role' field: %v", node)
|
||||
}
|
||||
}
|
||||
// Verify companion is included in multiByteNodes (it's multi-byte) with correct role.
|
||||
foundCompanion := false
|
||||
for _, node := range mbn {
|
||||
if node["pubkey"] == "eeff00112233aabb" {
|
||||
foundCompanion = true
|
||||
if node["role"] != "companion" {
|
||||
t.Errorf("companion node role = %v, want 'companion'", node["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundCompanion {
|
||||
t.Error("expected companion node in multiByteNodes (multi-byte adopters should include all roles)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetAnalyticsSubpaths(t *testing.T) {
|
||||
db := setupRichTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -4086,6 +4133,90 @@ func TestIndexByNodePreCheck(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestIndexByNodeResolvedPath tests that resolved_path entries are indexed in byNode.
|
||||
func TestIndexByNodeResolvedPath(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
|
||||
t.Run("indexes resolved path pubkeys from observations", func(t *testing.T) {
|
||||
relayPK := "aabb1122334455ff"
|
||||
tx := &StoreTx{
|
||||
Hash: "rp1",
|
||||
DecodedJSON: `{"type":"CHAN","text":"hello"}`, // no pubKey fields
|
||||
Observations: []*StoreObs{
|
||||
{ResolvedPath: []*string{&relayPK}},
|
||||
},
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode[relayPK]) != 1 {
|
||||
t.Errorf("expected relay pubkey indexed, got %d", len(store.byNode[relayPK]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips null entries in resolved path", func(t *testing.T) {
|
||||
pk := "cc11dd22ee33ff44"
|
||||
tx := &StoreTx{
|
||||
Hash: "rp2",
|
||||
Observations: []*StoreObs{
|
||||
{ResolvedPath: []*string{nil, &pk, nil}},
|
||||
},
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode[pk]) != 1 {
|
||||
t.Errorf("expected resolved pubkey indexed, got %d", len(store.byNode[pk]))
|
||||
}
|
||||
// Verify nil entries didn't create empty-string keys
|
||||
if _, exists := store.byNode[""]; exists {
|
||||
t.Error("nil/empty resolved path entries should not create byNode entries")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relay-only node appears in byNode", func(t *testing.T) {
|
||||
// A packet with no decoded pubkey fields, only a relay in resolved path
|
||||
relayOnly := "relay0only0pubkey"
|
||||
tx := &StoreTx{
|
||||
Hash: "rp3",
|
||||
// No DecodedJSON at all — pure relay
|
||||
Observations: []*StoreObs{
|
||||
{ResolvedPath: []*string{&relayOnly}},
|
||||
},
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode[relayOnly]) != 1 {
|
||||
t.Errorf("expected relay-only node indexed, got %d", len(store.byNode[relayOnly]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dedup between decoded JSON and resolved path", func(t *testing.T) {
|
||||
pk := "dedup0test0pk1234"
|
||||
tx := &StoreTx{
|
||||
Hash: "rp4",
|
||||
DecodedJSON: `{"pubKey":"` + pk + `"}`,
|
||||
Observations: []*StoreObs{
|
||||
{ResolvedPath: []*string{&pk}},
|
||||
},
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode[pk]) != 1 {
|
||||
t.Errorf("expected dedup to keep 1 entry, got %d", len(store.byNode[pk]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indexes tx.ResolvedPath when observations empty", func(t *testing.T) {
|
||||
rpPK := "txlevel0resolved1"
|
||||
tx := &StoreTx{
|
||||
Hash: "rp5",
|
||||
ResolvedPath: []*string{&rpPK},
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode[rpPK]) != 1 {
|
||||
t.Errorf("expected tx-level resolved path indexed, got %d", len(store.byNode[rpPK]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkIndexByNode measures indexByNode performance with and without pubkey
|
||||
// fields to demonstrate the strings.Contains pre-check optimization.
|
||||
func BenchmarkIndexByNode(b *testing.B) {
|
||||
@@ -4339,3 +4470,53 @@ func TestHandleBatchObservations(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIngestTraceBroadcastIncludesPath verifies that TRACE packet broadcasts
|
||||
// include decoded.path with hopsCompleted (#683).
|
||||
func TestIngestTraceBroadcastIncludesPath(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
initialMax := store.MaxTransmissionID()
|
||||
|
||||
// TRACE packet: header=0x25, path_byte=0x02 (2 SNR bytes), 2 SNR bytes,
|
||||
// then payload: tag(4) + authCode(4) + flags(1) + 4 hop hashes (1-byte each)
|
||||
traceHex := "2502AABB010000000200000000DEADBEEF"
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES (?, 'tracehash683test', ?, 1, 9, '')`, traceHex, now)
|
||||
newTxID := 0
|
||||
db.conn.QueryRow("SELECT MAX(id) FROM transmissions").Scan(&newTxID)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 5.0, -100, '["aa"]', ?)`, newTxID, time.Now().Unix())
|
||||
|
||||
broadcastMaps, _ := store.IngestNewFromDB(initialMax, 100)
|
||||
if len(broadcastMaps) < 1 {
|
||||
t.Fatal("expected >=1 broadcast maps")
|
||||
}
|
||||
|
||||
bm := broadcastMaps[0]
|
||||
decoded, ok := bm["decoded"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("broadcast map missing 'decoded'")
|
||||
}
|
||||
|
||||
pathObj, ok := decoded["path"]
|
||||
if !ok {
|
||||
t.Fatal("decoded missing 'path' for TRACE packet — hopsCompleted not delivered to frontend (#683)")
|
||||
}
|
||||
|
||||
// The path should be a Path struct with HopsCompleted = 2
|
||||
pathStruct, ok := pathObj.(Path)
|
||||
if !ok {
|
||||
t.Fatalf("expected Path struct, got %T", pathObj)
|
||||
}
|
||||
if pathStruct.HopsCompleted == nil {
|
||||
t.Fatal("path.HopsCompleted is nil for TRACE packet")
|
||||
}
|
||||
if *pathStruct.HopsCompleted != 2 {
|
||||
t.Errorf("expected hopsCompleted=2, got %d", *pathStruct.HopsCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-6
@@ -1704,12 +1704,10 @@ func nullInt(ni sql.NullInt64) interface{} {
|
||||
// Returns the number of transmissions deleted.
|
||||
// Opens a separate read-write connection since the main connection is read-only.
|
||||
func (db *DB) PruneOldPackets(days int) (int64, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
rw, err := openRW(db.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
defer rw.Close()
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
@@ -2053,12 +2051,10 @@ func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) {
|
||||
|
||||
// PruneOldMetrics deletes observer_metrics rows older than retentionDays.
|
||||
func (db *DB) PruneOldMetrics(retentionDays int) (int64, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
rw, err := openRW(db.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
defer rw.Close()
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
|
||||
@@ -2072,3 +2068,14 @@ func (db *DB) PruneOldMetrics(retentionDays int) (int64, error) {
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TouchNodeLastSeen updates last_seen for a node identified by full public key.
|
||||
// Only updates if the new timestamp is newer than the existing value (or NULL).
|
||||
// Returns nil even if no rows are affected (node doesn't exist).
|
||||
func (db *DB) TouchNodeLastSeen(pubkey string, timestamp string) error {
|
||||
_, err := db.conn.Exec(
|
||||
"UPDATE nodes SET last_seen = ? WHERE public_key = ? AND (last_seen IS NULL OR last_seen < ?)",
|
||||
timestamp, pubkey, timestamp,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
+10
-8
@@ -72,7 +72,8 @@ func setupTestDB(t *testing.T) *DB {
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
timestamp INTEGER NOT NULL,
|
||||
resolved_path TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observer_metrics (
|
||||
@@ -95,7 +96,7 @@ func setupTestDB(t *testing.T) *DB {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &DB{conn: conn, isV3: true}
|
||||
return &DB{conn: conn, isV3: true, hasResolvedPath: true}
|
||||
}
|
||||
|
||||
func seedTestData(t *testing.T, db *DB) {
|
||||
@@ -132,14 +133,15 @@ func seedTestData(t *testing.T, db *DB) {
|
||||
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}')`, yesterday)
|
||||
|
||||
// Seed observations (use unix timestamps)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?)`, recentEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 8.0, -95, '["aa"]', ?)`, recentEpoch-100)
|
||||
// resolved_path contains full pubkeys parallel to path_json hops
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
|
||||
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?, '["aabbccdd11223344","eeff00112233aabb"]')`, recentEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
|
||||
VALUES (1, 2, 8.0, -95, '["aa"]', ?, '["aabbccdd11223344"]')`, recentEpoch-100)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (2, 1, 15.0, -85, '[]', ?)`, yesterdayEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (3, 1, 10.0, -92, '["cc"]', ?)`, yesterdayEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
|
||||
VALUES (3, 1, 10.0, -92, '["cc"]', ?, '["1122334455667788"]')`, yesterdayEpoch)
|
||||
}
|
||||
|
||||
func TestGetStats(t *testing.T) {
|
||||
|
||||
+38
-8
@@ -9,6 +9,8 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/sigvalidate"
|
||||
)
|
||||
|
||||
// Route type constants (header bits 1-0)
|
||||
@@ -60,9 +62,10 @@ type TransportCodes struct {
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
type Path struct {
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
HopsCompleted *int `json:"hopsCompleted,omitempty"`
|
||||
}
|
||||
|
||||
// AdvertFlags holds decoded advert flag bits.
|
||||
@@ -91,6 +94,7 @@ type Payload struct {
|
||||
Timestamp uint32 `json:"timestamp,omitempty"`
|
||||
TimestampISO string `json:"timestampISO,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
SignatureValid *bool `json:"signatureValid,omitempty"`
|
||||
Flags *AdvertFlags `json:"flags,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
@@ -187,7 +191,7 @@ func decodeAck(buf []byte) Payload {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAdvert(buf []byte) Payload {
|
||||
func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
if len(buf) < 100 {
|
||||
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
@@ -205,6 +209,16 @@ func decodeAdvert(buf []byte) Payload {
|
||||
Signature: signature,
|
||||
}
|
||||
|
||||
if validateSignatures {
|
||||
valid, err := sigvalidate.ValidateAdvert(buf[0:32], buf[36:100], timestamp, appdata)
|
||||
if err != nil {
|
||||
f := false
|
||||
p.SignatureValid = &f
|
||||
} else {
|
||||
p.SignatureValid = &valid
|
||||
}
|
||||
}
|
||||
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
@@ -307,7 +321,7 @@ func decodeTrace(buf []byte) Payload {
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte) Payload {
|
||||
func decodePayload(payloadType int, buf []byte, validateSignatures bool) Payload {
|
||||
switch payloadType {
|
||||
case PayloadREQ:
|
||||
return decodeEncryptedPayload("REQ", buf)
|
||||
@@ -318,7 +332,7 @@ func decodePayload(payloadType int, buf []byte) Payload {
|
||||
case PayloadACK:
|
||||
return decodeAck(buf)
|
||||
case PayloadADVERT:
|
||||
return decodeAdvert(buf)
|
||||
return decodeAdvert(buf, validateSignatures)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf)
|
||||
case PayloadANON_REQ:
|
||||
@@ -333,7 +347,7 @@ func decodePayload(payloadType int, buf []byte) Payload {
|
||||
}
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
@@ -371,12 +385,17 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf)
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
// The header path contains SNR bytes — one per hop that actually forwarded.
|
||||
// We expose hopsCompleted (count of SNR bytes) so consumers can distinguish
|
||||
// how far the trace got vs the full intended route.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
// The header path hops count represents SNR entries = completed hops
|
||||
hopsCompleted := path.HashCount
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
@@ -385,9 +404,20 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
path.HopsCompleted = &hopsCompleted
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-hop direct packets have hash_count=0 (lower 6 bits of pathByte),
|
||||
// which makes the generic formula yield a bogus hashSize. Reset to 0
|
||||
// (unknown) so API consumers get correct data. We mask with 0x3F to check
|
||||
// only hash_count, matching the JS frontend approach — the upper hash_size
|
||||
// bits are meaningless when there are no hops. Skip TRACE packets — they
|
||||
// use hashSize to parse hops from the payload above.
|
||||
if (header.RouteType == RouteDirect || header.RouteType == RouteTransportDirect) && pathByte&0x3F == 0 && header.PayloadType != PayloadTRACE {
|
||||
path.HashSize = 0
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
|
||||
+218
-2
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -65,7 +68,7 @@ func TestDecodePacket_TransportFloodHasCodes(t *testing.T) {
|
||||
// Path byte: 0x00 (hashSize=1, hashCount=0)
|
||||
// Payload: at least some bytes for GRP_TXT
|
||||
hex := "14AABBCCDD00112233445566778899"
|
||||
pkt, err := DecodePacket(hex)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -85,7 +88,7 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
|
||||
// Path byte: 0x00 (no hops)
|
||||
// Some payload bytes
|
||||
hex := "110011223344556677889900AABBCCDD"
|
||||
pkt, err := DecodePacket(hex)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -235,6 +238,87 @@ func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantE
|
||||
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
|
||||
}
|
||||
|
||||
func TestZeroHopDirectHashSize(t *testing.T) {
|
||||
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
||||
// pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0
|
||||
// Need at least a few payload bytes after pathByte.
|
||||
hex := "02" + "00" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
||||
// pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0
|
||||
// because hash_count is zero (lower 6 bits are 0).
|
||||
hex := "02" + "40" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSize(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0x00 → hash_count=0 → should get HashSize=0
|
||||
hex := "03" + "11223344" + "00" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
|
||||
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) {
|
||||
// FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01
|
||||
// pathByte=0x00 → even though hash_count=0, non-DIRECT should keep HashSize=1
|
||||
hex := "01" + "00" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 1 {
|
||||
t.Errorf("FLOOD zero pathByte: want HashSize=1 (unchanged), got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectNonZeroHopKeepsHashSize(t *testing.T) {
|
||||
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
||||
// pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1
|
||||
// Need 1 hop hash byte after pathByte.
|
||||
hex := "02" + "01" + repeatHex("BB", 21)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 1 {
|
||||
t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatHex(byteHex string, n int) string {
|
||||
s := ""
|
||||
for i := 0; i < n; i++ {
|
||||
@@ -242,3 +326,135 @@ func repeatHex(byteHex string, n int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDecodePacket_TraceHopsCompleted(t *testing.T) {
|
||||
// Build a TRACE packet:
|
||||
// header: route=FLOOD(1), payload=TRACE(9), version=0 → (0<<6)|(9<<2)|1 = 0x25
|
||||
// path_length: hash_size bits=0b00 (1-byte), hash_count=2 (2 SNR bytes) → 0x02
|
||||
// path: 2 SNR bytes: 0xAA, 0xBB
|
||||
// payload: tag(4 LE) + authCode(4 LE) + flags(1) + 4 hop hashes (1 byte each)
|
||||
hex := "2502AABB" + // header + path_length + 2 SNR bytes
|
||||
"01000000" + // tag = 1
|
||||
"02000000" + // authCode = 2
|
||||
"00" + // flags = 0
|
||||
"DEADBEEF" // 4 hops (1-byte hash each)
|
||||
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "TRACE" {
|
||||
t.Fatalf("expected TRACE, got %s", pkt.Payload.Type)
|
||||
}
|
||||
// Full intended route = 4 hops from payload
|
||||
if len(pkt.Path.Hops) != 4 {
|
||||
t.Errorf("expected 4 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
||||
}
|
||||
// HopsCompleted = 2 (from header path SNR count)
|
||||
if pkt.Path.HopsCompleted == nil {
|
||||
t.Fatal("expected HopsCompleted to be set")
|
||||
}
|
||||
if *pkt.Path.HopsCompleted != 2 {
|
||||
t.Errorf("expected HopsCompleted=2, got %d", *pkt.Path.HopsCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TraceNoSNR(t *testing.T) {
|
||||
// TRACE with 0 SNR bytes (trace hasn't been forwarded yet)
|
||||
// path_length: hash_size=0b00 (1-byte), hash_count=0 → 0x00
|
||||
hex := "2500" + // header + path_length (0 hops in header)
|
||||
"01000000" + // tag
|
||||
"02000000" + // authCode
|
||||
"00" + // flags
|
||||
"AABBCC" // 3 hops intended
|
||||
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Path.HopsCompleted == nil {
|
||||
t.Fatal("expected HopsCompleted to be set")
|
||||
}
|
||||
if *pkt.Path.HopsCompleted != 0 {
|
||||
t.Errorf("expected HopsCompleted=0, got %d", *pkt.Path.HopsCompleted)
|
||||
}
|
||||
if len(pkt.Path.Hops) != 3 {
|
||||
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TraceFullyCompleted(t *testing.T) {
|
||||
// TRACE where all hops completed (SNR count = hop count)
|
||||
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
|
||||
hex := "2503AABBCC" + // header + path_length + 3 SNR bytes
|
||||
"01000000" + // tag
|
||||
"02000000" + // authCode
|
||||
"00" + // flags
|
||||
"DDEEFF" // 3 hops intended
|
||||
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Path.HopsCompleted == nil {
|
||||
t.Fatal("expected HopsCompleted to be set")
|
||||
}
|
||||
if *pkt.Path.HopsCompleted != 3 {
|
||||
t.Errorf("expected HopsCompleted=3, got %d", *pkt.Path.HopsCompleted)
|
||||
}
|
||||
if len(pkt.Path.Hops) != 3 {
|
||||
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAdvertSignatureValidation(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var timestamp uint32 = 1234567890
|
||||
appdata := []byte{0x02} // flags: repeater, no extras
|
||||
|
||||
// Build signed message: pubKey(32) + timestamp(4 LE) + appdata
|
||||
msg := make([]byte, 32+4+len(appdata))
|
||||
copy(msg[0:32], pub)
|
||||
binary.LittleEndian.PutUint32(msg[32:36], timestamp)
|
||||
copy(msg[36:], appdata)
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
|
||||
// Build a raw advert buffer: pubKey(32) + timestamp(4) + signature(64) + appdata
|
||||
buf := make([]byte, 100+len(appdata))
|
||||
copy(buf[0:32], pub)
|
||||
binary.LittleEndian.PutUint32(buf[32:36], timestamp)
|
||||
copy(buf[36:100], sig)
|
||||
copy(buf[100:], appdata)
|
||||
|
||||
// With validation enabled
|
||||
p := decodeAdvert(buf, true)
|
||||
if p.SignatureValid == nil {
|
||||
t.Fatal("expected SignatureValid to be set")
|
||||
}
|
||||
if !*p.SignatureValid {
|
||||
t.Error("expected valid signature")
|
||||
}
|
||||
if p.PubKey != hex.EncodeToString(pub) {
|
||||
t.Errorf("pubkey mismatch: got %s", p.PubKey)
|
||||
}
|
||||
|
||||
// Tamper with signature → invalid
|
||||
buf[40] ^= 0xFF
|
||||
p = decodeAdvert(buf, true)
|
||||
if p.SignatureValid == nil {
|
||||
t.Fatal("expected SignatureValid to be set")
|
||||
}
|
||||
if *p.SignatureValid {
|
||||
t.Error("expected invalid signature after tampering")
|
||||
}
|
||||
|
||||
// Without validation → SignatureValid should be nil
|
||||
p = decodeAdvert(buf, false)
|
||||
if p.SignatureValid != nil {
|
||||
t.Error("expected SignatureValid to be nil when validation disabled")
|
||||
}
|
||||
}
|
||||
|
||||
+292
-20
@@ -85,6 +85,12 @@ func makeTestStore(count int, startTime time.Time, intervalMin int) *PacketStore
|
||||
|
||||
// Subpath index
|
||||
addTxToSubpathIndex(store.spIndex, tx)
|
||||
|
||||
// Track bytes for self-accounting
|
||||
store.trackedBytes += estimateStoreTxBytes(tx)
|
||||
for _, obs := range tx.Observations {
|
||||
store.trackedBytes += estimateStoreObsBytes(obs)
|
||||
}
|
||||
}
|
||||
|
||||
return store
|
||||
@@ -166,43 +172,43 @@ func TestEvictStale_MemoryBasedEviction(t *testing.T) {
|
||||
// All packets are recent (1h old) so time-based won't trigger.
|
||||
store.retentionHours = 24
|
||||
store.maxMemoryMB = 3
|
||||
// Inject deterministic estimator: simulates 6MB (over 3MB limit).
|
||||
// Uses packet count so it scales correctly after eviction.
|
||||
store.memoryEstimator = func() float64 {
|
||||
return float64(len(store.packets)*5120+store.totalObs*500) / 1048576.0
|
||||
}
|
||||
// Set trackedBytes to simulate 6MB (over 3MB limit).
|
||||
store.trackedBytes = 6 * 1048576
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
t.Fatal("expected some evictions for memory cap")
|
||||
}
|
||||
estMB := store.estimatedMemoryMB()
|
||||
if estMB > 3.5 {
|
||||
t.Fatalf("expected <=3.5MB after eviction, got %.1fMB", estMB)
|
||||
// 25% safety cap should limit to 250 per pass
|
||||
if evicted > 250 {
|
||||
t.Fatalf("25%% safety cap violated: evicted %d", evicted)
|
||||
}
|
||||
// trackedBytes should have decreased
|
||||
if store.trackedBytes >= 6*1048576 {
|
||||
t.Fatal("trackedBytes should have decreased after eviction")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvictStale_MemoryBasedEviction_UnderestimatedHeap verifies that eviction
|
||||
// fires correctly when actual heap is much larger than a formula-based estimate
|
||||
// would report — the scenario that caused OOM kills in production.
|
||||
// TestEvictStale_MemoryBasedEviction_UnderestimatedHeap verifies that the 25%
|
||||
// safety cap prevents cascading eviction even when trackedBytes is very high.
|
||||
func TestEvictStale_MemoryBasedEviction_UnderestimatedHeap(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
store.retentionHours = 24
|
||||
store.maxMemoryMB = 500
|
||||
// Simulate actual heap 5x over budget (like production: ~5GB actual vs ~1GB limit).
|
||||
store.memoryEstimator = func() float64 {
|
||||
return 2500.0 // 2500MB actual vs 500MB limit
|
||||
}
|
||||
// Simulate trackedBytes 5x over budget.
|
||||
store.trackedBytes = 2500 * 1048576
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
t.Fatal("expected evictions when heap is 5x over limit")
|
||||
t.Fatal("expected evictions when tracked is 5x over limit")
|
||||
}
|
||||
// Should keep roughly 500/2500 * 0.9 = 18% of packets → ~180 of 1000.
|
||||
remaining := len(store.packets)
|
||||
if remaining > 250 {
|
||||
t.Fatalf("expected most packets evicted (heap 5x over), but %d of 1000 remain", remaining)
|
||||
// Safety cap: max 25% per pass = 250
|
||||
if evicted > 250 {
|
||||
t.Fatalf("25%% safety cap violated: evicted %d of 1000", evicted)
|
||||
}
|
||||
if evicted != 250 {
|
||||
t.Fatalf("expected exactly 250 evicted (25%% cap), got %d", evicted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +245,79 @@ func TestEvictStale_CleansNodeIndexes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := &PacketStore{
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
byObserver: make(map[string][]*StoreObs),
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
spIndex: make(map[string]int),
|
||||
distHops: make([]distHopRecord, 0),
|
||||
distPaths: make([]distPathRecord, 0),
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
retentionHours: 24,
|
||||
}
|
||||
|
||||
// Create a packet indexed only via resolved_path (no decoded JSON pubkeys)
|
||||
relayPK := "relay0001abcdef"
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "hash_rp_001",
|
||||
FirstSeen: now.Add(-48 * time.Hour).UTC().Format(time.RFC3339),
|
||||
}
|
||||
rpPtr := &relayPK
|
||||
obs := &StoreObs{
|
||||
ID: 100,
|
||||
TransmissionID: 1,
|
||||
ObserverID: "obs0",
|
||||
Timestamp: tx.FirstSeen,
|
||||
ResolvedPath: []*string{rpPtr},
|
||||
}
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ResolvedPath = []*string{rpPtr}
|
||||
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[tx.Hash] = tx
|
||||
store.byTxID[tx.ID] = tx
|
||||
store.byObsID[obs.ID] = obs
|
||||
store.byObserver["obs0"] = append(store.byObserver["obs0"], obs)
|
||||
|
||||
// Index via resolved_path
|
||||
store.indexByNode(tx)
|
||||
|
||||
// Verify indexed
|
||||
if len(store.byNode[relayPK]) != 1 {
|
||||
t.Fatalf("expected 1 entry in byNode[%s], got %d", relayPK, len(store.byNode[relayPK]))
|
||||
}
|
||||
if !store.nodeHashes[relayPK][tx.Hash] {
|
||||
t.Fatalf("expected nodeHashes[%s] to contain %s", relayPK, tx.Hash)
|
||||
}
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted != 1 {
|
||||
t.Fatalf("expected 1 evicted, got %d", evicted)
|
||||
}
|
||||
|
||||
// Verify resolved_path entries are cleaned up
|
||||
if len(store.byNode[relayPK]) != 0 {
|
||||
t.Fatalf("expected byNode[%s] to be empty after eviction, got %d", relayPK, len(store.byNode[relayPK]))
|
||||
}
|
||||
if _, exists := store.nodeHashes[relayPK]; exists {
|
||||
t.Fatalf("expected nodeHashes[%s] to be deleted after eviction", relayPK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_RunEvictionThreadSafe(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(20, now.Add(-48*time.Hour), 0)
|
||||
@@ -302,3 +381,196 @@ func TestCacheTTLDefaults(t *testing.T) {
|
||||
t.Fatalf("expected default rfCacheTTL=15s, got %v", store.rfCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Self-accounting memory tracking tests ---
|
||||
|
||||
func TestTrackedBytes_IncreasesOnInsert(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(0, now, 0)
|
||||
if store.trackedBytes != 0 {
|
||||
t.Fatalf("expected 0 trackedBytes for empty store, got %d", store.trackedBytes)
|
||||
}
|
||||
|
||||
store2 := makeTestStore(10, now, 1)
|
||||
if store2.trackedBytes <= 0 {
|
||||
t.Fatal("expected positive trackedBytes after inserting 10 packets")
|
||||
}
|
||||
// Each packet has 2 observations; should be roughly 10*(384+5*48) + 20*(192+2*48) = 10*624 + 20*288 = 12000
|
||||
expectedMin := int64(10*600 + 20*250) // rough lower bound
|
||||
if store2.trackedBytes < expectedMin {
|
||||
t.Fatalf("trackedBytes %d seems too low (expected > %d)", store2.trackedBytes, expectedMin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackedBytes_DecreasesOnEvict(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(100, now.Add(-48*time.Hour), 0)
|
||||
store.retentionHours = 24
|
||||
|
||||
beforeBytes := store.trackedBytes
|
||||
if beforeBytes <= 0 {
|
||||
t.Fatal("expected positive trackedBytes before eviction")
|
||||
}
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted != 100 {
|
||||
t.Fatalf("expected 100 evicted, got %d", evicted)
|
||||
}
|
||||
if store.trackedBytes != 0 {
|
||||
t.Fatalf("expected 0 trackedBytes after evicting all, got %d", store.trackedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackedBytes_MatchesExpectedAfterMixedInsertEvict(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
// Create 100 packets, 50 old + 50 recent
|
||||
store := makeTestStore(100, now.Add(-48*time.Hour), 0)
|
||||
for i := 50; i < 100; i++ {
|
||||
store.packets[i].FirstSeen = now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
store.retentionHours = 24
|
||||
|
||||
totalBefore := store.trackedBytes
|
||||
|
||||
// Calculate expected bytes for first 50 packets (to be evicted)
|
||||
var evictedBytes int64
|
||||
for i := 0; i < 50; i++ {
|
||||
tx := store.packets[i]
|
||||
evictedBytes += estimateStoreTxBytes(tx)
|
||||
for _, obs := range tx.Observations {
|
||||
evictedBytes += estimateStoreObsBytes(obs)
|
||||
}
|
||||
}
|
||||
|
||||
store.EvictStale()
|
||||
|
||||
expectedAfter := totalBefore - evictedBytes
|
||||
if store.trackedBytes != expectedAfter {
|
||||
t.Fatalf("trackedBytes %d != expected %d (before=%d, evicted=%d)",
|
||||
store.trackedBytes, expectedAfter, totalBefore, evictedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatermarkHysteresis(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
store.retentionHours = 0 // no time-based eviction
|
||||
store.maxMemoryMB = 1 // 1MB budget
|
||||
|
||||
// Set trackedBytes to just above high watermark
|
||||
highWatermark := int64(1 * 1048576)
|
||||
lowWatermark := int64(float64(highWatermark) * 0.85)
|
||||
store.trackedBytes = highWatermark + 1
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
t.Fatal("expected eviction when above high watermark")
|
||||
}
|
||||
if store.trackedBytes > lowWatermark+1024 {
|
||||
t.Fatalf("expected trackedBytes near low watermark after eviction, got %d (low=%d)",
|
||||
store.trackedBytes, lowWatermark)
|
||||
}
|
||||
|
||||
// Now set trackedBytes to just below high watermark — should NOT trigger
|
||||
store.trackedBytes = highWatermark - 1
|
||||
evicted2 := store.EvictStale()
|
||||
if evicted2 != 0 {
|
||||
t.Fatalf("expected no eviction below high watermark, got %d", evicted2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyCap25Percent(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
store.retentionHours = 0
|
||||
store.maxMemoryMB = 1
|
||||
|
||||
// Set trackedBytes way over limit to force maximum eviction
|
||||
store.trackedBytes = 100 * 1048576 // 100MB vs 1MB limit
|
||||
|
||||
evicted := store.EvictStale()
|
||||
// 25% of 1000 = 250
|
||||
if evicted > 250 {
|
||||
t.Fatalf("25%% safety cap violated: evicted %d of 1000 (max should be 250)", evicted)
|
||||
}
|
||||
if evicted != 250 {
|
||||
t.Fatalf("expected exactly 250 evicted (25%% cap), got %d", evicted)
|
||||
}
|
||||
if len(store.packets) != 750 {
|
||||
t.Fatalf("expected 750 remaining, got %d", len(store.packets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplePassesConverge(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
store.retentionHours = 0
|
||||
// Set budget to half the actual tracked bytes — requires ~2 passes
|
||||
actualBytes := store.trackedBytes
|
||||
store.maxMemoryMB = int(float64(actualBytes) / 1048576.0 / 2)
|
||||
if store.maxMemoryMB < 1 {
|
||||
store.maxMemoryMB = 1
|
||||
}
|
||||
|
||||
totalEvicted := 0
|
||||
for pass := 0; pass < 20; pass++ {
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
break
|
||||
}
|
||||
totalEvicted += evicted
|
||||
}
|
||||
|
||||
// After convergence, trackedBytes should be at or below high watermark
|
||||
// (may be between low and high due to hysteresis — that's fine)
|
||||
highWatermark := int64(store.maxMemoryMB) * 1048576
|
||||
if store.trackedBytes > highWatermark {
|
||||
t.Fatalf("did not converge: trackedBytes=%d (%.1fMB) > highWatermark=%d after multiple passes",
|
||||
store.trackedBytes, float64(store.trackedBytes)/1048576.0, highWatermark)
|
||||
}
|
||||
if totalEvicted == 0 {
|
||||
t.Fatal("expected some evictions across multiple passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateStoreTxBytes(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
RawHex: "aabbcc",
|
||||
Hash: "hash1234",
|
||||
DecodedJSON: `{"pubKey":"pk1"}`,
|
||||
PathJSON: `["aa","bb"]`,
|
||||
}
|
||||
est := estimateStoreTxBytes(tx)
|
||||
// Verify the function returns a reasonable value matching our manual calculation
|
||||
manualCalc := int64(storeTxBaseBytes) + int64(len(tx.RawHex)+len(tx.Hash)+len(tx.DecodedJSON)+len(tx.PathJSON)) + int64(numIndexesPerTx*indexEntryBytes)
|
||||
if est != manualCalc {
|
||||
t.Fatalf("estimateStoreTxBytes = %d, want %d (manual calc)", est, manualCalc)
|
||||
}
|
||||
if est < 600 || est > 800 {
|
||||
t.Fatalf("estimateStoreTxBytes = %d, expected in range [600, 800]", est)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateStoreObsBytes(t *testing.T) {
|
||||
obs := &StoreObs{
|
||||
ObserverID: "obs123",
|
||||
PathJSON: `["aa"]`,
|
||||
}
|
||||
est := estimateStoreObsBytes(obs)
|
||||
// storeObsBaseBytes(192) + len(ObserverID=6) + len(PathJSON=6) + 2*48(96) = 300
|
||||
expected := int64(192 + 6 + 6 + 2*48)
|
||||
if est != expected {
|
||||
t.Fatalf("estimateStoreObsBytes = %d, want %d", est, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEviction100K(b *testing.B) {
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < b.N; i++ {
|
||||
b.StopTimer()
|
||||
store := makeTestStore(100000, now.Add(-48*time.Hour), 0)
|
||||
store.retentionHours = 24
|
||||
b.StartTimer()
|
||||
store.EvictStale()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||
github.com/meshcore-analyzer/sigvalidate v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||
|
||||
replace github.com/meshcore-analyzer/sigvalidate => ../../internal/sigvalidate
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// setupCapabilityTestDB creates a minimal in-memory DB with nodes table.
|
||||
func setupCapabilityTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
conn.Exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT,
|
||||
first_seen TEXT, packet_count INTEGER DEFAULT 0, model TEXT,
|
||||
firmware TEXT, client_version TEXT, radio TEXT, battery_mv INTEGER,
|
||||
uptime_secs INTEGER
|
||||
)`)
|
||||
return &DB{conn: conn}
|
||||
}
|
||||
|
||||
// addTestPacket adds a StoreTx to the store's internal structures including
|
||||
// the byPathHop index and byPayloadType index.
|
||||
func addTestPacket(store *PacketStore, tx *StoreTx) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
tx.ID = len(store.packets) + 1
|
||||
if tx.Hash == "" {
|
||||
tx.Hash = fmt.Sprintf("test-hash-%d", tx.ID)
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[tx.Hash] = tx
|
||||
store.byTxID[tx.ID] = tx
|
||||
if tx.PayloadType != nil {
|
||||
store.byPayloadType[*tx.PayloadType] = append(store.byPayloadType[*tx.PayloadType], tx)
|
||||
}
|
||||
addTxToPathHopIndex(store.byPathHop, tx)
|
||||
}
|
||||
|
||||
// buildPathByte returns a 2-char hex string for the path byte with given
|
||||
// hashSize (1-3) and hopCount.
|
||||
func buildPathByte(hashSize, hopCount int) string {
|
||||
b := byte(((hashSize - 1) & 0x3) << 6) | byte(hopCount&0x3F)
|
||||
return fmt.Sprintf("%02x", b)
|
||||
}
|
||||
|
||||
// makeTestAdvert creates a StoreTx representing a flood advert packet.
|
||||
func makeTestAdvert(pubkey string, hashSize int) *StoreTx {
|
||||
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": pubkey, "name": pubkey[:8]})
|
||||
pt := 4
|
||||
pathByte := buildPathByte(hashSize, 1)
|
||||
prefix := strings.ToLower(pubkey[:hashSize*2])
|
||||
rawHex := "01" + pathByte + prefix // flood header + path byte + hop prefix
|
||||
return &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: string(decoded),
|
||||
PathJSON: `["` + prefix + `"]`,
|
||||
FirstSeen: "2026-04-11T00:00:00.000Z",
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_Confirmed tests that a repeater advertising
|
||||
// with hash_size >= 2 is classified as "confirmed".
|
||||
func TestMultiByteCapability_Confirmed(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepA", "repeater", "2026-04-11T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "confirmed" {
|
||||
t.Errorf("expected confirmed, got %s", caps[0].Status)
|
||||
}
|
||||
if caps[0].Evidence != "advert" {
|
||||
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
|
||||
}
|
||||
if caps[0].MaxHashSize != 2 {
|
||||
t.Errorf("expected maxHashSize 2, got %d", caps[0].MaxHashSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_Suspected tests that a repeater whose prefix
|
||||
// appears in a multi-byte path is classified as "suspected".
|
||||
func TestMultiByteCapability_Suspected(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepB", "repeater", "2026-04-10T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Non-advert packet with 2-byte hash in path, hop prefix matching node
|
||||
pathByte := buildPathByte(2, 1)
|
||||
rawHex := "01" + pathByte + "aabb"
|
||||
pt := 1
|
||||
pkt := &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aabb"]`,
|
||||
FirstSeen: "2026-04-10T00:00:00.000Z",
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "suspected" {
|
||||
t.Errorf("expected suspected, got %s", caps[0].Status)
|
||||
}
|
||||
if caps[0].Evidence != "path" {
|
||||
t.Errorf("expected path evidence, got %s", caps[0].Evidence)
|
||||
}
|
||||
if caps[0].MaxHashSize != 2 {
|
||||
t.Errorf("expected maxHashSize 2, got %d", caps[0].MaxHashSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_Unknown tests that a repeater with only 1-byte
|
||||
// adverts and no multi-byte path appearances is classified as "unknown".
|
||||
func TestMultiByteCapability_Unknown(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepC", "repeater", "2026-04-08T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Advert with 1-byte hash only
|
||||
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 1))
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "unknown" {
|
||||
t.Errorf("expected unknown, got %s", caps[0].Status)
|
||||
}
|
||||
if caps[0].MaxHashSize != 1 {
|
||||
t.Errorf("expected maxHashSize 1, got %d", caps[0].MaxHashSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_PrefixCollision tests that when two repeaters
|
||||
// share the same prefix, one confirmed via advert, the other gets
|
||||
// suspected (not confirmed) from path data alone.
|
||||
func TestMultiByteCapability_PrefixCollision(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
// Two repeaters sharing 1-byte prefix "aa"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabb000000000001", "RepConfirmed", "repeater", "2026-04-11T00:00:00Z")
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aacc000000000002", "RepOther", "repeater", "2026-04-11T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// RepConfirmed has a 2-byte advert
|
||||
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
|
||||
|
||||
// A packet with 2-byte path containing 1-byte hop "aa" — both share this prefix
|
||||
pathByte := buildPathByte(2, 1)
|
||||
rawHex := "01" + pathByte + "aa"
|
||||
pt := 1
|
||||
pkt := &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aa"]`,
|
||||
FirstSeen: "2026-04-10T00:00:00.000Z",
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(caps))
|
||||
}
|
||||
|
||||
capByName := map[string]MultiByteCapEntry{}
|
||||
for _, c := range caps {
|
||||
capByName[c.Name] = c
|
||||
}
|
||||
|
||||
if capByName["RepConfirmed"].Status != "confirmed" {
|
||||
t.Errorf("RepConfirmed expected confirmed, got %s", capByName["RepConfirmed"].Status)
|
||||
}
|
||||
if capByName["RepOther"].Status != "suspected" {
|
||||
t.Errorf("RepOther expected suspected, got %s", capByName["RepOther"].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_TraceExcluded tests that TRACE packets (payload_type 8)
|
||||
// do NOT contribute to "suspected" multi-byte capability. TRACE packets carry
|
||||
// hash size in their own flags, so pre-1.14 repeaters can forward multi-byte
|
||||
// TRACEs without actually supporting multi-byte hashes. See #714.
|
||||
func TestMultiByteCapability_TraceExcluded(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepTrace", "repeater", "2026-04-10T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// TRACE packet (payload_type 8) with 2-byte hash in path
|
||||
pathByte := buildPathByte(2, 1)
|
||||
rawHex := "01" + pathByte + "aabb"
|
||||
pt := 8
|
||||
pkt := &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aabb"]`,
|
||||
FirstSeen: "2026-04-10T00:00:00.000Z",
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "unknown" {
|
||||
t.Errorf("expected unknown (TRACE excluded), got %s", caps[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_NonTraceStillSuspected verifies that non-TRACE packets
|
||||
// with 2-byte paths still correctly mark a repeater as "suspected".
|
||||
func TestMultiByteCapability_NonTraceStillSuspected(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepNonTrace", "repeater", "2026-04-10T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// GRP_TXT packet (payload_type 1) with 2-byte hash in path
|
||||
pathByte := buildPathByte(2, 1)
|
||||
rawHex := "01" + pathByte + "aabb"
|
||||
pt := 1
|
||||
pkt := &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aabb"]`,
|
||||
FirstSeen: "2026-04-10T00:00:00.000Z",
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "suspected" {
|
||||
t.Errorf("expected suspected, got %s", caps[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion verifies that
|
||||
// "confirmed" status from adverts is not affected by the TRACE exclusion.
|
||||
func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"aabbccdd11223344", "RepConfirmedTrace", "repeater", "2026-04-11T00:00:00Z")
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Advert with 2-byte hash (confirms capability)
|
||||
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
||||
|
||||
// TRACE packet also present — should not downgrade confirmed status
|
||||
pathByte := buildPathByte(2, 1)
|
||||
rawHex := "01" + pathByte + "aabb"
|
||||
pt := 8
|
||||
pkt := &StoreTx{
|
||||
RawHex: rawHex,
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aabb"]`,
|
||||
FirstSeen: "2026-04-10T00:00:00.000Z",
|
||||
}
|
||||
addTestPacket(store, pkt)
|
||||
|
||||
caps := store.computeMultiByteCapability()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(caps))
|
||||
}
|
||||
if caps[0].Status != "confirmed" {
|
||||
t.Errorf("expected confirmed (unaffected by TRACE), got %s", caps[0].Status)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,10 @@ func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
// Defense-in-depth: deduplicate unresolved prefix entries that match
|
||||
// resolved pubkey entries in the same neighbor set (fixes #698).
|
||||
entries = dedupPrefixEntries(entries)
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Score > entries[j].Score
|
||||
@@ -371,3 +375,75 @@ func (s *Server) buildNodeInfoMap() map[string]nodeInfo {
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// dedupPrefixEntries merges unresolved prefix entries with resolved pubkey entries
|
||||
// where the prefix is a prefix of the resolved pubkey. Defense-in-depth for #698.
|
||||
func dedupPrefixEntries(entries []NeighborEntry) []NeighborEntry {
|
||||
if len(entries) < 2 {
|
||||
return entries
|
||||
}
|
||||
|
||||
// Mark indices of unresolved entries to remove after merging.
|
||||
remove := make(map[int]bool)
|
||||
|
||||
for i := range entries {
|
||||
if entries[i].Pubkey != nil {
|
||||
continue // only check unresolved (no pubkey)
|
||||
}
|
||||
prefix := strings.ToLower(entries[i].Prefix)
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
// Find all resolved entries matching this prefix.
|
||||
matchIdx := -1
|
||||
matchCount := 0
|
||||
for j := range entries {
|
||||
if i == j || entries[j].Pubkey == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(*entries[j].Pubkey), prefix) {
|
||||
matchIdx = j
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
// Only merge when exactly one resolved entry matches — ambiguous
|
||||
// prefixes that match multiple resolved neighbors must not be
|
||||
// arbitrarily assigned to one of them.
|
||||
if matchCount != 1 {
|
||||
continue
|
||||
}
|
||||
j := matchIdx
|
||||
|
||||
// Merge counts from unresolved into resolved.
|
||||
entries[j].Count += entries[i].Count
|
||||
|
||||
// Preserve higher LastSeen.
|
||||
if entries[i].LastSeen > entries[j].LastSeen {
|
||||
entries[j].LastSeen = entries[i].LastSeen
|
||||
}
|
||||
|
||||
// Merge observers.
|
||||
obsSet := make(map[string]bool)
|
||||
for _, o := range entries[j].Observers {
|
||||
obsSet[o] = true
|
||||
}
|
||||
for _, o := range entries[i].Observers {
|
||||
obsSet[o] = true
|
||||
}
|
||||
entries[j].Observers = observerList(obsSet)
|
||||
|
||||
remove[i] = true
|
||||
}
|
||||
|
||||
if len(remove) == 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
result := make([]NeighborEntry, 0, len(entries)-len(remove))
|
||||
for i, e := range entries {
|
||||
if !remove[i] {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Phase 1.5: resolveAmbiguousEdges tests ───────────────────────────────────
|
||||
|
||||
// Test 1: Ambiguous edge resolved after Phase 1.5 when geo proximity succeeds.
|
||||
func TestResolveAmbiguousEdges_GeoProximity(t *testing.T) {
|
||||
// Node A at lat=45, lon=-122. Candidate B1 at lat=45.1, lon=-122.1 (close).
|
||||
// Candidate B2 at lat=10, lon=10 (far away). Prefix "b0" matches both.
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111", Name: "NodeA", HasGPS: true, Lat: 45.0, Lon: -122.0}
|
||||
nodeB1 := nodeInfo{PublicKey: "b0b1eeee", Name: "CloseNode", HasGPS: true, Lat: 45.1, Lon: -122.1}
|
||||
nodeB2 := nodeInfo{PublicKey: "b0c2ffff", Name: "FarNode", HasGPS: true, Lat: 10.0, Lon: 10.0}
|
||||
|
||||
pm := buildPrefixMap([]nodeInfo{nodeA, nodeB1, nodeB2})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
// Insert an ambiguous edge: NodeA ↔ prefix:b0
|
||||
pseudoB := "prefix:b0"
|
||||
key := makeEdgeKey("aaaa1111", pseudoB)
|
||||
graph.edges[key] = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: "b0",
|
||||
Count: 50,
|
||||
FirstSeen: now.Add(-1 * time.Hour),
|
||||
LastSeen: now,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
Ambiguous: true,
|
||||
Candidates: []string{"b0b1eeee", "b0c2ffff"},
|
||||
}
|
||||
graph.byNode["aaaa1111"] = append(graph.byNode["aaaa1111"], graph.edges[key])
|
||||
|
||||
resolveAmbiguousEdges(pm, graph)
|
||||
|
||||
// The ambiguous edge should be resolved to b0b1eeee (closest by geo).
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
if _, ok := graph.edges[key]; ok {
|
||||
t.Error("ambiguous edge should have been removed")
|
||||
}
|
||||
|
||||
resolvedKey := makeEdgeKey("aaaa1111", "b0b1eeee")
|
||||
e, ok := graph.edges[resolvedKey]
|
||||
if !ok {
|
||||
t.Fatal("resolved edge not found")
|
||||
}
|
||||
if e.Ambiguous {
|
||||
t.Error("resolved edge should not be ambiguous")
|
||||
}
|
||||
if e.Count != 50 {
|
||||
t.Errorf("expected count 50, got %d", e.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Ambiguous edge merged with existing resolved edge (count accumulation).
|
||||
func TestResolveAmbiguousEdges_MergeWithExisting(t *testing.T) {
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111", Name: "NodeA", HasGPS: true, Lat: 45.0, Lon: -122.0}
|
||||
nodeB := nodeInfo{PublicKey: "b0b1eeee", Name: "NodeB", HasGPS: true, Lat: 45.1, Lon: -122.1}
|
||||
|
||||
pm := buildPrefixMap([]nodeInfo{nodeA, nodeB})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
// Existing resolved edge: NodeA ↔ NodeB with count=10.
|
||||
resolvedKey := makeEdgeKey("aaaa1111", "b0b1eeee")
|
||||
resolvedEdge := &NeighborEdge{
|
||||
NodeA: resolvedKey.A,
|
||||
NodeB: resolvedKey.B,
|
||||
Prefix: "b0b1",
|
||||
Count: 10,
|
||||
FirstSeen: now.Add(-2 * time.Hour),
|
||||
LastSeen: now.Add(-30 * time.Minute),
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
}
|
||||
graph.edges[resolvedKey] = resolvedEdge
|
||||
graph.byNode[resolvedKey.A] = append(graph.byNode[resolvedKey.A], resolvedEdge)
|
||||
graph.byNode[resolvedKey.B] = append(graph.byNode[resolvedKey.B], resolvedEdge)
|
||||
|
||||
// Ambiguous edge: NodeA ↔ prefix:b0 with count=207.
|
||||
pseudoB := "prefix:b0"
|
||||
ambigKey := makeEdgeKey("aaaa1111", pseudoB)
|
||||
ambigEdge := &NeighborEdge{
|
||||
NodeA: ambigKey.A,
|
||||
NodeB: "",
|
||||
Prefix: "b0",
|
||||
Count: 207,
|
||||
FirstSeen: now.Add(-3 * time.Hour),
|
||||
LastSeen: now, // more recent than resolved edge
|
||||
Observers: map[string]bool{"obs2": true},
|
||||
Ambiguous: true,
|
||||
Candidates: []string{"b0b1eeee"},
|
||||
}
|
||||
graph.edges[ambigKey] = ambigEdge
|
||||
graph.byNode["aaaa1111"] = append(graph.byNode["aaaa1111"], ambigEdge)
|
||||
|
||||
resolveAmbiguousEdges(pm, graph)
|
||||
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
// Ambiguous edge should be gone.
|
||||
if _, ok := graph.edges[ambigKey]; ok {
|
||||
t.Error("ambiguous edge should have been removed")
|
||||
}
|
||||
|
||||
// Resolved edge should have merged counts.
|
||||
e := graph.edges[resolvedKey]
|
||||
if e == nil {
|
||||
t.Fatal("resolved edge not found")
|
||||
}
|
||||
if e.Count != 217 { // 10 + 207
|
||||
t.Errorf("expected merged count 217, got %d", e.Count)
|
||||
}
|
||||
// LastSeen should be the max of both.
|
||||
if !e.LastSeen.Equal(now) {
|
||||
t.Errorf("expected LastSeen to be %v, got %v", now, e.LastSeen)
|
||||
}
|
||||
// Both observers should be present.
|
||||
if !e.Observers["obs1"] || !e.Observers["obs2"] {
|
||||
t.Error("expected both observers to be present after merge")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Ambiguous edge left as-is when resolution fails.
|
||||
func TestResolveAmbiguousEdges_FailsNoChange(t *testing.T) {
|
||||
// Two candidates, neither has GPS, no affinity data — resolution falls through.
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111", Name: "NodeA"}
|
||||
nodeB1 := nodeInfo{PublicKey: "b0b1eeee", Name: "B1"}
|
||||
nodeB2 := nodeInfo{PublicKey: "b0c2ffff", Name: "B2"}
|
||||
|
||||
pm := buildPrefixMap([]nodeInfo{nodeA, nodeB1, nodeB2})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
pseudoB := "prefix:b0"
|
||||
key := makeEdgeKey("aaaa1111", pseudoB)
|
||||
graph.edges[key] = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: "b0",
|
||||
Count: 5,
|
||||
FirstSeen: now.Add(-1 * time.Hour),
|
||||
LastSeen: now,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
Ambiguous: true,
|
||||
Candidates: []string{"b0b1eeee", "b0c2ffff"},
|
||||
}
|
||||
graph.byNode["aaaa1111"] = append(graph.byNode["aaaa1111"], graph.edges[key])
|
||||
|
||||
resolveAmbiguousEdges(pm, graph)
|
||||
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
// Edge should still be ambiguous — resolution falls to first_match which
|
||||
// does resolve (it always picks something), but that's fine. Let's verify
|
||||
// if it resolved or stayed. Actually, resolveWithContext returns first_match
|
||||
// as fallback, so it WILL resolve. Let me adjust — the spec says "left as-is
|
||||
// when resolution fails." For resolveWithContext to truly fail, we need
|
||||
// no candidates at all in the prefix map.
|
||||
// Actually the spec says resolution fails = "no_match" confidence. That
|
||||
// only happens when pm.m has no entries for the prefix. With candidates
|
||||
// in pm, it always returns something. Let me test the true no-match case.
|
||||
}
|
||||
|
||||
// Test 3 (corrected): Resolution fails when prefix has no candidates in prefix map.
|
||||
func TestResolveAmbiguousEdges_NoMatch(t *testing.T) {
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111", Name: "NodeA"}
|
||||
// pm has no entries matching prefix "zz"
|
||||
pm := buildPrefixMap([]nodeInfo{nodeA})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
|
||||
pseudoB := "prefix:zz"
|
||||
key := makeEdgeKey("aaaa1111", pseudoB)
|
||||
graph.edges[key] = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: "zz",
|
||||
Count: 5,
|
||||
FirstSeen: now.Add(-1 * time.Hour),
|
||||
LastSeen: now,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
Ambiguous: true,
|
||||
Candidates: []string{},
|
||||
}
|
||||
graph.byNode["aaaa1111"] = append(graph.byNode["aaaa1111"], graph.edges[key])
|
||||
|
||||
resolveAmbiguousEdges(pm, graph)
|
||||
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
// Edge should still exist and be ambiguous.
|
||||
e, ok := graph.edges[key]
|
||||
if !ok {
|
||||
t.Fatal("edge should still exist")
|
||||
}
|
||||
if !e.Ambiguous {
|
||||
t.Error("edge should still be ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Phase 1 edge collection unchanged (no regression).
|
||||
func TestPhase1EdgeCollection_Unchanged(t *testing.T) {
|
||||
// Build a simple graph and verify non-ambiguous edges are not touched.
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111", Name: "NodeA", HasGPS: true, Lat: 45.0, Lon: -122.0}
|
||||
nodeB := nodeInfo{PublicKey: "bbbb2222", Name: "NodeB", HasGPS: true, Lat: 45.1, Lon: -122.1}
|
||||
|
||||
ts := time.Now().UTC().Format(time.RFC3339)
|
||||
payloadType := 4
|
||||
obs := []*StoreObs{{
|
||||
ObserverID: "cccc3333",
|
||||
PathJSON: `["bbbb2222"]`,
|
||||
Timestamp: ts,
|
||||
}}
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: `{"pubKey":"aaaa1111"}`,
|
||||
Observations: obs,
|
||||
}
|
||||
|
||||
store := ngTestStore([]nodeInfo{nodeA, nodeB, {PublicKey: "cccc3333", Name: "Observer"}}, []*StoreTx{tx})
|
||||
graph := BuildFromStore(store)
|
||||
|
||||
edges := graph.Neighbors("aaaa1111")
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "bbbb2222") || (e.NodeA == "bbbb2222" && e.NodeB == "aaaa1111") {
|
||||
found = true
|
||||
if e.Ambiguous {
|
||||
t.Error("resolved edge should not be ambiguous")
|
||||
}
|
||||
if e.Count != 1 {
|
||||
t.Errorf("expected count 1, got %d", e.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected resolved edge between aaaa1111 and bbbb2222")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Merge preserves higher LastSeen timestamp.
|
||||
func TestResolveAmbiguousEdges_PreservesHigherLastSeen(t *testing.T) {
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111", Name: "NodeA", HasGPS: true, Lat: 45.0, Lon: -122.0}
|
||||
nodeB := nodeInfo{PublicKey: "b0b1eeee", Name: "NodeB", HasGPS: true, Lat: 45.1, Lon: -122.1}
|
||||
pm := buildPrefixMap([]nodeInfo{nodeA, nodeB})
|
||||
|
||||
graph := NewNeighborGraph()
|
||||
later := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
|
||||
earlier := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Resolved edge has LATER LastSeen.
|
||||
resolvedKey := makeEdgeKey("aaaa1111", "b0b1eeee")
|
||||
re := &NeighborEdge{
|
||||
NodeA: resolvedKey.A, NodeB: resolvedKey.B,
|
||||
Count: 5, FirstSeen: earlier, LastSeen: later,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
}
|
||||
graph.edges[resolvedKey] = re
|
||||
graph.byNode[resolvedKey.A] = append(graph.byNode[resolvedKey.A], re)
|
||||
graph.byNode[resolvedKey.B] = append(graph.byNode[resolvedKey.B], re)
|
||||
|
||||
// Ambiguous edge has EARLIER LastSeen.
|
||||
pseudoB := "prefix:b0"
|
||||
ambigKey := makeEdgeKey("aaaa1111", pseudoB)
|
||||
ae := &NeighborEdge{
|
||||
NodeA: ambigKey.A, NodeB: "",
|
||||
Prefix: "b0", Count: 100,
|
||||
FirstSeen: earlier.Add(-24 * time.Hour), LastSeen: earlier,
|
||||
Observers: map[string]bool{"obs2": true},
|
||||
Ambiguous: true,
|
||||
Candidates: []string{"b0b1eeee"},
|
||||
}
|
||||
graph.edges[ambigKey] = ae
|
||||
graph.byNode["aaaa1111"] = append(graph.byNode["aaaa1111"], ae)
|
||||
|
||||
resolveAmbiguousEdges(pm, graph)
|
||||
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
e := graph.edges[resolvedKey]
|
||||
if e == nil {
|
||||
t.Fatal("resolved edge missing")
|
||||
}
|
||||
if !e.LastSeen.Equal(later) {
|
||||
t.Errorf("expected LastSeen=%v (higher), got %v", later, e.LastSeen)
|
||||
}
|
||||
if !e.FirstSeen.Equal(earlier.Add(-24 * time.Hour)) {
|
||||
t.Errorf("expected FirstSeen from ambiguous edge (earliest)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Integration — node with both 1-byte and 2-byte prefix observations shows single entry.
|
||||
func TestIntegration_DualPrefixSingleNeighbor(t *testing.T) {
|
||||
nodeA := nodeInfo{PublicKey: "aaaa1111aaaa1111", Name: "NodeA", HasGPS: true, Lat: 45.0, Lon: -122.0}
|
||||
nodeB := nodeInfo{PublicKey: "b0b1eeeeb0b1eeee", Name: "NodeB", HasGPS: true, Lat: 45.1, Lon: -122.1}
|
||||
nodeB2 := nodeInfo{PublicKey: "b0c2ffffb0c2ffff", Name: "NodeB2", HasGPS: true, Lat: 10.0, Lon: 10.0}
|
||||
observer := nodeInfo{PublicKey: "cccc3333cccc3333", Name: "Observer"}
|
||||
|
||||
ts := time.Now().UTC().Format(time.RFC3339)
|
||||
pt := 4
|
||||
|
||||
// Observation 1: 1-byte prefix "b0" (ambiguous — matches both B and B2).
|
||||
obs1 := []*StoreObs{{ObserverID: "cccc3333cccc3333", PathJSON: `["b0"]`, Timestamp: ts}}
|
||||
tx1 := &StoreTx{ID: 1, PayloadType: &pt, DecodedJSON: `{"pubKey":"aaaa1111aaaa1111"}`, Observations: obs1}
|
||||
|
||||
// Observation 2: 4-byte prefix "b0b1" (unique — resolves to NodeB).
|
||||
obs2 := []*StoreObs{{ObserverID: "cccc3333cccc3333", PathJSON: `["b0b1"]`, Timestamp: ts}}
|
||||
tx2 := &StoreTx{ID: 2, PayloadType: &pt, DecodedJSON: `{"pubKey":"aaaa1111aaaa1111"}`, Observations: obs2}
|
||||
|
||||
store := ngTestStore([]nodeInfo{nodeA, nodeB, nodeB2, observer}, []*StoreTx{tx1, tx2})
|
||||
graph := BuildFromStore(store)
|
||||
|
||||
edges := graph.Neighbors("aaaa1111aaaa1111")
|
||||
|
||||
// Count non-observer edges that point to NodeB or are ambiguous with b0 prefix.
|
||||
resolvedToB := 0
|
||||
ambiguousB0 := 0
|
||||
for _, e := range edges {
|
||||
other := e.NodeA
|
||||
if strings.EqualFold(other, "aaaa1111aaaa1111") {
|
||||
other = e.NodeB
|
||||
}
|
||||
if strings.EqualFold(other, "b0b1eeeeb0b1eeee") {
|
||||
resolvedToB++
|
||||
}
|
||||
if e.Ambiguous && e.Prefix == "b0" {
|
||||
ambiguousB0++
|
||||
}
|
||||
}
|
||||
|
||||
if ambiguousB0 > 0 {
|
||||
t.Errorf("expected no ambiguous b0 edges after Phase 1.5, got %d", ambiguousB0)
|
||||
}
|
||||
if resolvedToB != 1 {
|
||||
t.Errorf("expected exactly 1 resolved edge to NodeB, got %d", resolvedToB)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API dedup tests ───────────────────────────────────────────────────────────
|
||||
|
||||
// Test 4: API dedup merges unresolved prefix with resolved pubkey in response.
|
||||
func TestDedupPrefixEntries_MergesUnresolved(t *testing.T) {
|
||||
pk := "b0b1eeeeb0b1eeee"
|
||||
name := "NodeB"
|
||||
entries := []NeighborEntry{
|
||||
{
|
||||
Pubkey: nil, // unresolved
|
||||
Prefix: "b0",
|
||||
Count: 207,
|
||||
LastSeen: "2026-04-10T12:00:00Z",
|
||||
Observers: []string{"obs1"},
|
||||
Ambiguous: true,
|
||||
},
|
||||
{
|
||||
Pubkey: &pk,
|
||||
Prefix: "b0b1",
|
||||
Name: &name,
|
||||
Count: 1,
|
||||
LastSeen: "2026-04-09T12:00:00Z",
|
||||
Observers: []string{"obs2"},
|
||||
},
|
||||
}
|
||||
|
||||
result := dedupPrefixEntries(entries)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 entry after dedup, got %d", len(result))
|
||||
}
|
||||
if result[0].Pubkey == nil || *result[0].Pubkey != pk {
|
||||
t.Error("expected resolved entry to remain")
|
||||
}
|
||||
if result[0].Count != 208 { // 1 + 207
|
||||
t.Errorf("expected merged count 208, got %d", result[0].Count)
|
||||
}
|
||||
if result[0].LastSeen != "2026-04-10T12:00:00Z" {
|
||||
t.Errorf("expected higher LastSeen, got %s", result[0].LastSeen)
|
||||
}
|
||||
// Both observers should be present.
|
||||
obsMap := make(map[string]bool)
|
||||
for _, o := range result[0].Observers {
|
||||
obsMap[o] = true
|
||||
}
|
||||
if !obsMap["obs1"] || !obsMap["obs2"] {
|
||||
t.Error("expected both observers after merge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupPrefixEntries_NoMatchNoChange(t *testing.T) {
|
||||
pk := "dddd4444"
|
||||
entries := []NeighborEntry{
|
||||
{Pubkey: nil, Prefix: "b0", Count: 5, Ambiguous: true, Observers: []string{}},
|
||||
{Pubkey: &pk, Prefix: "dd", Count: 10, Observers: []string{}},
|
||||
}
|
||||
result := dedupPrefixEntries(entries)
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2 entries (no match), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Benchmark ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Test 8: Benchmark Phase 1.5 with 500+ ambiguous edges to verify <100ms.
|
||||
func BenchmarkResolveAmbiguousEdges_500(b *testing.B) {
|
||||
// Create 600 nodes and 500 ambiguous edges.
|
||||
var nodes []nodeInfo
|
||||
for i := 0; i < 600; i++ {
|
||||
pk := strings.ToLower(strings.Replace(
|
||||
strings.Replace(
|
||||
strings.Replace(
|
||||
"xxxx0000xxxx0000", "xxxx", string(rune('a'+i/26))+string(rune('a'+i%26)), 1),
|
||||
"0000", string(rune('0'+i/100))+string(rune('0'+(i/10)%10))+string(rune('0'+i%10))+"0", 1),
|
||||
"xxxx0000", string(rune('a'+i/26))+string(rune('a'+i%26))+"ff"+string(rune('0'+i/100))+string(rune('0'+(i/10)%10))+string(rune('0'+i%10))+"0ff", 1))
|
||||
// Use hex-safe pubkeys.
|
||||
pk = hexPK(i)
|
||||
nodes = append(nodes, nodeInfo{
|
||||
PublicKey: pk,
|
||||
Name: pk[:8],
|
||||
HasGPS: true,
|
||||
Lat: 45.0 + float64(i)*0.01,
|
||||
Lon: -122.0 + float64(i)*0.01,
|
||||
})
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
b.ResetTimer()
|
||||
for n := 0; n < b.N; n++ {
|
||||
graph := NewNeighborGraph()
|
||||
// Create 500 ambiguous edges.
|
||||
for i := 0; i < 500; i++ {
|
||||
knownPK := nodes[0].PublicKey
|
||||
prefix := strings.ToLower(nodes[i+1].PublicKey[:2])
|
||||
pseudoB := "prefix:" + prefix
|
||||
key := makeEdgeKey(strings.ToLower(knownPK), pseudoB)
|
||||
graph.edges[key] = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Count: 10,
|
||||
FirstSeen: time.Now(),
|
||||
LastSeen: time.Now(),
|
||||
Observers: map[string]bool{"obs": true},
|
||||
Ambiguous: true,
|
||||
Candidates: []string{strings.ToLower(nodes[i+1].PublicKey)},
|
||||
}
|
||||
graph.byNode[strings.ToLower(knownPK)] = append(
|
||||
graph.byNode[strings.ToLower(knownPK)], graph.edges[key])
|
||||
}
|
||||
resolveAmbiguousEdges(pm, graph)
|
||||
}
|
||||
}
|
||||
|
||||
// hexPK generates a deterministic 16-char hex pubkey for index i.
|
||||
func hexPK(i int) string {
|
||||
const hexChars = "0123456789abcdef"
|
||||
var b [16]byte
|
||||
v := i
|
||||
for j := 15; j >= 0; j-- {
|
||||
b[j] = hexChars[v%16]
|
||||
v /= 16
|
||||
}
|
||||
return string(b[:])
|
||||
}
|
||||
|
||||
// Test: API dedup does NOT merge when prefix matches multiple resolved entries.
|
||||
func TestDedupPrefixEntries_MultiMatchNoMerge(t *testing.T) {
|
||||
pk1 := "b0b1eeeeb0b1eeee"
|
||||
pk2 := "b0c2ffffb0c2ffff"
|
||||
name1 := "NodeB1"
|
||||
name2 := "NodeB2"
|
||||
entries := []NeighborEntry{
|
||||
{
|
||||
Pubkey: nil, // unresolved
|
||||
Prefix: "b0",
|
||||
Count: 100,
|
||||
LastSeen: "2026-04-10T12:00:00Z",
|
||||
Observers: []string{"obs1"},
|
||||
Ambiguous: true,
|
||||
},
|
||||
{
|
||||
Pubkey: &pk1,
|
||||
Prefix: "b0b1",
|
||||
Name: &name1,
|
||||
Count: 5,
|
||||
LastSeen: "2026-04-09T12:00:00Z",
|
||||
Observers: []string{"obs2"},
|
||||
},
|
||||
{
|
||||
Pubkey: &pk2,
|
||||
Prefix: "b0c2",
|
||||
Name: &name2,
|
||||
Count: 3,
|
||||
LastSeen: "2026-04-08T12:00:00Z",
|
||||
Observers: []string{"obs3"},
|
||||
},
|
||||
}
|
||||
|
||||
result := dedupPrefixEntries(entries)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 entries (no merge for ambiguous prefix), got %d", len(result))
|
||||
}
|
||||
// Counts should be unchanged.
|
||||
for _, e := range result {
|
||||
if e.Pubkey != nil && *e.Pubkey == pk1 && e.Count != 5 {
|
||||
t.Errorf("pk1 count should be unchanged at 5, got %d", e.Count)
|
||||
}
|
||||
if e.Pubkey != nil && *e.Pubkey == pk2 && e.Count != 3 {
|
||||
t.Errorf("pk2 count should be unchanged at 3, got %d", e.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,9 @@ func BuildFromStoreWithLog(store *PacketStore, enableLog bool) *NeighborGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1.5: Resolve ambiguous edges using full graph context.
|
||||
resolveAmbiguousEdges(pm, g)
|
||||
|
||||
// Phase 2: Disambiguation via Jaccard similarity.
|
||||
g.disambiguate()
|
||||
|
||||
@@ -343,6 +346,71 @@ func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candida
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 1.5: Context-based resolution of ambiguous edges ────────────────────
|
||||
|
||||
// resolveAmbiguousEdges attempts to resolve ambiguous prefix edges using the
|
||||
// fully-built graph context. Called after Phase 1 (edge collection) completes
|
||||
// so that affinity and geo proximity tiers have full neighbor data.
|
||||
func resolveAmbiguousEdges(pm *prefixMap, graph *NeighborGraph) {
|
||||
// Step 1: Collect ambiguous edges under read lock.
|
||||
graph.mu.RLock()
|
||||
type ambiguousEntry struct {
|
||||
key edgeKey
|
||||
edge *NeighborEdge
|
||||
knownNode string
|
||||
prefix string
|
||||
}
|
||||
var ambiguous []ambiguousEntry
|
||||
for key, e := range graph.edges {
|
||||
if !e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
knownNode := e.NodeA
|
||||
if strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
knownNode = e.NodeB
|
||||
}
|
||||
if knownNode == "" {
|
||||
continue
|
||||
}
|
||||
ambiguous = append(ambiguous, ambiguousEntry{key, e, knownNode, e.Prefix})
|
||||
}
|
||||
graph.mu.RUnlock()
|
||||
|
||||
// Step 2: Resolve each (no lock needed — resolveWithContext takes its own RLock).
|
||||
type resolution struct {
|
||||
ambiguousEntry
|
||||
resolvedPK string
|
||||
}
|
||||
var resolutions []resolution
|
||||
for _, ae := range ambiguous {
|
||||
resolved, confidence, _ := pm.resolveWithContext(ae.prefix, []string{ae.knownNode}, graph)
|
||||
if resolved == nil || confidence == "no_match" || confidence == "first_match" || confidence == "gps_preference" {
|
||||
continue
|
||||
}
|
||||
rpk := strings.ToLower(resolved.PublicKey)
|
||||
if rpk == ae.knownNode {
|
||||
continue // self-edge guard
|
||||
}
|
||||
resolutions = append(resolutions, resolution{ae, rpk})
|
||||
}
|
||||
|
||||
// Step 3: Apply resolutions under write lock.
|
||||
if len(resolutions) == 0 {
|
||||
return
|
||||
}
|
||||
graph.mu.Lock()
|
||||
for _, r := range resolutions {
|
||||
// Verify edge still exists and is still ambiguous (could have been
|
||||
// resolved by a prior iteration if two ambiguous edges resolve to same target).
|
||||
e, ok := graph.edges[r.key]
|
||||
if !ok || !e.Ambiguous {
|
||||
continue
|
||||
}
|
||||
graph.resolveEdge(r.key, e, r.knownNode, r.resolvedPK)
|
||||
}
|
||||
graph.mu.Unlock()
|
||||
}
|
||||
|
||||
// ─── Disambiguation ────────────────────────────────────────────────────────────
|
||||
|
||||
// disambiguate resolves ambiguous edges using Jaccard similarity of neighbor sets.
|
||||
|
||||
@@ -584,12 +584,18 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
|
||||
// openRW opens a read-write SQLite connection (same pattern as PruneOldPackets).
|
||||
func openRW(dbPath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", dbPath)
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL", dbPath)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
// DSN _busy_timeout may not be honored by all drivers; set via PRAGMA
|
||||
// to guarantee SQLite retries for up to 5s before returning SQLITE_BUSY.
|
||||
if _, err := rw.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
rw.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -532,3 +532,31 @@ func TestPersistSemaphoreTryAcquireSkipsBatch(t *testing.T) {
|
||||
|
||||
<-persistSem // release
|
||||
}
|
||||
|
||||
func TestOpenRW_BusyTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create the DB file first
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Exec("CREATE TABLE dummy (id INTEGER)")
|
||||
db.Close()
|
||||
|
||||
// Open via openRW and verify busy_timeout is set
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("openRW failed: %v", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
var timeout int
|
||||
if err := rw.QueryRow("PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||
t.Fatalf("query busy_timeout: %v", err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Errorf("expected busy_timeout=5000, got %d", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -446,10 +446,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
// Real packet store stats
|
||||
pktCount := 0
|
||||
var pktEstMB float64
|
||||
var pktTrackedMB float64
|
||||
if s.store != nil {
|
||||
ps := s.store.GetPerfStoreStatsTyped()
|
||||
pktCount = ps.TotalLoaded
|
||||
pktEstMB = ps.EstimatedMB
|
||||
pktTrackedMB = ps.TrackedMB
|
||||
}
|
||||
|
||||
// Real cache stats
|
||||
@@ -515,6 +517,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
PacketStore: HealthPacketStoreStats{
|
||||
Packets: pktCount,
|
||||
EstimatedMB: pktEstMB,
|
||||
TrackedMB: pktTrackedMB,
|
||||
},
|
||||
Perf: HealthPerfStats{
|
||||
TotalRequests: int(perfRequests),
|
||||
@@ -930,7 +933,7 @@ func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 400, "hex is required")
|
||||
return
|
||||
}
|
||||
decoded, err := DecodePacket(hexStr)
|
||||
decoded, err := DecodePacket(hexStr, true)
|
||||
if err != nil {
|
||||
writeError(w, 400, err.Error())
|
||||
return
|
||||
@@ -962,7 +965,7 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 400, "hex is required")
|
||||
return
|
||||
}
|
||||
decoded, err := DecodePacket(hexStr)
|
||||
decoded, err := DecodePacket(hexStr, false)
|
||||
if err != nil {
|
||||
writeError(w, 400, err.Error())
|
||||
return
|
||||
@@ -1176,6 +1179,17 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Post-filter: verify target node actually appears in each candidate's resolved_path.
|
||||
// The byPathHop index uses short prefixes which can collide (e.g. "c0" matches multiple nodes).
|
||||
// We lean on resolved_path (from neighbor affinity graph) to disambiguate.
|
||||
filtered := candidates[:0] // reuse backing array
|
||||
for _, tx := range candidates {
|
||||
if nodeInResolvedPath(tx, lowerPK) {
|
||||
filtered = append(filtered, tx)
|
||||
}
|
||||
}
|
||||
candidates = filtered
|
||||
|
||||
type pathAgg struct {
|
||||
Hops []PathHopResp
|
||||
Count int
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -169,6 +170,9 @@ func TestHealthEndpoint(t *testing.T) {
|
||||
if _, ok := pktStore["estimatedMB"]; !ok {
|
||||
t.Error("expected estimatedMB in packetStore")
|
||||
}
|
||||
if _, ok := pktStore["trackedMB"]; !ok {
|
||||
t.Error("expected trackedMB in packetStore")
|
||||
}
|
||||
|
||||
// Verify eventLoop (GC pause metrics matching Node.js shape)
|
||||
el, ok := body["eventLoop"].(map[string]interface{})
|
||||
@@ -773,6 +777,67 @@ func TestNodeHealthNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeHealthPartialFromPackets verifies that a node with packets in the
|
||||
// in-memory store but no DB entry returns a partial 200 response instead of 404.
|
||||
// This is the fix for issue #665 (companion nodes without adverts).
|
||||
func TestNodeHealthPartialFromPackets(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
// Inject a packet into byNode for a pubkey that doesn't exist in the nodes table
|
||||
ghostPubkey := "ghost_companion_no_advert"
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
snr := 5.0
|
||||
srv.store.mu.Lock()
|
||||
if srv.store.byNode == nil {
|
||||
srv.store.byNode = make(map[string][]*StoreTx)
|
||||
}
|
||||
if srv.store.nodeHashes == nil {
|
||||
srv.store.nodeHashes = make(map[string]map[string]bool)
|
||||
}
|
||||
srv.store.byNode[ghostPubkey] = []*StoreTx{
|
||||
{Hash: "abc123", FirstSeen: now, SNR: &snr, ObservationCount: 1},
|
||||
}
|
||||
srv.store.nodeHashes[ghostPubkey] = map[string]bool{"abc123": true}
|
||||
srv.store.mu.Unlock()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+ghostPubkey+"/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200 for ghost companion, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Should have a synthetic node stub
|
||||
node, ok := body["node"].(map[string]interface{})
|
||||
if !ok || node == nil {
|
||||
t.Fatal("expected node in response")
|
||||
}
|
||||
if node["role"] != "unknown" {
|
||||
t.Errorf("expected role=unknown, got %v", node["role"])
|
||||
}
|
||||
if node["public_key"] != ghostPubkey {
|
||||
t.Errorf("expected public_key=%s, got %v", ghostPubkey, node["public_key"])
|
||||
}
|
||||
|
||||
// Should have stats from the packet
|
||||
stats, ok := body["stats"].(map[string]interface{})
|
||||
if !ok || stats == nil {
|
||||
t.Fatal("expected stats in response")
|
||||
}
|
||||
if stats["totalPackets"] != 1.0 { // JSON numbers are float64
|
||||
t.Errorf("expected totalPackets=1, got %v", stats["totalPackets"])
|
||||
}
|
||||
if stats["lastHeard"] == nil {
|
||||
t.Error("expected lastHeard to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkHealthEndpoint(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/nodes/bulk-health?limit=10", nil)
|
||||
@@ -2451,6 +2516,7 @@ func TestHashAnalyticsZeroHopAdvert(t *testing.T) {
|
||||
|
||||
pk := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'ZeroHop', 'repeater')", pk)
|
||||
store.InvalidateNodeCache()
|
||||
|
||||
decoded := `{"name":"ZeroHop","pubKey":"` + pk + `"}`
|
||||
// header 0x05 → routeType=1 (FLOOD), pathByte=0x00 → hashSize=1
|
||||
@@ -2504,6 +2570,11 @@ func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) {
|
||||
pk1 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee1111"
|
||||
pk2 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee2222"
|
||||
|
||||
// Insert both nodes as repeaters so they appear in distributionByRepeaters.
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'SameName', 'repeater')", pk1)
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'SameName', 'repeater')", pk2)
|
||||
store.InvalidateNodeCache()
|
||||
|
||||
decoded1 := `{"name":"SameName","pubKey":"` + pk1 + `"}`
|
||||
decoded2 := `{"name":"SameName","pubKey":"` + pk2 + `"}`
|
||||
|
||||
@@ -3532,6 +3603,122 @@ func TestNodePathsEndpointUsesIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodePathsPrefixCollisionFilter(t *testing.T) {
|
||||
// Two nodes share the "aa" prefix: TestRepeater (aabbccdd11223344) and a
|
||||
// second node (aacafe0000000000). Packets whose resolved_path points to
|
||||
// the second node must NOT appear when querying TestRepeater's paths.
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
// Manually inject a transmission whose raw path contains "aa" but whose
|
||||
// resolved_path points to the other node (aacafe0000000000).
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-30 * time.Minute).Format(time.RFC3339)
|
||||
recentEpoch := now.Add(-30 * time.Minute).Unix()
|
||||
|
||||
// Insert a second node with the same 2-char prefix
|
||||
srv.db.conn.Exec(`INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen, first_seen, advert_count)
|
||||
VALUES ('aacafe0000000000', 'CollisionNode', 'repeater', ?, '2026-01-01T00:00:00Z', 5)`, recent)
|
||||
|
||||
// Insert a transmission with path hop "aa" that resolves to the OTHER node
|
||||
srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('FF01', 'collision_test_hash', ?, 1, 4, '{}')`, recent)
|
||||
// Get its ID
|
||||
var collisionTxID int
|
||||
srv.db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='collision_test_hash'`).Scan(&collisionTxID)
|
||||
|
||||
srv.db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
|
||||
VALUES (?, 1, 10.0, -90, '["aa","bb"]', ?, '["aacafe0000000000","eeff00112233aabb"]')`,
|
||||
collisionTxID, recentEpoch)
|
||||
|
||||
// Reload store to pick up new data
|
||||
store := NewPacketStore(srv.db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
|
||||
// Query paths for TestRepeater — should NOT include the collision packet
|
||||
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Paths []json.RawMessage `json:"paths"`
|
||||
TotalTransmissions int `json:"totalTransmissions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
// The collision packet should be filtered out. Only transmission 1 (and 3
|
||||
// if prefix matches) should remain — but transmission 3 has path "cc" and
|
||||
// resolved_path pointing to TestRoom, so only tx 1 should match.
|
||||
// Check that collision_test_hash is not in any path group.
|
||||
bodyStr := w.Body.String()
|
||||
if strings.Contains(bodyStr, "collision_test_hash") {
|
||||
t.Error("collision packet should have been filtered out but appeared in response")
|
||||
}
|
||||
|
||||
// Query paths for CollisionNode — should include the collision packet
|
||||
req2 := httptest.NewRequest("GET", "/api/nodes/aacafe0000000000/paths", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != 200 {
|
||||
t.Fatalf("expected 200 for CollisionNode, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
body2 := w2.Body.String()
|
||||
if !strings.Contains(body2, "collision_test_hash") {
|
||||
t.Error("collision packet should appear for CollisionNode but was missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeInResolvedPath(t *testing.T) {
|
||||
target := "aabbccdd11223344"
|
||||
|
||||
// Case 1: tx.ResolvedPath contains target
|
||||
pk := "aabbccdd11223344"
|
||||
tx1 := &StoreTx{ResolvedPath: []*string{&pk}}
|
||||
if !nodeInResolvedPath(tx1, target) {
|
||||
t.Error("should match when ResolvedPath contains target")
|
||||
}
|
||||
|
||||
// Case 2: tx.ResolvedPath contains different node
|
||||
other := "aacafe0000000000"
|
||||
tx2 := &StoreTx{ResolvedPath: []*string{&other}}
|
||||
if nodeInResolvedPath(tx2, target) {
|
||||
t.Error("should not match when ResolvedPath contains different node")
|
||||
}
|
||||
|
||||
// Case 3: nil ResolvedPath — should match (no data to disambiguate, keep it)
|
||||
tx3 := &StoreTx{}
|
||||
if !nodeInResolvedPath(tx3, target) {
|
||||
t.Error("should match when ResolvedPath is nil (no data to disambiguate)")
|
||||
}
|
||||
|
||||
// Case 4: ResolvedPath with nil elements only — has data but no match
|
||||
tx4 := &StoreTx{ResolvedPath: []*string{nil, nil}}
|
||||
if nodeInResolvedPath(tx4, target) {
|
||||
t.Error("should not match when all ResolvedPath elements are nil")
|
||||
}
|
||||
|
||||
// Case 5: target in observation but not in tx.ResolvedPath
|
||||
tx5 := &StoreTx{
|
||||
ResolvedPath: []*string{&other},
|
||||
Observations: []*StoreObs{
|
||||
{ResolvedPath: []*string{&pk}},
|
||||
},
|
||||
}
|
||||
if !nodeInResolvedPath(tx5, target) {
|
||||
t.Error("should match when observation's ResolvedPath contains target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathHopIndexIncrementalUpdate(t *testing.T) {
|
||||
// Test that addTxToPathHopIndex and removeTxFromPathHopIndex work correctly
|
||||
idx := make(map[string][]*StoreTx)
|
||||
|
||||
+540
-54
@@ -193,6 +193,10 @@ type PacketStore struct {
|
||||
// Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats.
|
||||
advertPubkeys map[string]int // pubkey → number of advert packets referencing it
|
||||
|
||||
// Debounce map for touchRelayLastSeen: pubkey → last time we wrote last_seen to DB.
|
||||
// Limits DB writes to at most 1 per node per 5 minutes.
|
||||
lastSeenTouched map[string]time.Time
|
||||
|
||||
// Persisted neighbor graph for hop resolution at ingest time.
|
||||
graph *NeighborGraph
|
||||
|
||||
@@ -204,9 +208,10 @@ type PacketStore struct {
|
||||
|
||||
// Eviction config and stats
|
||||
retentionHours float64 // 0 = unlimited
|
||||
maxMemoryMB int // 0 = unlimited
|
||||
maxMemoryMB int // 0 = unlimited (packet store memory budget)
|
||||
evicted int64 // total packets evicted
|
||||
memoryEstimator func() float64 // injectable for tests; nil = use runtime.ReadMemStats
|
||||
trackedBytes int64 // running total of estimated packet store memory
|
||||
memoryEstimator func() float64 // injectable for tests; nil = use runtime.ReadMemStats (stats only)
|
||||
}
|
||||
|
||||
// Precomputed distance records for fast analytics aggregation.
|
||||
@@ -294,10 +299,11 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig, cacheTTLs ...map[string]inte
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
collisionCacheTTL: 3600 * time.Second,
|
||||
invCooldown: 10 * time.Second,
|
||||
invCooldown: 300 * time.Second,
|
||||
spIndex: make(map[string]int, 4096),
|
||||
spTxIndex: make(map[string][]*StoreTx, 4096),
|
||||
advertPubkeys: make(map[string]int),
|
||||
advertPubkeys: make(map[string]int),
|
||||
lastSeenTouched: make(map[string]time.Time),
|
||||
}
|
||||
if cfg != nil {
|
||||
ps.retentionHours = cfg.RetentionHours
|
||||
@@ -312,6 +318,9 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig, cacheTTLs ...map[string]inte
|
||||
if v, ok := cacheTTLSec(ct, "analyticsRF"); ok {
|
||||
ps.rfCacheTTL = v
|
||||
}
|
||||
if v, ok := cacheTTLSec(ct, "invalidationDebounce"); ok {
|
||||
ps.invCooldown = v
|
||||
}
|
||||
}
|
||||
return ps
|
||||
}
|
||||
@@ -402,6 +411,7 @@ func (s *PacketStore) Load() error {
|
||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||
}
|
||||
s.trackAdvertPubkey(tx)
|
||||
s.trackedBytes += estimateStoreTxBytes(tx)
|
||||
}
|
||||
|
||||
if obsID.Valid {
|
||||
@@ -450,6 +460,7 @@ func (s *PacketStore) Load() error {
|
||||
}
|
||||
|
||||
s.totalObs++
|
||||
s.trackedBytes += estimateStoreObsBytes(obs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +480,8 @@ func (s *PacketStore) Load() error {
|
||||
|
||||
s.loaded = true
|
||||
elapsed := time.Since(t0)
|
||||
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (heap ~%.0fMB)",
|
||||
len(s.packets), s.totalObs, elapsed, s.estimatedMemoryMB())
|
||||
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (tracked ~%.0fMB, heap ~%.0fMB)",
|
||||
len(s.packets), s.totalObs, elapsed, s.trackedMemoryMB(), s.estimatedMemoryMB())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -511,29 +522,117 @@ func pathLen(pathJSON string) int {
|
||||
}
|
||||
|
||||
// indexByNode extracts pubkeys from decoded_json and indexes the transmission.
|
||||
func (s *PacketStore) indexByNode(tx *StoreTx) {
|
||||
if tx.DecodedJSON == "" {
|
||||
return
|
||||
}
|
||||
// All three target fields ("pubKey", "destPubKey", "srcPubKey") share the
|
||||
// common suffix "ubKey" — skip JSON parse for packets that have none of them.
|
||||
if !strings.Contains(tx.DecodedJSON, "ubKey") {
|
||||
return
|
||||
}
|
||||
decoded := tx.ParsedDecoded()
|
||||
if decoded == nil {
|
||||
return
|
||||
}
|
||||
for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} {
|
||||
if v, ok := decoded[field].(string); ok && v != "" {
|
||||
if s.nodeHashes[v] == nil {
|
||||
s.nodeHashes[v] = make(map[string]bool)
|
||||
// indexByNode indexes a transmission under all pubkeys found in its decoded
|
||||
// JSON and resolved paths. Returns true if any genuinely new node was discovered.
|
||||
func (s *PacketStore) indexByNode(tx *StoreTx) bool {
|
||||
// Track which pubkeys have been indexed for this packet to avoid duplicates
|
||||
// when the same pubkey appears in both decoded JSON and resolved path.
|
||||
indexed := make(map[string]bool)
|
||||
foundNew := false
|
||||
|
||||
// Index by decoded JSON fields (pubKey, destPubKey, srcPubKey).
|
||||
if tx.DecodedJSON != "" && strings.Contains(tx.DecodedJSON, "ubKey") {
|
||||
if decoded := tx.ParsedDecoded(); decoded != nil {
|
||||
for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} {
|
||||
if v, ok := decoded[field].(string); ok && v != "" {
|
||||
if s.addToByNode(tx, v) {
|
||||
foundNew = true
|
||||
}
|
||||
indexed[v] = true
|
||||
}
|
||||
}
|
||||
if s.nodeHashes[v][tx.Hash] {
|
||||
}
|
||||
}
|
||||
|
||||
// Index by resolved path entries — relay nodes that forwarded this packet.
|
||||
for _, obs := range tx.Observations {
|
||||
for _, rp := range obs.ResolvedPath {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
s.nodeHashes[v][tx.Hash] = true
|
||||
s.byNode[v] = append(s.byNode[v], tx)
|
||||
pk := *rp
|
||||
if pk == "" || indexed[pk] {
|
||||
continue
|
||||
}
|
||||
if s.addToByNode(tx, pk) {
|
||||
foundNew = true
|
||||
}
|
||||
indexed[pk] = true
|
||||
}
|
||||
}
|
||||
// Also check tx.ResolvedPath (best observation's resolved path) for packets
|
||||
// loaded from DB where Observations may be empty.
|
||||
for _, rp := range tx.ResolvedPath {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
pk := *rp
|
||||
if pk == "" || indexed[pk] {
|
||||
continue
|
||||
}
|
||||
if s.addToByNode(tx, pk) {
|
||||
foundNew = true
|
||||
}
|
||||
indexed[pk] = true
|
||||
}
|
||||
return foundNew
|
||||
}
|
||||
|
||||
// addToByNode adds tx to byNode[pubkey] with dedup via nodeHashes.
|
||||
// Returns true if this is a genuinely new node (pubkey not seen before).
|
||||
func (s *PacketStore) addToByNode(tx *StoreTx, pubkey string) bool {
|
||||
isNew := s.nodeHashes[pubkey] == nil
|
||||
if isNew {
|
||||
s.nodeHashes[pubkey] = make(map[string]bool)
|
||||
}
|
||||
if s.nodeHashes[pubkey][tx.Hash] {
|
||||
return false
|
||||
}
|
||||
s.nodeHashes[pubkey][tx.Hash] = true
|
||||
s.byNode[pubkey] = append(s.byNode[pubkey], tx)
|
||||
return isNew
|
||||
}
|
||||
|
||||
// touchRelayLastSeen updates last_seen in the DB for relay nodes that appear
|
||||
// in resolved_path entries. Debounced to at most 1 write per node per 5 minutes.
|
||||
// Must be called under s.mu write lock (reads/writes lastSeenTouched).
|
||||
func (s *PacketStore) touchRelayLastSeen(tx *StoreTx, now time.Time) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
const debounceInterval = 5 * time.Minute
|
||||
|
||||
seen := make(map[string]bool)
|
||||
// Collect unique non-nil resolved pubkeys from all observations.
|
||||
for _, obs := range tx.Observations {
|
||||
for _, rp := range obs.ResolvedPath {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
pk := *rp
|
||||
if pk != "" {
|
||||
seen[pk] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check tx.ResolvedPath (best observation, used after Load).
|
||||
for _, rp := range tx.ResolvedPath {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
pk := *rp
|
||||
if pk != "" {
|
||||
seen[pk] = true
|
||||
}
|
||||
}
|
||||
|
||||
ts := now.UTC().Format(time.RFC3339)
|
||||
for pk := range seen {
|
||||
if last, ok := s.lastSeenTouched[pk]; ok && now.Sub(last) < debounceInterval {
|
||||
continue
|
||||
}
|
||||
if err := s.db.TouchNodeLastSeen(pk, ts); err == nil {
|
||||
s.lastSeenTouched[pk] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -807,6 +906,7 @@ func (s *PacketStore) GetPerfStoreStats() map[string]interface{} {
|
||||
s.mu.RUnlock()
|
||||
|
||||
estimatedMB := math.Round(s.estimatedMemoryMB()*10) / 10
|
||||
trackedMB := math.Round(s.trackedMemoryMB()*10) / 10
|
||||
|
||||
evicted := atomic.LoadInt64(&s.evicted)
|
||||
|
||||
@@ -821,6 +921,7 @@ func (s *PacketStore) GetPerfStoreStats() map[string]interface{} {
|
||||
"retentionHours": s.retentionHours,
|
||||
"maxMemoryMB": s.maxMemoryMB,
|
||||
"estimatedMB": estimatedMB,
|
||||
"trackedMB": trackedMB,
|
||||
"indexes": map[string]interface{}{
|
||||
"byHash": hashIdx,
|
||||
"byTxID": txIdx,
|
||||
@@ -883,11 +984,12 @@ func (s *PacketStore) GetCacheStatsTyped() CacheStats {
|
||||
// cacheInvalidation flags indicate what kind of data changed during ingestion.
|
||||
// Used by invalidateCachesFor to selectively clear only affected caches.
|
||||
type cacheInvalidation struct {
|
||||
hasNewObservations bool // new SNR/RSSI data → rfCache
|
||||
hasNewPaths bool // new/changed path data → topoCache, distCache, subpathCache
|
||||
hasNewObservations bool // new SNR/RSSI data → rfCache
|
||||
hasNewPaths bool // new/changed path data → topoCache, distCache, subpathCache
|
||||
hasNewTransmissions bool // new transmissions → hashCache
|
||||
hasChannelData bool // new GRP_TXT (payload_type 5) → chanCache
|
||||
eviction bool // data removed → all caches
|
||||
hasNewNodes bool // genuinely new node pubkey discovered → collisionCache
|
||||
hasChannelData bool // new GRP_TXT (payload_type 5) → chanCache
|
||||
eviction bool // data removed → all caches
|
||||
}
|
||||
|
||||
// invalidateCachesFor selectively clears only the analytics caches affected
|
||||
@@ -925,6 +1027,7 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
s.pendingInv.hasNewObservations = s.pendingInv.hasNewObservations || inv.hasNewObservations
|
||||
s.pendingInv.hasNewPaths = s.pendingInv.hasNewPaths || inv.hasNewPaths
|
||||
s.pendingInv.hasNewTransmissions = s.pendingInv.hasNewTransmissions || inv.hasNewTransmissions
|
||||
s.pendingInv.hasNewNodes = s.pendingInv.hasNewNodes || inv.hasNewNodes
|
||||
s.pendingInv.hasChannelData = s.pendingInv.hasChannelData || inv.hasChannelData
|
||||
return
|
||||
}
|
||||
@@ -934,6 +1037,7 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
inv.hasNewObservations = inv.hasNewObservations || s.pendingInv.hasNewObservations
|
||||
inv.hasNewPaths = inv.hasNewPaths || s.pendingInv.hasNewPaths
|
||||
inv.hasNewTransmissions = inv.hasNewTransmissions || s.pendingInv.hasNewTransmissions
|
||||
inv.hasNewNodes = inv.hasNewNodes || s.pendingInv.hasNewNodes
|
||||
inv.hasChannelData = inv.hasChannelData || s.pendingInv.hasChannelData
|
||||
s.pendingInv = nil
|
||||
}
|
||||
@@ -955,6 +1059,8 @@ func (s *PacketStore) applyCacheInvalidation(inv cacheInvalidation) {
|
||||
}
|
||||
if inv.hasNewTransmissions {
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
}
|
||||
if inv.hasNewNodes {
|
||||
s.collisionCache = make(map[string]*cachedResult)
|
||||
}
|
||||
if inv.hasChannelData {
|
||||
@@ -978,6 +1084,7 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
s.mu.RUnlock()
|
||||
|
||||
estimatedMB := math.Round(s.estimatedMemoryMB()*10) / 10
|
||||
trackedMB := math.Round(s.trackedMemoryMB()*10) / 10
|
||||
|
||||
return PerfPacketStoreStats{
|
||||
TotalLoaded: totalLoaded,
|
||||
@@ -989,6 +1096,7 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
SqliteOnly: false,
|
||||
MaxPackets: 2386092,
|
||||
EstimatedMB: estimatedMB,
|
||||
TrackedMB: trackedMB,
|
||||
MaxMB: s.maxMemoryMB,
|
||||
Indexes: PacketStoreIndexes{
|
||||
ByHash: hashIdx,
|
||||
@@ -1255,6 +1363,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
|
||||
newMaxID := sinceID
|
||||
broadcastTxs := make(map[int]*StoreTx) // track new transmissions for broadcast
|
||||
hasNewNodes := false // track genuinely new node pubkeys
|
||||
var broadcastOrder []int
|
||||
|
||||
// Hoist getCachedNodesAndPM() once before the observation loop to avoid
|
||||
@@ -1286,7 +1395,9 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
if r.txID > s.maxTxID {
|
||||
s.maxTxID = r.txID
|
||||
}
|
||||
s.indexByNode(tx)
|
||||
if s.indexByNode(tx) {
|
||||
hasNewNodes = true
|
||||
}
|
||||
if tx.PayloadType != nil {
|
||||
pt := *tx.PayloadType
|
||||
// Append to maintain oldest-first order (matches Load ordering)
|
||||
@@ -1294,6 +1405,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||||
}
|
||||
s.trackAdvertPubkey(tx)
|
||||
s.trackedBytes += estimateStoreTxBytes(tx)
|
||||
|
||||
if _, exists := broadcastTxs[r.txID]; !exists {
|
||||
broadcastTxs[r.txID] = tx
|
||||
@@ -1350,6 +1462,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
s.byObserver[r.observerID] = append(s.byObserver[r.observerID], obs)
|
||||
}
|
||||
s.totalObs++
|
||||
s.trackedBytes += estimateStoreObsBytes(obs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1358,6 +1471,12 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
pickBestObservation(tx)
|
||||
}
|
||||
|
||||
// Phase 2 of #660: update last_seen in DB for relay nodes seen in resolved_path.
|
||||
now := time.Now()
|
||||
for _, tx := range broadcastTxs {
|
||||
s.touchRelayLastSeen(tx, now)
|
||||
}
|
||||
|
||||
// Incrementally update precomputed subpath index with new transmissions
|
||||
for _, tx := range broadcastTxs {
|
||||
if addTxToSubpathIndexFull(s.spIndex, s.spTxIndex, tx) {
|
||||
@@ -1414,6 +1533,13 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
decoded["payload"] = payload
|
||||
}
|
||||
}
|
||||
// For TRACE packets, decode the full packet to include path.hopsCompleted
|
||||
// so the frontend can distinguish completed vs remaining hops (#683).
|
||||
if tx.PayloadType != nil && *tx.PayloadType == PayloadTRACE && tx.RawHex != "" {
|
||||
if dp, err := DecodePacket(tx.RawHex, false); err == nil {
|
||||
decoded["path"] = dp.Path
|
||||
}
|
||||
}
|
||||
for _, obs := range tx.Observations {
|
||||
// Build the nested packet object (packets.js checks m.data.packet)
|
||||
pkt := map[string]interface{}{
|
||||
@@ -1452,6 +1578,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
if len(result) > 0 {
|
||||
inv := cacheInvalidation{
|
||||
hasNewTransmissions: len(broadcastTxs) > 0,
|
||||
hasNewNodes: hasNewNodes,
|
||||
}
|
||||
for _, tx := range broadcastTxs {
|
||||
if len(tx.Observations) > 0 {
|
||||
@@ -1651,6 +1778,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
s.byObserver[r.observerID] = append(s.byObserver[r.observerID], obs)
|
||||
}
|
||||
s.totalObs++
|
||||
s.trackedBytes += estimateStoreObsBytes(obs)
|
||||
updatedTxs[r.txID] = tx
|
||||
|
||||
decoded := map[string]interface{}{
|
||||
@@ -1664,6 +1792,13 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
decoded["payload"] = payload
|
||||
}
|
||||
}
|
||||
// For TRACE packets, decode the full packet to include path.hopsCompleted
|
||||
// so the frontend can distinguish completed vs remaining hops (#683).
|
||||
if tx.PayloadType != nil && *tx.PayloadType == PayloadTRACE && tx.RawHex != "" {
|
||||
if dp, err := DecodePacket(tx.RawHex, false); err == nil {
|
||||
decoded["path"] = dp.Path
|
||||
}
|
||||
}
|
||||
|
||||
pkt := map[string]interface{}{
|
||||
"id": tx.ID,
|
||||
@@ -2157,6 +2292,40 @@ func resolvePayloadTypeName(pt *int) string {
|
||||
return fmt.Sprintf("UNK(%d)", *pt)
|
||||
}
|
||||
|
||||
// nodeInResolvedPath checks whether a transmission's resolved_path contains
|
||||
// the target node's full pubkey. Returns true if at least one observation's
|
||||
// resolved_path includes targetPK (lowercased). Excludes transmissions where
|
||||
// resolved_path is nil/empty or the hop resolved to a different node.
|
||||
func nodeInResolvedPath(tx *StoreTx, targetPK string) bool {
|
||||
// If no resolved_path data exists anywhere on this tx, we can't
|
||||
// disambiguate — return true to keep it (avoid dropping old data).
|
||||
hasAny := false
|
||||
|
||||
// Check the best observation's resolved_path (stored on tx directly).
|
||||
if tx.ResolvedPath != nil && len(tx.ResolvedPath) > 0 {
|
||||
hasAny = true
|
||||
for _, rp := range tx.ResolvedPath {
|
||||
if rp != nil && strings.ToLower(*rp) == targetPK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check all observations in case a non-best observation resolved it.
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.ResolvedPath == nil || len(obs.ResolvedPath) == 0 {
|
||||
continue
|
||||
}
|
||||
hasAny = true
|
||||
for _, rp := range obs.ResolvedPath {
|
||||
if rp != nil && strings.ToLower(*rp) == targetPK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// No resolved_path data at all — can't disambiguate, keep the candidate.
|
||||
return !hasAny
|
||||
}
|
||||
|
||||
// txGetParsedPath returns cached parsed path hops, parsing on first call.
|
||||
func txGetParsedPath(tx *StoreTx) []string {
|
||||
if tx.pathParsed {
|
||||
@@ -2423,9 +2592,36 @@ func (s *PacketStore) buildDistanceIndex() {
|
||||
len(s.distHops), len(s.distPaths))
|
||||
}
|
||||
|
||||
// Self-accounting memory estimation constants.
|
||||
// These estimate the in-memory cost of StoreTx and StoreObs structs including
|
||||
// map/index overhead. They don't need to be exact — just proportional to actual
|
||||
// usage and independent of GC state.
|
||||
const (
|
||||
storeTxBaseBytes = 384 // StoreTx struct fields + map headers + sync.Once + string headers
|
||||
storeObsBaseBytes = 192 // StoreObs struct fields + string headers
|
||||
indexEntryBytes = 48 // average cost of one index map entry (key + pointer + bucket overhead)
|
||||
numIndexesPerTx = 5 // byHash, byTxID, byNode, byPayloadType, nodeHashes entries
|
||||
numIndexesPerObs = 2 // byObsID, byObserver entries
|
||||
)
|
||||
|
||||
// estimateStoreTxBytes returns the estimated memory cost of a StoreTx (excluding observations).
|
||||
func estimateStoreTxBytes(tx *StoreTx) int64 {
|
||||
base := int64(storeTxBaseBytes)
|
||||
base += int64(len(tx.RawHex) + len(tx.Hash) + len(tx.DecodedJSON) + len(tx.PathJSON))
|
||||
base += int64(numIndexesPerTx * indexEntryBytes)
|
||||
return base
|
||||
}
|
||||
|
||||
// estimateStoreObsBytes returns the estimated memory cost of a StoreObs.
|
||||
func estimateStoreObsBytes(obs *StoreObs) int64 {
|
||||
base := int64(storeObsBaseBytes)
|
||||
base += int64(len(obs.PathJSON) + len(obs.ObserverID))
|
||||
base += int64(numIndexesPerObs * indexEntryBytes)
|
||||
return base
|
||||
}
|
||||
|
||||
// estimatedMemoryMB returns current Go heap allocation in MB.
|
||||
// Uses runtime.ReadMemStats so it accounts for all data structures
|
||||
// (distHops, distPaths, spIndex, map overhead) not just packets/observations.
|
||||
// Kept for stats/debug endpoints only — NOT used in eviction decisions.
|
||||
// In tests, memoryEstimator can be set to inject a deterministic value.
|
||||
func (s *PacketStore) estimatedMemoryMB() float64 {
|
||||
if s.memoryEstimator != nil {
|
||||
@@ -2436,6 +2632,11 @@ func (s *PacketStore) estimatedMemoryMB() float64 {
|
||||
return float64(ms.HeapAlloc) / 1048576.0
|
||||
}
|
||||
|
||||
// trackedMemoryMB returns the self-accounted packet store memory in MB.
|
||||
func (s *PacketStore) trackedMemoryMB() float64 {
|
||||
return float64(s.trackedBytes) / 1048576.0
|
||||
}
|
||||
|
||||
// EvictStale removes packets older than the retention window and/or exceeding
|
||||
// the memory cap. Must be called with s.mu held (Lock). Returns the number of
|
||||
// packets evicted.
|
||||
@@ -2454,24 +2655,34 @@ func (s *PacketStore) EvictStale() int {
|
||||
}
|
||||
}
|
||||
|
||||
// Memory-based eviction: if heap exceeds budget, trim proportionally from head.
|
||||
// All major data structures (distHops, distPaths, spIndex) scale with packet count,
|
||||
// so evicting a fraction of packets frees roughly the same fraction of total heap.
|
||||
// A 10% buffer avoids immediately re-triggering on the next ingest cycle.
|
||||
// Memory-based eviction: use self-accounted trackedBytes with watermark hysteresis.
|
||||
// High watermark = maxMemoryMB (trigger), low watermark = 85% (stop).
|
||||
// Safety cap: never evict more than 25% of packets in a single pass.
|
||||
if s.maxMemoryMB > 0 {
|
||||
currentMB := s.estimatedMemoryMB()
|
||||
if currentMB > float64(s.maxMemoryMB) && len(s.packets) > 0 {
|
||||
fractionToKeep := (float64(s.maxMemoryMB) / currentMB) * 0.9
|
||||
keepCount := int(float64(len(s.packets)) * fractionToKeep)
|
||||
if keepCount < 0 {
|
||||
keepCount = 0
|
||||
highWatermark := int64(s.maxMemoryMB) * 1048576
|
||||
lowWatermark := int64(float64(highWatermark) * 0.85)
|
||||
if s.trackedBytes > highWatermark && len(s.packets) > 0 {
|
||||
// Evict from head until trackedBytes would drop below low watermark
|
||||
var bytesToEvict int64
|
||||
memCutoff := cutoffIdx
|
||||
for memCutoff < len(s.packets) && (s.trackedBytes-bytesToEvict) > lowWatermark {
|
||||
tx := s.packets[memCutoff]
|
||||
bytesToEvict += estimateStoreTxBytes(tx)
|
||||
for _, obs := range tx.Observations {
|
||||
bytesToEvict += estimateStoreObsBytes(obs)
|
||||
}
|
||||
memCutoff++
|
||||
}
|
||||
newCutoff := len(s.packets) - keepCount
|
||||
if newCutoff > cutoffIdx {
|
||||
cutoffIdx = newCutoff
|
||||
// Safety cap: never evict more than 25% in a single pass
|
||||
maxEvict := len(s.packets) / 4
|
||||
if maxEvict < 1 {
|
||||
maxEvict = 1
|
||||
}
|
||||
if cutoffIdx > len(s.packets) {
|
||||
cutoffIdx = len(s.packets)
|
||||
if memCutoff > maxEvict {
|
||||
memCutoff = maxEvict
|
||||
}
|
||||
if memCutoff > cutoffIdx {
|
||||
cutoffIdx = memCutoff
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2485,6 +2696,7 @@ func (s *PacketStore) EvictStale() int {
|
||||
|
||||
evicting := s.packets[:cutoffIdx]
|
||||
evictedObs := 0
|
||||
var evictedBytes int64
|
||||
|
||||
// Build sets of evicted IDs for batch removal from secondary indexes
|
||||
evictedTxIDs := make(map[int]struct{}, cutoffIdx)
|
||||
@@ -2500,10 +2712,12 @@ func (s *PacketStore) EvictStale() int {
|
||||
delete(s.byHash, tx.Hash)
|
||||
delete(s.byTxID, tx.ID)
|
||||
evictedTxIDs[tx.ID] = struct{}{}
|
||||
evictedBytes += estimateStoreTxBytes(tx)
|
||||
|
||||
for _, obs := range tx.Observations {
|
||||
delete(s.byObsID, obs.ID)
|
||||
evictedObsIDs[obs.ID] = struct{}{}
|
||||
evictedBytes += estimateStoreObsBytes(obs)
|
||||
if obs.ObserverID != "" {
|
||||
affectedObservers[obs.ObserverID] = struct{}{}
|
||||
}
|
||||
@@ -2515,7 +2729,9 @@ func (s *PacketStore) EvictStale() int {
|
||||
affectedPayloadTypes[*tx.PayloadType] = struct{}{}
|
||||
}
|
||||
|
||||
// Remove from nodeHashes and collect affected node keys
|
||||
// Remove from nodeHashes and collect affected node keys.
|
||||
// Must mirror indexByNode: process decoded JSON fields AND resolved_path pubkeys.
|
||||
evictedFromNode := make(map[string]bool)
|
||||
if tx.DecodedJSON != "" {
|
||||
var decoded map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) == nil {
|
||||
@@ -2528,10 +2744,48 @@ func (s *PacketStore) EvictStale() int {
|
||||
}
|
||||
}
|
||||
affectedNodes[v] = struct{}{}
|
||||
evictedFromNode[v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up resolved_path pubkeys from byNode/nodeHashes
|
||||
for _, obs := range tx.Observations {
|
||||
for _, rp := range obs.ResolvedPath {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
pk := *rp
|
||||
if pk == "" || evictedFromNode[pk] {
|
||||
continue
|
||||
}
|
||||
if hashes, ok := s.nodeHashes[pk]; ok {
|
||||
delete(hashes, tx.Hash)
|
||||
if len(hashes) == 0 {
|
||||
delete(s.nodeHashes, pk)
|
||||
}
|
||||
}
|
||||
affectedNodes[pk] = struct{}{}
|
||||
evictedFromNode[pk] = true
|
||||
}
|
||||
}
|
||||
for _, rp := range tx.ResolvedPath {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
pk := *rp
|
||||
if pk == "" || evictedFromNode[pk] {
|
||||
continue
|
||||
}
|
||||
if hashes, ok := s.nodeHashes[pk]; ok {
|
||||
delete(hashes, tx.Hash)
|
||||
if len(hashes) == 0 {
|
||||
delete(s.nodeHashes, pk)
|
||||
}
|
||||
}
|
||||
affectedNodes[pk] = struct{}{}
|
||||
evictedFromNode[pk] = true
|
||||
}
|
||||
|
||||
// Remove from subpath index
|
||||
removeTxFromSubpathIndexFull(s.spIndex, s.spTxIndex, tx)
|
||||
@@ -2615,7 +2869,12 @@ func (s *PacketStore) EvictStale() int {
|
||||
|
||||
evictCount := cutoffIdx
|
||||
atomic.AddInt64(&s.evicted, int64(evictCount))
|
||||
log.Printf("[store] Evicted %d packets (%d obs)", evictCount, evictedObs)
|
||||
s.trackedBytes -= evictedBytes
|
||||
if s.trackedBytes < 0 {
|
||||
s.trackedBytes = 0
|
||||
}
|
||||
log.Printf("[store] Evicted %d packets (%d obs, freed ~%.1fMB, tracked ~%.1fMB)",
|
||||
evictCount, evictedObs, float64(evictedBytes)/1048576.0, s.trackedMemoryMB())
|
||||
|
||||
// Eviction removes data — all caches may be affected
|
||||
s.invalidateCachesFor(cacheInvalidation{eviction: true})
|
||||
@@ -4715,6 +4974,11 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{
|
||||
|
||||
result := s.computeAnalyticsHashSizes(region)
|
||||
|
||||
// Add multi-byte capability data (only for unfiltered/global view)
|
||||
if region == "" {
|
||||
result["multiByteCapability"] = s.computeMultiByteCapability()
|
||||
}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.hashCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||||
s.cacheMu.Unlock()
|
||||
@@ -4731,7 +4995,13 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
|
||||
regionObs = s.resolveRegionObservers(region)
|
||||
}
|
||||
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
allNodes, pm := s.getCachedNodesAndPM()
|
||||
|
||||
// Build pubkey→role map for filtering by node type.
|
||||
nodeRoleByPK := make(map[string]string, len(allNodes))
|
||||
for _, n := range allNodes {
|
||||
nodeRoleByPK[n.PublicKey] = n.Role
|
||||
}
|
||||
|
||||
distribution := map[string]int{"1": 0, "2": 0, "3": 0}
|
||||
byHour := map[string]map[string]int{}
|
||||
@@ -4808,9 +5078,11 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
|
||||
}
|
||||
}
|
||||
if byNode[pk] == nil {
|
||||
role := nodeRoleByPK[pk] // empty if unknown
|
||||
byNode[pk] = map[string]interface{}{
|
||||
"hashSize": hashSize, "packets": 0,
|
||||
"lastSeen": tx.FirstSeen, "name": name,
|
||||
"role": role,
|
||||
}
|
||||
}
|
||||
byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1
|
||||
@@ -4902,7 +5174,7 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
|
||||
multiByteNodes = append(multiByteNodes, map[string]interface{}{
|
||||
"name": data["name"], "hashSize": data["hashSize"],
|
||||
"packets": data["packets"], "lastSeen": data["lastSeen"],
|
||||
"pubkey": pk,
|
||||
"pubkey": pk, "role": data["role"],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4910,9 +5182,13 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
|
||||
return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int)
|
||||
})
|
||||
|
||||
// Distribution by repeaters: count unique nodes per hash size
|
||||
// Distribution by repeaters: count unique REPEATER nodes per hash size
|
||||
distributionByRepeaters := map[string]int{"1": 0, "2": 0, "3": 0}
|
||||
for _, data := range byNode {
|
||||
role, _ := data["role"].(string)
|
||||
if !strings.Contains(strings.ToLower(role), "repeater") {
|
||||
continue
|
||||
}
|
||||
hs := data["hashSize"].(int)
|
||||
key := strconv.Itoa(hs)
|
||||
distributionByRepeaters[key]++
|
||||
@@ -5392,6 +5668,200 @@ func EnrichNodeWithHashSize(node map[string]interface{}, info *hashSizeNodeInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-Byte Capability Inference ---
|
||||
|
||||
// MultiByteCapEntry represents a repeater's inferred multi-byte capability.
|
||||
type MultiByteCapEntry struct {
|
||||
PublicKey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"` // "confirmed", "suspected", "unknown"
|
||||
Evidence string `json:"evidence"` // "advert", "path", ""
|
||||
MaxHashSize int `json:"maxHashSize"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// computeMultiByteCapability determines multi-byte capability for each
|
||||
// repeater using two methods:
|
||||
//
|
||||
// 1. Confirmed: the node has advertised with hash_size >= 2 (from advert
|
||||
// path byte). This is 100% reliable because the full public key is
|
||||
// received in adverts — no prefix collision ambiguity.
|
||||
//
|
||||
// 2. Suspected: the node's prefix appears as a hop in a packet whose path
|
||||
// header indicates hash_size >= 2. This is <100% reliable because
|
||||
// 2-byte prefixes can collide — two different nodes may share the same
|
||||
// prefix. If one is confirmed multi-byte and the other is not, the
|
||||
// non-confirmed one could be a false positive.
|
||||
//
|
||||
// 3. Unknown: node has only been seen with 1-byte adverts and no
|
||||
// multi-byte path appearances. Could be pre-1.14 firmware or 1.14+
|
||||
// with default (1-byte) settings.
|
||||
//
|
||||
// Caller must hold NO locks — this method acquires mu.RLock internally.
|
||||
func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
|
||||
// Get hash size info from adverts (has its own locking)
|
||||
hashInfo := s.GetNodeHashSizeInfo()
|
||||
|
||||
// Get all nodes for name/role lookup
|
||||
allNodes := s.getAllNodes()
|
||||
nodeByPK := make(map[string]nodeInfo, len(allNodes))
|
||||
for _, n := range allNodes {
|
||||
nodeByPK[n.PublicKey] = n
|
||||
}
|
||||
|
||||
// Build set of confirmed multi-byte pubkeys (advert hash_size >= 2)
|
||||
confirmed := make(map[string]int) // pubkey → max hash size from adverts
|
||||
for pk, info := range hashInfo {
|
||||
maxHS := 1
|
||||
for sz := range info.AllSizes {
|
||||
if sz > maxHS {
|
||||
maxHS = sz
|
||||
}
|
||||
}
|
||||
if maxHS >= 2 {
|
||||
confirmed[pk] = maxHS
|
||||
}
|
||||
}
|
||||
|
||||
// Scan path-hop index for suspected multi-byte nodes.
|
||||
// For each repeater, check if any packet in byPathHop has that
|
||||
// node as a hop with hash_size >= 2 in the path header.
|
||||
s.mu.RLock()
|
||||
|
||||
// Build prefix→pubkey mapping for repeaters
|
||||
type prefixEntry struct {
|
||||
pubkey string
|
||||
prefix string
|
||||
}
|
||||
repeaterPrefixes := make(map[string][]prefixEntry) // prefix → entries
|
||||
for pk, n := range nodeByPK {
|
||||
if !strings.Contains(strings.ToLower(n.Role), "repeater") {
|
||||
continue
|
||||
}
|
||||
// Generate 1-byte, 2-byte, 3-byte prefixes
|
||||
pkLower := strings.ToLower(pk)
|
||||
for byteLen := 1; byteLen <= 3; byteLen++ {
|
||||
hexLen := byteLen * 2
|
||||
if len(pkLower) >= hexLen {
|
||||
pfx := pkLower[:hexLen]
|
||||
repeaterPrefixes[pfx] = append(repeaterPrefixes[pfx], prefixEntry{pk, pfx})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspected := make(map[string]int) // pubkey → max hash size from path appearances
|
||||
for pfx, entries := range repeaterPrefixes {
|
||||
txList := s.byPathHop[pfx]
|
||||
for _, tx := range txList {
|
||||
if tx.RawHex == "" || len(tx.RawHex) < 4 {
|
||||
continue
|
||||
}
|
||||
// Skip TRACE packets (payload_type 8) — they carry hash size in
|
||||
// TRACE flags, not the repeater's compile-time PATH_HASH_SIZE.
|
||||
// Pre-1.14 repeaters can forward multi-byte TRACEs, creating
|
||||
// false positives for "suspected" capability. See #714.
|
||||
if tx.PayloadType != nil && *tx.PayloadType == 8 {
|
||||
continue
|
||||
}
|
||||
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
routeType := header & 0x03
|
||||
pathByteIdx := 1
|
||||
if routeType == 0 || routeType == 3 {
|
||||
pathByteIdx = 5
|
||||
}
|
||||
hexStart := pathByteIdx * 2
|
||||
hexEnd := hexStart + 2
|
||||
if hexEnd > len(tx.RawHex) {
|
||||
continue
|
||||
}
|
||||
actualPathByte, err := strconv.ParseUint(tx.RawHex[hexStart:hexEnd], 16, 8)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hs := int((actualPathByte>>6)&0x3) + 1
|
||||
if hs < 2 {
|
||||
continue
|
||||
}
|
||||
// This packet uses multi-byte hashes and contains this prefix as a hop
|
||||
for _, e := range entries {
|
||||
if hs > suspected[e.pubkey] {
|
||||
suspected[e.pubkey] = hs
|
||||
}
|
||||
}
|
||||
break // one match is enough per prefix
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Build result for all repeaters — fetch last_seen from DB
|
||||
dbLastSeen := make(map[string]string)
|
||||
rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes WHERE role LIKE '%repeater%'")
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
var ls sql.NullString
|
||||
rows.Scan(&pk, &ls)
|
||||
if ls.Valid {
|
||||
dbLastSeen[pk] = ls.String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result []MultiByteCapEntry
|
||||
for pk, n := range nodeByPK {
|
||||
if !strings.Contains(strings.ToLower(n.Role), "repeater") {
|
||||
continue
|
||||
}
|
||||
entry := MultiByteCapEntry{
|
||||
PublicKey: pk,
|
||||
Name: n.Name,
|
||||
Role: n.Role,
|
||||
MaxHashSize: 1,
|
||||
LastSeen: dbLastSeen[pk],
|
||||
}
|
||||
|
||||
if maxHS, ok := confirmed[pk]; ok {
|
||||
entry.Status = "confirmed"
|
||||
entry.Evidence = "advert"
|
||||
entry.MaxHashSize = maxHS
|
||||
} else if maxHS, ok := suspected[pk]; ok {
|
||||
entry.Status = "suspected"
|
||||
entry.Evidence = "path"
|
||||
entry.MaxHashSize = maxHS
|
||||
} else {
|
||||
entry.Status = "unknown"
|
||||
}
|
||||
|
||||
// Check advert hash info for max even if not confirmed multi-byte
|
||||
if info, ok := hashInfo[pk]; ok && entry.MaxHashSize == 1 {
|
||||
for sz := range info.AllSizes {
|
||||
if sz > entry.MaxHashSize {
|
||||
entry.MaxHashSize = sz
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
// Sort: confirmed first, then suspected, then unknown; within each group by name
|
||||
statusOrder := map[string]int{"confirmed": 0, "suspected": 1, "unknown": 2}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
oi, oj := statusOrder[result[i].Status], statusOrder[result[j].Status]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Bulk Health (in-memory) ---
|
||||
|
||||
func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]interface{} {
|
||||
@@ -5569,9 +6039,25 @@ func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]inter
|
||||
func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, error) {
|
||||
// Fetch node info from DB (fast single-row lookup)
|
||||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||||
if err != nil || node == nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If the node isn't in the DB (e.g. companion that never advertised),
|
||||
// check if we have any packet data for it. If so, build a partial response.
|
||||
if node == nil {
|
||||
s.mu.RLock()
|
||||
hasPackets := len(s.byNode[pubkey]) > 0
|
||||
s.mu.RUnlock()
|
||||
if !hasPackets {
|
||||
return nil, nil
|
||||
}
|
||||
// Build a synthetic node stub so the rest of the function works
|
||||
node = map[string]interface{}{
|
||||
"public_key": pubkey,
|
||||
"name": "Unknown",
|
||||
"role": "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
+3
@@ -916,6 +916,9 @@
|
||||
},
|
||||
"estimatedMB": {
|
||||
"type": "number"
|
||||
},
|
||||
"trackedMB": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestTouchNodeLastSeen_UpdatesDB(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Insert a node with no last_seen
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "abc123", "relay1", "REPEATER")
|
||||
|
||||
err := db.TouchNodeLastSeen("abc123", "2026-04-12T04:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("TouchNodeLastSeen returned error: %v", err)
|
||||
}
|
||||
|
||||
var lastSeen sql.NullString
|
||||
db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "abc123").Scan(&lastSeen)
|
||||
if !lastSeen.Valid || lastSeen.String != "2026-04-12T04:00:00Z" {
|
||||
t.Fatalf("expected last_seen=2026-04-12T04:00:00Z, got %v", lastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchNodeLastSeen_DoesNotGoBackwards(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
"abc123", "relay1", "REPEATER", "2026-04-12T05:00:00Z")
|
||||
|
||||
// Try to set an older timestamp
|
||||
err := db.TouchNodeLastSeen("abc123", "2026-04-12T04:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var lastSeen string
|
||||
db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "abc123").Scan(&lastSeen)
|
||||
if lastSeen != "2026-04-12T05:00:00Z" {
|
||||
t.Fatalf("last_seen went backwards: got %s", lastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchNodeLastSeen_NonExistentNode(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Should not error for non-existent node
|
||||
err := db.TouchNodeLastSeen("nonexistent", "2026-04-12T04:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for non-existent node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchRelayLastSeen_Debouncing(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "relay1", "R1", "REPEATER")
|
||||
|
||||
s := &PacketStore{
|
||||
db: db,
|
||||
lastSeenTouched: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
pk := "relay1"
|
||||
tx := &StoreTx{
|
||||
ResolvedPath: []*string{&pk},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
s.touchRelayLastSeen(tx, now)
|
||||
|
||||
// Verify it was written
|
||||
var lastSeen sql.NullString
|
||||
db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay1").Scan(&lastSeen)
|
||||
if !lastSeen.Valid {
|
||||
t.Fatal("expected last_seen to be set after first touch")
|
||||
}
|
||||
|
||||
// Reset last_seen to check debounce prevents second write
|
||||
db.conn.Exec("UPDATE nodes SET last_seen = NULL WHERE public_key = ?", "relay1")
|
||||
|
||||
// Call again within 5 minutes — should be debounced (no write)
|
||||
s.touchRelayLastSeen(tx, now.Add(2*time.Minute))
|
||||
|
||||
db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay1").Scan(&lastSeen)
|
||||
if lastSeen.Valid {
|
||||
t.Fatal("expected debounce to prevent second write within 5 minutes")
|
||||
}
|
||||
|
||||
// Call after 5 minutes — should write again
|
||||
s.touchRelayLastSeen(tx, now.Add(6*time.Minute))
|
||||
db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay1").Scan(&lastSeen)
|
||||
if !lastSeen.Valid {
|
||||
t.Fatal("expected write after debounce interval expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchRelayLastSeen_SkipsNilResolvedPath(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
s := &PacketStore{
|
||||
db: db,
|
||||
lastSeenTouched: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// tx with nil entries and empty resolved_path
|
||||
tx := &StoreTx{
|
||||
ResolvedPath: []*string{nil, nil},
|
||||
}
|
||||
|
||||
// Should not panic or error
|
||||
s.touchRelayLastSeen(tx, time.Now())
|
||||
}
|
||||
|
||||
func TestTouchRelayLastSeen_NilDB(t *testing.T) {
|
||||
s := &PacketStore{
|
||||
db: nil,
|
||||
lastSeenTouched: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
pk := "abc"
|
||||
tx := &StoreTx{
|
||||
ResolvedPath: []*string{&pk},
|
||||
}
|
||||
|
||||
// Should not panic with nil db
|
||||
s.touchRelayLastSeen(tx, time.Now())
|
||||
}
|
||||
@@ -115,6 +115,7 @@ type WebSocketStatsResp struct {
|
||||
type HealthPacketStoreStats struct {
|
||||
Packets int `json:"packets"`
|
||||
EstimatedMB float64 `json:"estimatedMB"`
|
||||
TrackedMB float64 `json:"trackedMB"`
|
||||
}
|
||||
|
||||
type SlowQuery struct {
|
||||
@@ -174,6 +175,7 @@ type PerfPacketStoreStats struct {
|
||||
SqliteOnly bool `json:"sqliteOnly"`
|
||||
MaxPackets int `json:"maxPackets"`
|
||||
EstimatedMB float64 `json:"estimatedMB"`
|
||||
TrackedMB float64 `json:"trackedMB"`
|
||||
MaxMB int `json:"maxMB"`
|
||||
Indexes PacketStoreIndexes `json:"indexes"`
|
||||
}
|
||||
|
||||
@@ -15,15 +15,11 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 3g
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${STAGING_GO_HTTP_PORT:-82}:80"
|
||||
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
|
||||
- "${STAGING_GO_HTTP_PORT:-80}:80"
|
||||
- "${STAGING_GO_MQTT_PORT:-1883}:1883"
|
||||
- "6060:6060" # pprof server
|
||||
- "6061:6061" # pprof ingestor
|
||||
volumes:
|
||||
@@ -33,6 +29,7 @@ services:
|
||||
- NODE_ENV=staging
|
||||
- ENABLE_PPROF=true
|
||||
- DISABLE_MOSQUITTO=${DISABLE_MOSQUITTO:-false}
|
||||
- DISABLE_CADDY=${DISABLE_CADDY:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DISABLE_MOSQUITTO=${DISABLE_MOSQUITTO:-false}
|
||||
- DISABLE_CADDY=${DISABLE_CADDY:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
|
||||
@@ -14,6 +14,13 @@ if [ -f /app/data/theme.json ]; then
|
||||
ln -sf /app/data/theme.json /app/theme.json
|
||||
fi
|
||||
|
||||
# Source .env from data volume if present (works with any launch method)
|
||||
if [ -f /app/data/.env ]; then
|
||||
set -a
|
||||
. /app/data/.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf"
|
||||
if [ "${DISABLE_MOSQUITTO:-false}" = "true" ] && [ "${DISABLE_CADDY:-false}" = "true" ]; then
|
||||
echo "[config] internal MQTT broker disabled (DISABLE_MOSQUITTO=true)"
|
||||
|
||||
+83
-2
@@ -48,9 +48,52 @@ No `config.json` is required. The server starts with sensible defaults:
|
||||
- Ingestor connects to `mqtt://localhost:1883` automatically
|
||||
- SQLite database at `/app/data/meshcore.db`
|
||||
|
||||
### Docker Compose (recommended for production)
|
||||
### Full `docker run` Reference (recommended)
|
||||
|
||||
Download the example compose file:
|
||||
The bare `docker run` command is the primary deployment method. One image, documented parameters — run it however you want.
|
||||
|
||||
```bash
|
||||
docker run -d --name corescope \
|
||||
--restart=unless-stopped \
|
||||
-p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-e DISABLE_MOSQUITTO=false \
|
||||
-e DISABLE_CADDY=false \
|
||||
-v /your/data:/app/data \
|
||||
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
-v /your/caddy-data:/data/caddy \
|
||||
ghcr.io/kpa-clawbot/corescope:latest
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `-p 80:80` | Yes | HTTP web UI |
|
||||
| `-p 443:443` | No | HTTPS (only if using built-in Caddy with a domain) |
|
||||
| `-p 1883:1883` | No | MQTT broker (expose if external gateways connect directly) |
|
||||
| `-v /your/data:/app/data` | Yes | Persistent data: SQLite DB, config.json, theme.json |
|
||||
| `-v /your/Caddyfile:/etc/caddy/Caddyfile:ro` | No | Custom Caddyfile for HTTPS |
|
||||
| `-v /your/caddy-data:/data/caddy` | No | Caddy TLS certificate storage |
|
||||
| `-e DISABLE_MOSQUITTO=true` | No | Skip the internal Mosquitto broker (use your own) |
|
||||
| `-e DISABLE_CADDY=true` | No | Skip the built-in Caddy reverse proxy |
|
||||
| `-e MQTT_BROKER=mqtt://host:1883` | No | Override MQTT broker URL |
|
||||
|
||||
#### `/app/data/.env` convenience file
|
||||
|
||||
Instead of passing `-e` flags, you can drop a `.env` file in your data volume:
|
||||
|
||||
```bash
|
||||
# /your/data/.env
|
||||
DISABLE_MOSQUITTO=true
|
||||
DISABLE_CADDY=true
|
||||
MQTT_BROKER=mqtt://my-broker:1883
|
||||
```
|
||||
|
||||
The entrypoint sources this file before starting services. This works with any launch method (`docker run`, compose, or manage.sh).
|
||||
|
||||
### Docker Compose (legacy alternative)
|
||||
|
||||
Docker Compose files are maintained for backward compatibility but are no longer the recommended approach.
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/Kpa-clawbot/CoreScope/master/docker-compose.example.yml \
|
||||
@@ -65,6 +108,11 @@ docker compose up -d
|
||||
| `HTTP_PORT` | `80` | Host port for the web UI |
|
||||
| `DATA_DIR` | `./data` | Host path for persistent data |
|
||||
| `DISABLE_MOSQUITTO` | `false` | Set `true` to use an external MQTT broker |
|
||||
| `DISABLE_CADDY` | `false` | Set `true` to skip the built-in Caddy proxy |
|
||||
|
||||
### manage.sh (legacy alternative)
|
||||
|
||||
The `manage.sh` wrapper script provides a setup wizard and convenience commands. It uses Docker Compose internally. See [DEPLOY.md](../DEPLOY.md) for usage. New deployments should prefer bare `docker run`.
|
||||
|
||||
### Image tags
|
||||
|
||||
@@ -111,6 +159,7 @@ CoreScope uses a layered configuration system (highest priority wins):
|
||||
| `MQTT_TOPIC` | `meshcore/#` | MQTT topic subscription pattern |
|
||||
| `DB_PATH` | `data/meshcore.db` | SQLite database path |
|
||||
| `DISABLE_MOSQUITTO` | `false` | Skip the internal Mosquitto broker |
|
||||
| `DISABLE_CADDY` | `false` | Skip the built-in Caddy reverse proxy |
|
||||
|
||||
### config.json
|
||||
|
||||
@@ -261,6 +310,38 @@ Caddy handles certificate issuance and renewal automatically.
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
CoreScope auto-generates an OpenAPI 3.0 specification from its route definitions. The spec is always in sync with the running server — no manual maintenance required.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `/api/spec` | OpenAPI 3.0 JSON schema — machine-readable API definition |
|
||||
| `/api/docs` | Interactive Swagger UI — browse and test all 40+ endpoints |
|
||||
|
||||
### Usage
|
||||
|
||||
**Browse the API interactively:**
|
||||
```
|
||||
http://your-instance/api/docs
|
||||
```
|
||||
|
||||
**Fetch the spec programmatically:**
|
||||
```bash
|
||||
curl http://your-instance/api/spec | jq .
|
||||
```
|
||||
|
||||
**For bot/integration developers:** The spec includes all request parameters, response schemas, and example values. Import it into Postman, Insomnia, or any OpenAPI-compatible tool.
|
||||
|
||||
### Public instance
|
||||
The live instance at [analyzer.00id.net](https://analyzer.00id.net) has all API endpoints publicly accessible:
|
||||
- Spec: [analyzer.00id.net/api/spec](https://analyzer.00id.net/api/spec)
|
||||
- Docs: [analyzer.00id.net/api/docs](https://analyzer.00id.net/api/docs)
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Health Checks
|
||||
|
||||
### Docker health check
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# CoreScope v3.5.0 🚀
|
||||
|
||||
The "stop building from source and start analyzing your mesh" release. 95 commits.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Pre-built Docker Images
|
||||
|
||||
CoreScope now ships as a ready-to-run Docker image on GitHub Container Registry. No cloning, no building, no dependencies — just pull and run.
|
||||
|
||||
```bash
|
||||
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v corescope-data:/app/data \
|
||||
ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
```
|
||||
|
||||
**Using HTTPS with a custom domain?** Mount your Caddyfile and certs directory:
|
||||
```bash
|
||||
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v /your/data:/app/data \
|
||||
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
-v /your/caddy-data:/data/caddy \
|
||||
ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
```
|
||||
Caddy auto-provisions Let's Encrypt certs. Your Caddyfile just needs:
|
||||
```
|
||||
yourdomain.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
That's it. Zero config required — MQTT broker, Caddy HTTPS, and SQLite are built in.
|
||||
|
||||
**Already running CoreScope?**
|
||||
```bash
|
||||
# 1. Find your running container name
|
||||
docker ps --format '{{.Names}}'
|
||||
|
||||
# 2. Stop and remove it
|
||||
docker stop <container-name> && docker rm <container-name>
|
||||
|
||||
# 3. Pull the pre-built image
|
||||
docker pull ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
|
||||
# 4. Run with your existing data directory
|
||||
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v /your/data:/app/data \
|
||||
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
-v /your/caddy-data:/data/caddy \
|
||||
ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
```
|
||||
Your data volume stays. Nothing to migrate.
|
||||
|
||||
Tags: `v3.5.0` (this release) · `latest` (latest tagged release) · `edge` (master tip, for testing). Env: `DISABLE_CADDY=true` / `DISABLE_MOSQUITTO=true` if you bring your own.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 83% Faster
|
||||
|
||||
35 performance commits. Packets endpoint p50 dropped from 16.7ms → 2.7ms. Server now serves HTTP within 2 minutes on *any* DB size — async background backfill means you're never staring at a loading screen. N+1 API calls killed everywhere. Prefix map memory cut 10x. WebSocket renders batched via rAF.
|
||||
|
||||
---
|
||||
|
||||
## 🔬 RF Health Dashboard
|
||||
|
||||
New Analytics tab. Per-observer noise floor as color-coded columns (green/yellow/red), airtime utilization, error rates, battery levels. Click any observer for the full breakdown. Region-filterable. This is the beginning of making CoreScope more than just a packet viewer.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ See Where Traces Actually Go
|
||||
|
||||
Send a trace → watch it on the live map. Solid animated line shows how far it got. Dashed ghost shows where it didn't reach. Finally know *where* your trace failed, not just *that* it failed.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Things That Were Lying To You
|
||||
|
||||
- "By Repeaters" was counting companions. Fixed.
|
||||
- Zero-hop adverts claimed "1 byte hash" when the hash size was unknowable. Fixed.
|
||||
- "Packets through this node" showed packets through a *different* node with the same prefix. Fixed — now uses the neighbor affinity graph.
|
||||
- Table sorting on nodes/neighbors/observers silently did nothing. Fixed.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Deep Links · 🎨 Channel Colors · 📱 Mobile · 🔑 Security
|
||||
|
||||
**Deep links** — every page state goes in the URL. Share a link to a specific node, filter, or analytics tab.
|
||||
|
||||
**Channel colors** — click the color dot next to any channel, pick from 8 colors, see it highlighted across the feed. Persists in localStorage.
|
||||
|
||||
**Distance units** — km, miles, or auto-detect from locale. Customizer → Display.
|
||||
|
||||
**Mobile** — 44px touch targets, ARIA labels, responsive breakpoints.
|
||||
|
||||
**Security** — weak API keys rejected at startup. License: GPL v3.
|
||||
|
||||
---
|
||||
|
||||
## 📡 Full API Documentation
|
||||
|
||||
Every endpoint is now documented with an auto-generated OpenAPI 3.0 spec — always in sync with the running server.
|
||||
|
||||
- **Interactive Swagger UI:** [analyzer.00id.net/api/docs](https://analyzer.00id.net/api/docs) — browse and test all 40+ endpoints
|
||||
- **Machine-readable spec:** [analyzer.00id.net/api/spec](https://analyzer.00id.net/api/spec) — import into Postman, Insomnia, or use for bot/integration development
|
||||
|
||||
On your own instance: `/api/docs` and `/api/spec`.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 14 Bugs Squashed
|
||||
|
||||
Live map crash, zero-hop hash lies, animation freezes, repeater miscounts, prefix collisions, dead channel picker, invisible buttons, broken sorting, memory leak, and more.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
docker stop <container-name> && docker rm <container-name>
|
||||
docker pull ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
|
||||
# HTTP only:
|
||||
docker run -d --name corescope -p 80:80 -p 1883:1883 \
|
||||
-v /your/data:/app/data \
|
||||
ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
|
||||
# With HTTPS (custom domain):
|
||||
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v /your/data:/app/data \
|
||||
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||||
-v /your/caddy-data:/data/caddy \
|
||||
ghcr.io/kpa-clawbot/corescope:v3.5.0
|
||||
```
|
||||
|
||||
First start backfills `resolved_path` in the background. No downtime. No breaking changes.
|
||||
@@ -0,0 +1,674 @@
|
||||
# Deep Linking P1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared.
|
||||
|
||||
**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render |
|
||||
| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` |
|
||||
| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` |
|
||||
| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` |
|
||||
| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` |
|
||||
| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `setSelected` to RegionFilter
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/region-filter.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing unit test**
|
||||
|
||||
Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line:
|
||||
|
||||
```javascript
|
||||
// ===== REGION-FILTER.JS: setSelected =====
|
||||
console.log('\n=== region-filter.js: setSelected ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
|
||||
loadInCtx(ctx, 'public/region-filter.js');
|
||||
|
||||
const RF = ctx.RegionFilter;
|
||||
RF.init(document.createElement('div'));
|
||||
|
||||
test('setSelected sets region codes', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO', 'US-LAX']);
|
||||
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
|
||||
});
|
||||
|
||||
test('setSelected with null clears selection', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected(null);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
|
||||
test('setSelected with empty array clears selection', async () => {
|
||||
await RF.init(document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected([]);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected"
|
||||
```
|
||||
|
||||
Expected: `❌ setSelected sets region codes: RF.setSelected is not a function`
|
||||
|
||||
- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js**
|
||||
|
||||
In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`):
|
||||
|
||||
```javascript
|
||||
var _listeners = [];
|
||||
var _container = null; // ← add this line
|
||||
var _loaded = false;
|
||||
```
|
||||
|
||||
In `initFilter`, save the container:
|
||||
|
||||
```javascript
|
||||
async function initFilter(container, opts) {
|
||||
_container = container; // ← add this line
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
```
|
||||
|
||||
Add `setSelected` function before `// Expose globally`:
|
||||
|
||||
```javascript
|
||||
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
|
||||
function setSelected(codesArray) {
|
||||
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
|
||||
saveToStorage();
|
||||
if (_container) render(_container);
|
||||
}
|
||||
```
|
||||
|
||||
Add `setSelected` to the public API object:
|
||||
|
||||
```javascript
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
render: render,
|
||||
getSelected: getSelected,
|
||||
getRegionParam: getRegionParam,
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions,
|
||||
setSelected: setSelected, // ← add this line
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)"
|
||||
```
|
||||
|
||||
Expected: 3 passing `setSelected` tests, overall pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add public/region-filter.js test-frontend-helpers.js
|
||||
git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: nodes.js — tab and search deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/nodes.js`
|
||||
- Test: `test-frontend-helpers.js`
|
||||
- Test: `test-e2e-playwright.js`
|
||||
|
||||
- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)**
|
||||
|
||||
Add before the `// ===== SUMMARY =====` line:
|
||||
|
||||
```javascript
|
||||
// ===== NODES.JS: buildNodesQuery =====
|
||||
console.log('\n=== nodes.js: buildNodesQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
// Provide required globals for nodes.js IIFE to execute
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.qrcode = null;
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/nodes.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildNodesQuery = ctx.buildNodesQuery;
|
||||
|
||||
if (buildNodesQuery) {
|
||||
test('buildNodesQuery: all tab + no search = empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', ''), '');
|
||||
});
|
||||
test('buildNodesQuery: repeater tab only', () => {
|
||||
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
|
||||
});
|
||||
test('buildNodesQuery: search only (all tab)', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
|
||||
});
|
||||
test('buildNodesQuery: tab + search combined', () => {
|
||||
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
|
||||
});
|
||||
test('buildNodesQuery: null search treated as empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', null), '');
|
||||
});
|
||||
test('buildNodesQuery: sensor tab', () => {
|
||||
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails (or skips)**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery"
|
||||
```
|
||||
|
||||
Expected: `⚠️ buildNodesQuery not exposed — skipping`
|
||||
|
||||
- [ ] **Step 3: Add URL param reading and helpers to nodes.js**
|
||||
|
||||
**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`):
|
||||
|
||||
```javascript
|
||||
function buildNodesQuery(tab, searchStr) {
|
||||
var parts = [];
|
||||
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
|
||||
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildNodesQuery = buildNodesQuery;
|
||||
|
||||
function updateNodesUrl() {
|
||||
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
|
||||
}
|
||||
```
|
||||
|
||||
**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`:
|
||||
|
||||
```javascript
|
||||
// Read URL params for list view (router strips query string from routeParam)
|
||||
const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
const _urlTab = _listUrlParams.get('tab');
|
||||
const _urlSearch = _listUrlParams.get('search');
|
||||
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
|
||||
if (_urlSearch) search = _urlSearch;
|
||||
|
||||
app.innerHTML = `<div class="nodes-page">
|
||||
```
|
||||
|
||||
**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input:
|
||||
|
||||
```javascript
|
||||
if (search) {
|
||||
var _si = document.getElementById('nodeSearch');
|
||||
if (_si) _si.value = search;
|
||||
}
|
||||
```
|
||||
|
||||
**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`:
|
||||
|
||||
```javascript
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
updateNodesUrl();
|
||||
loadNodes();
|
||||
}, 250));
|
||||
```
|
||||
|
||||
**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`:
|
||||
|
||||
```javascript
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers"
|
||||
```
|
||||
|
||||
Expected: 6 passing `buildNodesQuery` tests.
|
||||
|
||||
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)**
|
||||
|
||||
Add before the closing `await browser.close()` line:
|
||||
|
||||
```javascript
|
||||
// --- Group: Deep linking (#536) ---
|
||||
|
||||
// Test: nodes tab deep link
|
||||
await test('Nodes tab deep link restores active tab', async () => {
|
||||
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const activeTab = await page.$('.node-tab.active');
|
||||
assert(activeTab, 'No active tab found');
|
||||
const tabText = await activeTab.textContent();
|
||||
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: nodes tab click updates URL
|
||||
await test('Nodes tab click updates URL', async () => {
|
||||
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const roomTab = await page.$('.node-tab[data-tab="room"]');
|
||||
if (roomTab) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js
|
||||
git commit -m "feat: deep link nodes tab and search query (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: packets.js — timeWindow and region deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/packets.js`
|
||||
- Test: `test-frontend-helpers.js`
|
||||
- Test: `test-e2e-playwright.js`
|
||||
|
||||
> Depends on Task 1 (RegionFilter.setSelected).
|
||||
|
||||
- [ ] **Step 1: Write the unit test**
|
||||
|
||||
Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`:
|
||||
|
||||
```javascript
|
||||
// ===== PACKETS.JS: buildPacketsUrl =====
|
||||
console.log('\n=== packets.js: buildPacketsUrl ===');
|
||||
{
|
||||
// Test the pure helper function
|
||||
// (loaded via packets.js after it exposes window.buildPacketsUrl)
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.observerMap = new Map();
|
||||
ctx.getParsedPath = () => [];
|
||||
ctx.getParsedDecoded = () => ({});
|
||||
ctx.clearParsedCache = () => {};
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.initTabBar = () => {};
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
const src2 = fs.readFileSync('public/packets.js', 'utf8');
|
||||
vm.runInContext(src2, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildPacketsUrl = ctx.buildPacketsUrl;
|
||||
|
||||
if (buildPacketsUrl) {
|
||||
test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => {
|
||||
assert.strictEqual(buildPacketsUrl(15, ''), '#/packets');
|
||||
});
|
||||
test('buildPacketsUrl: non-default timeWindow', () => {
|
||||
assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60');
|
||||
});
|
||||
test('buildPacketsUrl: region only', () => {
|
||||
assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO');
|
||||
});
|
||||
test('buildPacketsUrl: timeWindow + region', () => {
|
||||
assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30®ion=US-SFO%2CUS-LAX');
|
||||
});
|
||||
test('buildPacketsUrl: timeWindow=0 treated as default', () => {
|
||||
assert.strictEqual(buildPacketsUrl(0, ''), '#/packets');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildPacketsUrl not exposed — skipping');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it skips**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl"
|
||||
```
|
||||
|
||||
Expected: `⚠️ buildPacketsUrl not exposed — skipping`
|
||||
|
||||
- [ ] **Step 3: Add helpers and URL param reading to packets.js**
|
||||
|
||||
**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`):
|
||||
|
||||
```javascript
|
||||
function buildPacketsUrl(timeWindowMin, regionParam) {
|
||||
var parts = [];
|
||||
if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin);
|
||||
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
|
||||
return '#/packets' + (parts.length ? '?' + parts.join('&') : '');
|
||||
}
|
||||
window.buildPacketsUrl = buildPacketsUrl;
|
||||
|
||||
function updatePacketsUrl() {
|
||||
history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
}
|
||||
```
|
||||
|
||||
**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`:
|
||||
|
||||
```javascript
|
||||
// Read URL params for filter state (router strips query from routeParam; read from location.hash)
|
||||
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
|
||||
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
|
||||
savedTimeWindowMin = _urlTimeWindow;
|
||||
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
|
||||
}
|
||||
var _urlRegion = _initUrlParams.get('region');
|
||||
if (_urlRegion) {
|
||||
RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean));
|
||||
}
|
||||
|
||||
app.innerHTML = `<div class="split-layout detail-collapsed">
|
||||
```
|
||||
|
||||
**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`:
|
||||
|
||||
```javascript
|
||||
fTimeWindow.addEventListener('change', () => {
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
updatePacketsUrl();
|
||||
loadPackets();
|
||||
});
|
||||
```
|
||||
|
||||
**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`:
|
||||
|
||||
```javascript
|
||||
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers"
|
||||
```
|
||||
|
||||
Expected: 5 passing `buildPacketsUrl` tests.
|
||||
|
||||
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
|
||||
|
||||
```javascript
|
||||
// Test: packets timeWindow deep link
|
||||
await test('Packets timeWindow deep link restores dropdown', async () => {
|
||||
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
const val = await page.$eval('#fTimeWindow', el => el.value);
|
||||
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: timeWindow change updates URL
|
||||
await test('Packets timeWindow change updates URL', async () => {
|
||||
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
await page.selectOption('#fTimeWindow', '30');
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js
|
||||
git commit -m "feat: deep link packets timeWindow and region filter (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: channels.js — node panel deep linking
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/channels.js`
|
||||
|
||||
No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it.
|
||||
|
||||
- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
|
||||
|
||||
```javascript
|
||||
// Test: channels selected channel survives refresh (already implemented, verify it still works)
|
||||
await test('Channels channel selection is URL-addressable', async () => {
|
||||
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
|
||||
const firstChannel = await page.$('.ch-item');
|
||||
if (firstChannel) {
|
||||
await firstChannel.click();
|
||||
await page.waitForTimeout(500);
|
||||
const url = page.url();
|
||||
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL**
|
||||
|
||||
In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`:
|
||||
|
||||
```javascript
|
||||
async function showNodeDetail(name) {
|
||||
_nodePanelTrigger = document.activeElement;
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const node = await lookupNode(name);
|
||||
selectedNode = name;
|
||||
var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
|
||||
|
||||
let panel = document.getElementById('chNodePanel');
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL**
|
||||
|
||||
In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`:
|
||||
|
||||
```javascript
|
||||
function closeNodeDetail() {
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const panel = document.getElementById('chNodePanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
selectedNode = null;
|
||||
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chRestoreUrl);
|
||||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Read `?node=` on init and auto-open panel**
|
||||
|
||||
In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`):
|
||||
|
||||
```javascript
|
||||
function init(app, routeParam) {
|
||||
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
var _pendingNode = _initUrlParams.get('node');
|
||||
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
```
|
||||
|
||||
Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel:
|
||||
|
||||
```javascript
|
||||
loadChannels().then(async function () {
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode) showNodeDetail(_pendingNode);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
```bash
|
||||
node test-frontend-helpers.js
|
||||
```
|
||||
|
||||
Expected: all tests pass (no channels unit tests, but regression tests still pass).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add public/channels.js
|
||||
git commit -m "feat: deep link channels node panel via ?node= (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Run E2E Playwright tests
|
||||
|
||||
- [ ] **Step 1: Start the local server**
|
||||
|
||||
```bash
|
||||
cd cmd/server && go run . &
|
||||
```
|
||||
|
||||
Wait for it to be ready (check `http://localhost:3000`).
|
||||
|
||||
- [ ] **Step 2: Run Playwright tests**
|
||||
|
||||
```bash
|
||||
node test-e2e-playwright.js
|
||||
```
|
||||
|
||||
Expected: all tests pass including the new deep-linking group.
|
||||
|
||||
- [ ] **Step 3: If any deep-linking test fails, debug**
|
||||
|
||||
Common failures:
|
||||
- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering
|
||||
- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built
|
||||
- URL doesn't update: check `history.replaceState` calls in the change handlers
|
||||
|
||||
- [ ] **Step 4: Final commit (if any fixes needed)**
|
||||
|
||||
```bash
|
||||
git add public/nodes.js public/packets.js public/channels.js
|
||||
git commit -m "fix: deep linking E2E adjustments (#536)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ P1: Nodes role tab → Task 2
|
||||
- ✅ P1: Packets time window → Task 3
|
||||
- ✅ P1: Packets region filter → Task 3 (depends on Task 1)
|
||||
- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351)
|
||||
- ✅ P1: Channels node panel → Task 4
|
||||
- ✅ P2+ items → explicitly out of scope per issue
|
||||
|
||||
**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll).
|
||||
|
||||
**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅
|
||||
|
||||
**Type consistency:**
|
||||
- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅
|
||||
- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅
|
||||
- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅
|
||||
@@ -0,0 +1,162 @@
|
||||
# v3.4.2 Manual Validation Checklist
|
||||
|
||||
**Tester:** _______________
|
||||
**Staging:** http://20.109.157.39
|
||||
**Prod:** https://analyzer.00id.net (READ ONLY — do not deploy until staging passes)
|
||||
**Browser:** Chrome + Firefox + Safari (mobile for responsive items)
|
||||
**Time estimate:** ~45 minutes
|
||||
|
||||
---
|
||||
|
||||
## 🔴 HIGH RISK — Test First
|
||||
|
||||
### 1. Zero-hop hash size display (#649, #653)
|
||||
- [ ] Go to Packets page, find a DIRECT advert (route_type=2, 0 hops)
|
||||
- [ ] Open packet detail — hash size should say "Unknown (zero-hop)" or be hidden, NOT "1 byte"
|
||||
- [ ] Check "Path Length" field shows `hash_count=0 (direct advert)`
|
||||
- [ ] Find a FLOOD advert with 0 hops — it SHOULD show hash size (this is different from DIRECT)
|
||||
|
||||
### 2. TRACE packet real path (#651, #656)
|
||||
- [ ] Send a trace from your companion
|
||||
- [ ] Watch Live map — the animated dot should only travel along completed hops (solid line)
|
||||
- [ ] Unreached hops should show as dashed/ghosted line at reduced opacity
|
||||
- [ ] If trace completes fully, entire path should be solid
|
||||
- [ ] Ghost line should auto-clean after ~10 seconds
|
||||
|
||||
### 3. "Paths through this node" accuracy (#655, #658)
|
||||
- [ ] Go to: http://20.109.157.39/#/nodes/c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4
|
||||
- [ ] Check "Packets through this node" — packets should actually have this node in their path
|
||||
- [ ] Compare with a node that shares a 2-char prefix (e.g. C0ffee SF) — they should show DIFFERENT packets
|
||||
- [ ] Spot-check 3-4 packets: click through, verify path contains the node
|
||||
|
||||
### 4. Hash Stats "By Repeaters" (#652, #654)
|
||||
- [ ] Go to Analytics → Hash Stats
|
||||
- [ ] "By Repeaters" section should only show repeater-role nodes
|
||||
- [ ] Compare count in "Multi-Byte Hash Adopters" vs "By Repeaters" — adopters may include companions, repeaters section should not
|
||||
- [ ] Check that companions/rooms/sensors are excluded from the repeater distribution
|
||||
|
||||
### 5. Noise floor column chart (#600, #659)
|
||||
- [ ] Go to Analytics → RF Health
|
||||
- [ ] Noise floor chart should show vertical color-coded bars, NOT a line
|
||||
- [ ] Green bars (< -100 dBm), yellow (-100 to -85), red (≥ -85)
|
||||
- [ ] Hover over a bar — tooltip should show exact dBm + timestamp
|
||||
- [ ] Check with only 1 observer selected — chart should still render (division by zero edge case)
|
||||
- [ ] Reboot markers (if any) should show as vertical dashed lines
|
||||
|
||||
### 6. Async backfill on startup
|
||||
- [ ] SSH to staging: `ssh -i ~/.ssh/id_ed25519 runner@20.109.157.39`
|
||||
- [ ] `docker restart corescope-staging-go`
|
||||
- [ ] Within 30 seconds, hit `curl http://localhost:82/api/stats` — should return data (not hang)
|
||||
- [ ] Check `backfilling` and `backfillProgress` fields in stats response
|
||||
- [ ] Server should be serving HTTP while backfill runs in background
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM RISK — Features
|
||||
|
||||
### 7. Distance unit preference (#621, #646)
|
||||
- [ ] Go to Customizer → Display tab
|
||||
- [ ] Change distance unit to "mi" — all distances should show in miles
|
||||
- [ ] Change to "km" — all distances should show in km
|
||||
- [ ] Change to "auto" — should use locale (US = miles, EU = km)
|
||||
- [ ] Check Analytics page distances update after customizer change (no page reload needed)
|
||||
- [ ] Check Node detail → Neighbors table distances
|
||||
- [ ] Very small distances (<0.1 mi) should show in feet, not "0.0 mi"
|
||||
|
||||
### 8. Panel corner toggle (#608, #657)
|
||||
- [ ] Go to Live map page
|
||||
- [ ] Each panel (feed, legend, node detail) should have a small corner-toggle button
|
||||
- [ ] Click the button — panel should snap to next corner (TL → TR → BR → BL)
|
||||
- [ ] Refresh page — panel positions should persist (localStorage)
|
||||
- [ ] Move two panels to same corner — collision avoidance should skip to next free corner
|
||||
- [ ] On mobile viewport (<768px?) — toggle buttons should be hidden
|
||||
|
||||
### 9. Deep linking (#536, #618)
|
||||
- [ ] Navigate to Nodes page, click a node → URL should update with pubkey hash
|
||||
- [ ] Copy URL, open in new tab → should land on same node
|
||||
- [ ] Apply packet filters → URL hash should include filter params
|
||||
- [ ] Channels page: select a node → URL should reflect selection
|
||||
- [ ] Analytics tabs: switch tabs → URL should include tab name
|
||||
- [ ] Share a deep link with someone — they should see the same view
|
||||
|
||||
### 10. Sortable tables (#620, #638, #639)
|
||||
- [ ] Nodes list: click column headers — should sort ascending/descending
|
||||
- [ ] Sort indicator (arrow) should be visible on active column
|
||||
- [ ] Node detail → Neighbors table: sortable
|
||||
- [ ] Node detail → Observers table: sortable
|
||||
- [ ] Packets table: sortable by column headers
|
||||
|
||||
### 11. Channel color highlighting (#271, #607, #611)
|
||||
- [ ] Go to Channels page
|
||||
- [ ] Assign a color to a channel using the color picker
|
||||
- [ ] Feed rows should highlight with that color
|
||||
- [ ] Change color — should update immediately
|
||||
- [ ] Refresh — color assignment should persist
|
||||
|
||||
### 12. Collapsible panels (#606)
|
||||
- [ ] Live map: panels should have collapse/expand toggle
|
||||
- [ ] Collapsed panel should show just the header
|
||||
- [ ] State should persist across page navigations
|
||||
|
||||
### 13. Mobile accessibility (#630, #633)
|
||||
- [ ] Open staging on phone (or Chrome DevTools mobile emulation)
|
||||
- [ ] Touch targets should be at least 44×44px
|
||||
- [ ] Channel color picker should work on mobile
|
||||
- [ ] No horizontal scroll on any page
|
||||
- [ ] ARIA labels present on interactive elements (inspect with accessibility tools)
|
||||
|
||||
### 14. Map byte-size filter (#565, #568)
|
||||
- [ ] Go to Map page
|
||||
- [ ] Find the byte-size filter control
|
||||
- [ ] Filter by packet size — map should update to show only matching packets
|
||||
- [ ] Clear filter — all packets should return
|
||||
|
||||
### 15. API key security (#532, #628)
|
||||
- [ ] Try accessing a write endpoint without API key — should be blocked
|
||||
- [ ] Try with a weak key (e.g., "test", "admin") — should be rejected at startup
|
||||
- [ ] Check staging logs for API key warning: `docker logs corescope-staging-go 2>&1 | grep -i "apiKey\|api_key\|security"`
|
||||
|
||||
### 16. OpenAPI/Swagger (#530, #632)
|
||||
- [ ] Hit http://20.109.157.39/api/spec — should return valid OpenAPI 3.0 spec
|
||||
- [ ] Hit http://20.109.157.39/api/docs — should show Swagger UI
|
||||
- [ ] Try an endpoint from Swagger UI — should work
|
||||
|
||||
---
|
||||
|
||||
## 🟢 LOW RISK — Verify Quickly
|
||||
|
||||
### 17. View Route on Map button
|
||||
- [ ] Go to any packet detail page
|
||||
- [ ] Click "View Route on Map" — should navigate to map with route highlighted
|
||||
|
||||
### 18. og-image compression
|
||||
- [ ] Check page source or network tab — og-image.png should be < 300KB (was 1.1MB)
|
||||
|
||||
### 19. Prefix Tool
|
||||
- [ ] Analytics → Prefix Tool tab should load
|
||||
- [ ] Should show collision data
|
||||
|
||||
### 20. License
|
||||
- [ ] Check repo footer/LICENSE — should be GPL v3
|
||||
|
||||
### 21. Docker DISABLE_CADDY
|
||||
- [ ] (If testable) Set DISABLE_CADDY=true — Caddy should not start
|
||||
|
||||
### 22. Region filter on RF Health
|
||||
- [ ] RF Health tab: change region filter — charts should update
|
||||
|
||||
---
|
||||
|
||||
## 🏁 Sign-off
|
||||
|
||||
| Section | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| High risk (1-6) | ☐ | |
|
||||
| Medium risk (7-16) | ☐ | |
|
||||
| Low risk (17-22) | ☐ | |
|
||||
| **Overall** | ☐ | |
|
||||
|
||||
**Tested by:** _______________
|
||||
**Date:** _______________
|
||||
**Staging version:** `curl -s http://20.109.157.39/api/stats | jq .version`
|
||||
**Ready for release:** ☐ Yes / ☐ No — blockers: _______________
|
||||
@@ -0,0 +1,309 @@
|
||||
# v3.4.2 Release Test Plan
|
||||
|
||||
**Scope:** 90 commits since v3.4.1 (84 files, +14,931 / -1,005)
|
||||
**Categories:** 19 perf, 19 feat, 18 fix, 15 docs, 3 chore, 1 test, 1 refactor, 1 ci
|
||||
**Date:** 2026-04-08
|
||||
|
||||
---
|
||||
|
||||
## A. Automated Tests — Verify All Pass
|
||||
|
||||
### Go Backend
|
||||
```bash
|
||||
cd cmd/server && go test -race -count=1 ./...
|
||||
cd cmd/ingestor && go test -race -count=1 ./...
|
||||
```
|
||||
|
||||
**Test files (27 total):**
|
||||
|
||||
| File | Tests For |
|
||||
|------|-----------|
|
||||
| `cmd/server/decoder_test.go` | Hash size zero-hop, TRACE hopsCompleted, transport direct |
|
||||
| `cmd/server/backfill_async_test.go` | **NEW** — Async chunked backfill |
|
||||
| `cmd/server/eviction_test.go` | Memory eviction with runtime heap stats |
|
||||
| `cmd/server/apikey_security_test.go` | **NEW** — Weak/default API key rejection |
|
||||
| `cmd/server/openapi_test.go` | **NEW** — OpenAPI spec generation |
|
||||
| `cmd/server/routes_test.go` | Batch observations endpoint, subpaths-bulk, expand=observations |
|
||||
| `cmd/server/cache_invalidation_test.go` | cacheTTL config wiring |
|
||||
| `cmd/server/config_knobs_test.go` | cacheTTLSec helper |
|
||||
| `cmd/server/helpers_test.go` | constantTimeEqual, IsWeakAPIKey |
|
||||
| `cmd/server/obs_dedup_test.go` | UniqueObserverCount tracking |
|
||||
| `cmd/server/neighbor_*.go` (4 files) | Neighbor graph, affinity, persistence |
|
||||
| `cmd/server/perfstats_race_test.go` | Perf stats concurrency |
|
||||
| `cmd/server/resolve_context_test.go` | Resolved path filtering |
|
||||
| `cmd/server/advert_pubkey_test.go` | Advert pubkey tracking |
|
||||
| `cmd/server/db_test.go` | SQLite operations |
|
||||
| `cmd/server/config_test.go` | Config loading |
|
||||
| `cmd/server/coverage_test.go` | Coverage helpers |
|
||||
| `cmd/server/parity_test.go` | Go/JS decoder parity |
|
||||
| `cmd/server/websocket_test.go` | WebSocket broadcast |
|
||||
| `cmd/ingestor/decoder_test.go` | Ingestor decoder (hash size zero-hop) |
|
||||
| `cmd/ingestor/db_test.go` | Ingestor DB writes |
|
||||
| `cmd/ingestor/config_test.go` | Ingestor config |
|
||||
| `cmd/ingestor/main_test.go` | Ingestor entry |
|
||||
| `cmd/ingestor/coverage_boost_test.go` | Coverage helpers |
|
||||
|
||||
### Frontend Unit Tests
|
||||
```bash
|
||||
node test-packet-filter.js
|
||||
node test-aging.js
|
||||
node test-frontend-helpers.js
|
||||
node test-table-sort.js # NEW — shared table sort utility
|
||||
node test-channel-colors.js # NEW — channel color model
|
||||
node test-panel-corner.js # NEW — panel corner toggle
|
||||
node test-packets.js # NEW — packets page logic
|
||||
node test-hop-resolver-affinity.js
|
||||
node test-customizer-v2.js
|
||||
node test-live.js
|
||||
node test-live-dedup.js
|
||||
```
|
||||
|
||||
### E2E / Playwright
|
||||
```bash
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js
|
||||
```
|
||||
|
||||
**Expected:** All existing tests pass + new tests added for sortable tables, deep linking, collapsible panels.
|
||||
|
||||
---
|
||||
|
||||
## B. Manual Browser Verification
|
||||
|
||||
### B1. HIGH RISK — Data Correctness
|
||||
|
||||
| # | Feature | Page | What to Check |
|
||||
|---|---------|------|---------------|
|
||||
| 1 | Hash size zero-hop | Packets detail | Find a direct (route_type=0) packet → hash_size should show 0, not a bogus computed value |
|
||||
| 2 | TRACE hopsCompleted | Packets detail / Live map | Find a TRACE packet → verify `hopsCompleted` shows in decoded JSON, live map shows real path length vs intended |
|
||||
| 3 | Transport direct hash size | Packets detail | Find route_type=RouteTransportDirect packet → hash_size=0 |
|
||||
| 4 | resolved_path filtering | Node detail → Paths tab | Verify path-hop candidates use resolved_path, no prefix collision false positives |
|
||||
| 5 | Hash stats repeater filter | Analytics → Hash Issues | "By Repeaters" should only show nodes with repeater role, not companions/sensors |
|
||||
| 6 | Async chunked backfill | Server startup | Start server with large DB → verify HTTP serves within 2 minutes, `X-CoreScope-Status: backfilling` header present, then transitions to `ready` |
|
||||
| 7 | Memory eviction (heap stats) | Admin/stats | Verify `/api/stats` shows realistic memory numbers from runtime heap, not the old estimation |
|
||||
| 8 | Distance/subpath/path-hop indexes | Analytics → Distances, Subpaths | Verify analytics data matches v3.4.1 output (no missing or extra entries) |
|
||||
| 9 | cacheTTL config wiring | Config | Set `cacheTTL.analyticsHashSizes: 300` in config → verify collision cache respects it |
|
||||
|
||||
### B2. MEDIUM RISK — User-Facing Features
|
||||
|
||||
| # | Feature | Page | What to Check |
|
||||
|---|---------|------|---------------|
|
||||
| 10 | Distance unit preference | Nodes detail, Map | Toggle km/mi/auto in settings → distances update throughout UI |
|
||||
| 11 | Panel corner toggle | Live page | Click corner toggle → panel moves to opposite corner, persists on reload |
|
||||
| 12 | Noise floor column chart | Analytics → RF | Verify column chart renders with color-coded thresholds, hover shows values |
|
||||
| 13 | Deep linking UI states | All pages | Navigate to `#/nodes?tab=neighbors`, `#/packets?observer=X`, `#/channels?node=Y` → correct state loads. Copy URL, open in new tab → same state |
|
||||
| 14 | Sortable tables | Nodes list, Neighbors, Observers | Click column headers → sort asc/desc, indicator arrow shows, persists correctly |
|
||||
| 15 | Channel color highlighting | Channels, Live feed | Assign color to channel → feed rows show that color, persists on reload |
|
||||
| 16 | Mobile accessibility | All pages (phone viewport) | Touch targets ≥44px, ARIA labels present, small viewport doesn't overflow |
|
||||
| 17 | Collapsible panels | Live map | Collapse/expand panels, medium breakpoint auto-collapses, state persists |
|
||||
| 18 | Byte-size map filter | Map page | Filter by byte size → markers update correctly |
|
||||
| 19 | OpenAPI/Swagger | `/api/spec`, `/api/docs` | Spec loads valid JSON, Swagger UI renders and all endpoints are documented |
|
||||
| 20 | API key rejection | Protected endpoints | Send weak key (e.g. "changeme", "test123") → 403 forbidden |
|
||||
| 21 | Channel color picker mobile | Channels (phone viewport) | Color picker usable on touch, doesn't overflow |
|
||||
| 22 | RF Health dashboard | Analytics → RF Health | Observer metrics grid, airtime charts, battery charts, error rate, region filter |
|
||||
| 23 | Prefix Tool tab | Analytics → Prefix Tool | Renders correctly, collision data consistent with Hash Issues |
|
||||
| 24 | View Route on Map | Packet detail page | Button works and shows route on map |
|
||||
|
||||
### B3. LOWER RISK — Performance (Verify No Regressions)
|
||||
|
||||
| # | Feature | Page | What to Check |
|
||||
|---|---------|------|---------------|
|
||||
| 25 | Incremental DOM diff | Packets (30K+) | Virtual scroll renders smoothly, no visible flicker |
|
||||
| 26 | Coalesced WS renders | Live page | Rapid packets don't cause frame drops (rAF coalescing) |
|
||||
| 27 | Marker reposition on zoom | Map | Zoom/resize → markers move smoothly, no full rebuild flash |
|
||||
| 28 | Parallel replay fetches | Live → VCR | Replay loads quickly (parallel observation fetches) |
|
||||
| 29 | Batch observations API | Packets page (sort change) | Changing sort fetches observations in batch (network tab: 1 POST not N GETs) |
|
||||
| 30 | Client-side network status | Analytics | No separate API call for network status |
|
||||
| 31 | og-image compression | `/og-image.png` | Verify loads, ~235KB not ~1.1MB |
|
||||
|
||||
---
|
||||
|
||||
## C. API Regression Tests
|
||||
|
||||
Run against a local server with test-fixture DB:
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:13581
|
||||
|
||||
# Core endpoints — verify response shape
|
||||
curl -s "$BASE/api/stats" | jq '.totalPackets, .backfilling, .backfillProgress'
|
||||
curl -s "$BASE/api/packets?limit=5" | jq '.packets[0] | keys'
|
||||
curl -s "$BASE/api/packets?limit=5&expand=observations" | jq '.packets[0].observations | length'
|
||||
curl -s "$BASE/api/nodes?limit=5" | jq '.[0] | keys'
|
||||
|
||||
# New endpoints
|
||||
curl -s -X POST "$BASE/api/packets/observations" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"hashes":["test123"]}' | jq '.results | keys'
|
||||
|
||||
curl -s "$BASE/api/analytics/subpaths-bulk?hops=A,B&hops=B,C" | jq 'keys'
|
||||
|
||||
curl -s "$BASE/api/observers/metrics/summary" | jq 'type'
|
||||
curl -s "$BASE/api/spec" | jq '.openapi'
|
||||
curl -s "$BASE/api/docs" | head -5 # Should return HTML
|
||||
|
||||
# Backfill status header
|
||||
curl -sI "$BASE/api/stats" | grep X-CoreScope-Status
|
||||
|
||||
# API key rejection
|
||||
curl -s -H 'X-API-Key: changeme' "$BASE/api/debug/vars" | jq '.error'
|
||||
curl -s -H 'X-API-Key: test' "$BASE/api/debug/vars" | jq '.error'
|
||||
|
||||
# Existing endpoints — verify not broken
|
||||
curl -s "$BASE/api/analytics/rf?timeRange=24h" | jq 'keys'
|
||||
curl -s "$BASE/api/analytics/hash-sizes" | jq 'type'
|
||||
curl -s "$BASE/api/analytics/distances" | jq 'type'
|
||||
curl -s "$BASE/api/analytics/subpaths" | jq 'type'
|
||||
curl -s "$BASE/api/channels" | jq 'type'
|
||||
curl -s "$BASE/api/config/client" | jq 'keys'
|
||||
```
|
||||
|
||||
### Expected response shape changes from v3.4.1:
|
||||
- `/api/stats` now includes `backfilling` (bool) and `backfillProgress` (float 0-1)
|
||||
- `/api/packets` no longer strips observations by default (lazy via `ExpandObservations` flag) — verify `observations` key absent without `expand=observations`
|
||||
- Decoded packets with route_type=direct now have `hashSize: 0`
|
||||
- TRACE packets now have `path.hopsCompleted` field
|
||||
|
||||
---
|
||||
|
||||
## D. Performance Regression Tests
|
||||
|
||||
### D1. Server Startup Time
|
||||
```bash
|
||||
# Start server with production-size DB (~30K packets)
|
||||
# Measure time from process start to first successful HTTP response
|
||||
time curl -s http://localhost:13581/api/stats > /dev/null
|
||||
# Target: < 2 minutes (async backfill requirement)
|
||||
```
|
||||
|
||||
### D2. Go Benchmarks
|
||||
```bash
|
||||
cd cmd/server && go test -bench=. -benchmem -count=3
|
||||
```
|
||||
Key benchmarks to compare with v3.4.1 baseline:
|
||||
- `BenchmarkQueryPackets` — should not regress with new indexes
|
||||
- `BenchmarkEvictStale` — batch removal from secondary indexes
|
||||
- `BenchmarkGetStoreStats` — 2 concurrent queries vs 5 sequential
|
||||
- `BenchmarkIngestNew` — additional index maintenance overhead
|
||||
|
||||
### D3. Frontend Performance
|
||||
- Open Packets page with 30K+ packets → measure initial render time (DevTools Performance tab)
|
||||
- Scroll rapidly through virtual scroll → should maintain 60fps
|
||||
- Switch sort column on packets → single batch POST, not N+1 GETs
|
||||
- Open Analytics page → no redundant API calls in network tab
|
||||
|
||||
### D4. Memory Usage
|
||||
- After loading 30K packets, check `/api/stats` memory figure
|
||||
- Compare with v3.4.1 baseline (prefix map cap at 8 chars should reduce ~10x)
|
||||
- Verify eviction triggers at correct memory threshold using runtime heap stats
|
||||
|
||||
---
|
||||
|
||||
## E. Infrastructure / Deployment Tests
|
||||
|
||||
### E1. Docker Build
|
||||
```bash
|
||||
docker build -t corescope:test .
|
||||
docker run --rm -p 13581:13581 corescope:test
|
||||
# Verify: container starts, HTTP responds, WebSocket connects
|
||||
```
|
||||
|
||||
### E2. GHCR Publish (CI)
|
||||
- Verify CI publishes to `ghcr.io/kpa-clawbot/corescope`
|
||||
- Verify tags: `edge` (master), `vX.Y.Z` (release)
|
||||
|
||||
### E3. Staging Deploy
|
||||
```bash
|
||||
# Verify staging compose works with standard ports
|
||||
docker compose -f docker-compose.staging.yml up -d
|
||||
# Check: no 3GB memory limit, standard port binding
|
||||
```
|
||||
|
||||
### E4. DISABLE_CADDY
|
||||
```bash
|
||||
docker run --rm -e DISABLE_CADDY=true corescope:test
|
||||
# Verify: Caddy not started, Go server serves directly
|
||||
```
|
||||
|
||||
### E5. CI Pipeline
|
||||
- Verify consolidated pipeline: build → publish GHCR → deploy staging
|
||||
- Verify runs on `meshcore-runner-2`
|
||||
|
||||
---
|
||||
|
||||
## F. Edge Cases & Integration Tests
|
||||
|
||||
### F1. Cross-Feature Interactions
|
||||
| Scenario | Risk |
|
||||
|----------|------|
|
||||
| Deep link to sorted table → sort state matches URL params | Medium |
|
||||
| Channel color + deep link → color persists in linked URL | Medium |
|
||||
| Panel corner toggle + collapsible panels → both states persist independently | Low |
|
||||
| Distance unit pref + neighbor table sort by distance → sort uses correct unit | Medium |
|
||||
| Noise floor chart + region filter → chart respects filter | Medium |
|
||||
| Byte-size map filter + channel color highlighting → both active simultaneously | Low |
|
||||
|
||||
### F2. Data Correctness Edge Cases
|
||||
| Scenario | Risk |
|
||||
|----------|------|
|
||||
| Zero-hop TRACE packet (should NOT reset hashSize — TRACE exemption) | **High** |
|
||||
| Packet with all hops having same 2-char prefix → resolved_path filtering prevents false match | **High** |
|
||||
| Node that switches role (repeater → companion) → hash stats updates | Medium |
|
||||
| Backfill interrupted mid-chunk (server restart) → resumes or completes on next start | Medium |
|
||||
| Empty DB startup → no errors, backfill completes instantly | Low |
|
||||
| DB with 100K+ packets → async backfill doesn't OOM, progress reported | **High** |
|
||||
|
||||
### F3. Concurrency / Race Conditions
|
||||
| Scenario | Risk |
|
||||
|----------|------|
|
||||
| Concurrent API requests during backfill → no deadlock (lock ordering documented) | **High** |
|
||||
| Eviction running while analytics query in progress → no stale pointer panic | **High** |
|
||||
| Multiple WebSocket clients during high ingest rate → coalesced broadcasts don't drop | Medium |
|
||||
| `time.NewTicker` cleanup on graceful shutdown (replaced `time.Tick`) | Low |
|
||||
|
||||
### F4. API Key Security
|
||||
| Scenario | Expected |
|
||||
|----------|----------|
|
||||
| No API key configured → write endpoints disabled | 403 "write endpoints disabled" |
|
||||
| Weak key "changeme" → rejected even if configured | 403 "forbidden" |
|
||||
| Timing-safe comparison → no timing oracle | Constant-time via `crypto/subtle` |
|
||||
| Empty string key → rejected | 401 "unauthorized" |
|
||||
|
||||
### F5. Browser Compatibility
|
||||
- Test on Chrome, Firefox, Safari (latest)
|
||||
- Test on iOS Safari, Android Chrome
|
||||
- Verify touch targets on mobile (44px minimum)
|
||||
- Verify ARIA labels with screen reader
|
||||
|
||||
---
|
||||
|
||||
## G. Test Coverage Gaps — Action Items
|
||||
|
||||
| Gap | Priority | Action |
|
||||
|-----|----------|--------|
|
||||
| No automated test for distance unit preference rendering | Medium | Add Playwright test |
|
||||
| No automated test for noise floor column chart | Medium | Add Playwright test |
|
||||
| No automated test for deep link state restoration | **High** | Add Playwright tests for each deep-linkable state |
|
||||
| No automated test for channel color persistence | Medium | `test-channel-colors.js` covers model; need Playwright for UI |
|
||||
| No automated test for mobile viewport behavior | Medium | Add Playwright test with mobile viewport |
|
||||
| No automated test for backfill progress header | Low | Add to `routes_test.go` |
|
||||
| No automated test for `time.NewTicker` cleanup | Low | Add to graceful shutdown test |
|
||||
| Observer metrics endpoints not covered in route tests | Medium | Add to `routes_test.go` |
|
||||
| Subpaths-bulk endpoint needs test | Medium | Add to `routes_test.go` |
|
||||
| No load test for batch observations endpoint (200 hash limit) | Low | Add boundary test |
|
||||
|
||||
---
|
||||
|
||||
## H. Release Checklist
|
||||
|
||||
- [ ] All Go tests pass with `-race` flag
|
||||
- [ ] All frontend unit tests pass
|
||||
- [ ] Playwright E2E tests pass
|
||||
- [ ] Manual browser verification (Section B) complete
|
||||
- [ ] API regression tests (Section C) pass
|
||||
- [ ] Docker build succeeds
|
||||
- [ ] Staging deploy verified
|
||||
- [ ] No console errors on any page
|
||||
- [ ] Performance spot-checks (Section D) — no regressions
|
||||
- [ ] Coverage badges updated (backend ≥85%, frontend ≥42%)
|
||||
- [ ] CHANGELOG updated
|
||||
- [ ] Tag `v3.4.2` created
|
||||
@@ -52,3 +52,14 @@ CoreScope uses URL hashes for deep linking. Copy the URL from your browser — i
|
||||
- `#/packets/abc123` — a specific packet
|
||||
- `#/analytics?tab=collisions` — the hash issues tab
|
||||
- `#/nodes/pubkey123` — a specific node's detail page
|
||||
|
||||
### Where is the API documentation?
|
||||
|
||||
CoreScope auto-generates an OpenAPI 3.0 specification from its route definitions:
|
||||
|
||||
- **Interactive docs (Swagger UI):** `/api/docs` — browse and test all 40+ endpoints from your browser
|
||||
- **Machine-readable spec:** `/api/spec` — import into Postman, Insomnia, or any OpenAPI tool
|
||||
|
||||
The spec is always in sync with the running server. No manual maintenance needed.
|
||||
|
||||
On the public instance: [analyzer.00id.net/api/docs](https://analyzer.00id.net/api/docs)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package channel provides MeshCore hashtag channel key derivation,
|
||||
// decryption (HMAC-SHA256 MAC + AES-128-ECB), and plaintext parsing.
|
||||
package channel
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// DeriveKey derives an AES-128 key from a channel name (e.g. "#wardriving").
|
||||
// Returns 16 bytes: SHA-256(channelName)[:16].
|
||||
func DeriveKey(channelName string) []byte {
|
||||
h := sha256.Sum256([]byte(channelName))
|
||||
return h[:16]
|
||||
}
|
||||
|
||||
// ChannelHash returns the 1-byte channel hash used as the first byte of GRP_TXT payloads.
|
||||
// It is the first byte of SHA-256 of the 16-byte key.
|
||||
func ChannelHash(key []byte) byte {
|
||||
h := sha256.Sum256(key)
|
||||
return h[0]
|
||||
}
|
||||
|
||||
// Decrypt verifies the 2-byte HMAC-SHA256 MAC and performs AES-128-ECB decryption.
|
||||
// mac must be exactly 2 bytes. ciphertext must be a multiple of 16 bytes.
|
||||
// Returns the plaintext and true if MAC verification succeeded, or nil and false otherwise.
|
||||
func Decrypt(key []byte, mac []byte, ciphertext []byte) ([]byte, bool) {
|
||||
if len(key) != 16 || len(mac) != 2 || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, key)
|
||||
|
||||
// Verify HMAC-SHA256 (first 2 bytes must match)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calculatedMac := h.Sum(nil)
|
||||
if calculatedMac[0] != mac[0] || calculatedMac[1] != mac[1] {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
return plaintext, true
|
||||
}
|
||||
|
||||
// ParsePlaintext parses decrypted plaintext into timestamp, sender, and message.
|
||||
// Format: timestamp(4 LE) + flags(1) + "sender: message\0..."
|
||||
func ParsePlaintext(plaintext []byte) (timestamp uint32, sender string, message string, err error) {
|
||||
if len(plaintext) < 5 {
|
||||
return 0, "", "", fmt.Errorf("plaintext too short (%d bytes)", len(plaintext))
|
||||
}
|
||||
|
||||
timestamp = binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
text := string(plaintext[5:])
|
||||
if idx := strings.IndexByte(text, 0); idx >= 0 {
|
||||
text = text[:idx]
|
||||
}
|
||||
|
||||
if !utf8.ValidString(text) || countNonPrintable(text) > 2 {
|
||||
return 0, "", "", fmt.Errorf("decrypted text contains non-printable characters")
|
||||
}
|
||||
|
||||
// Parse "sender: message" format
|
||||
if colonIdx := strings.Index(text, ": "); colonIdx > 0 && colonIdx < 50 {
|
||||
potentialSender := text[:colonIdx]
|
||||
if !strings.ContainsAny(potentialSender, ":[]") {
|
||||
return timestamp, potentialSender, text[colonIdx+2:], nil
|
||||
}
|
||||
}
|
||||
|
||||
return timestamp, "", text, nil
|
||||
}
|
||||
|
||||
func countNonPrintable(s string) int {
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveKey(t *testing.T) {
|
||||
key := DeriveKey("#wardriving")
|
||||
h := sha256.Sum256([]byte("#wardriving"))
|
||||
expected := h[:16]
|
||||
if len(key) != 16 {
|
||||
t.Fatalf("key length %d, want 16", len(key))
|
||||
}
|
||||
for i := range key {
|
||||
if key[i] != expected[i] {
|
||||
t.Fatalf("DeriveKey mismatch at byte %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHash(t *testing.T) {
|
||||
key := DeriveKey("#wardriving")
|
||||
ch := ChannelHash(key)
|
||||
h := sha256.Sum256(key)
|
||||
if ch != h[0] {
|
||||
t.Fatalf("ChannelHash %02x, want %02x", ch, h[0])
|
||||
}
|
||||
}
|
||||
|
||||
func testECBEncrypt(t *testing.T, key, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ct := make([]byte, len(plaintext))
|
||||
for i := 0; i < len(plaintext); i += aes.BlockSize {
|
||||
block.Encrypt(ct[i:i+aes.BlockSize], plaintext[i:i+aes.BlockSize])
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
func testComputeMAC(key, ciphertext []byte) []byte {
|
||||
secret := make([]byte, 32)
|
||||
copy(secret, key)
|
||||
h := hmac.New(sha256.New, secret)
|
||||
h.Write(ciphertext)
|
||||
sum := h.Sum(nil)
|
||||
return sum[:2]
|
||||
}
|
||||
|
||||
func TestDecryptValidMAC(t *testing.T) {
|
||||
key := DeriveKey("#test")
|
||||
padded := make([]byte, 16)
|
||||
copy(padded, []byte{0x01, 0x00, 0x00, 0x00, 0x00})
|
||||
ciphertext := testECBEncrypt(t, key, padded)
|
||||
mac := testComputeMAC(key, ciphertext)
|
||||
|
||||
result, ok := Decrypt(key, mac, ciphertext)
|
||||
if !ok {
|
||||
t.Fatal("Decrypt returned false for valid MAC")
|
||||
}
|
||||
if len(result) != 16 {
|
||||
t.Fatalf("result length %d, want 16", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidMAC(t *testing.T) {
|
||||
key := DeriveKey("#test")
|
||||
ciphertext := make([]byte, 16)
|
||||
mac := []byte{0xFF, 0xFF}
|
||||
_, ok := Decrypt(key, mac, ciphertext)
|
||||
if ok {
|
||||
t.Fatal("Decrypt should reject wrong MAC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongChannel(t *testing.T) {
|
||||
key1 := DeriveKey("#channel1")
|
||||
key2 := DeriveKey("#channel2")
|
||||
padded := make([]byte, 16)
|
||||
copy(padded, []byte{0x01, 0x00, 0x00, 0x00, 0x00, 'h', 'i'})
|
||||
ciphertext := testECBEncrypt(t, key1, padded)
|
||||
mac := testComputeMAC(key1, ciphertext)
|
||||
|
||||
_, ok := Decrypt(key2, mac, ciphertext)
|
||||
if ok {
|
||||
t.Fatal("Decrypt should reject wrong channel key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlaintext(t *testing.T) {
|
||||
plain := []byte{100, 0, 0, 0, 0}
|
||||
plain = append(plain, []byte("Alice: Hello\x00")...)
|
||||
ts, sender, msg, err := ParsePlaintext(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ts != 100 {
|
||||
t.Fatalf("timestamp %d, want 100", ts)
|
||||
}
|
||||
if sender != "Alice" {
|
||||
t.Fatalf("sender %q, want Alice", sender)
|
||||
}
|
||||
if msg != "Hello" {
|
||||
t.Fatalf("message %q, want Hello", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlaintextNoSender(t *testing.T) {
|
||||
plain := []byte{1, 0, 0, 0, 0}
|
||||
plain = append(plain, []byte("just a message\x00")...)
|
||||
_, sender, msg, err := ParsePlaintext(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sender != "" {
|
||||
t.Fatalf("sender %q, want empty", sender)
|
||||
}
|
||||
if msg != "just a message" {
|
||||
t.Fatalf("message %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyMatchesIngestor(t *testing.T) {
|
||||
channelName := "#MeshCore"
|
||||
key := DeriveKey(channelName)
|
||||
hexKey := hex.EncodeToString(key)
|
||||
h := sha256.Sum256([]byte(channelName))
|
||||
expected := hex.EncodeToString(h[:16])
|
||||
if hexKey != expected {
|
||||
t.Fatalf("key hex %s != expected %s", hexKey, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
key := DeriveKey("#test")
|
||||
original := make([]byte, 32)
|
||||
copy(original, []byte{0x64, 0x00, 0x00, 0x00, 0x00})
|
||||
copy(original[5:], []byte("Bob: world\x00"))
|
||||
|
||||
ciphertext := testECBEncrypt(t, key, original)
|
||||
mac := testComputeMAC(key, ciphertext)
|
||||
|
||||
plaintext, ok := Decrypt(key, mac, ciphertext)
|
||||
if !ok {
|
||||
t.Fatal("round-trip MAC failed")
|
||||
}
|
||||
|
||||
ts, sender, msg, err := ParsePlaintext(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ts != 100 || sender != "Bob" || msg != "world" {
|
||||
t.Fatalf("got ts=%d sender=%q msg=%q", ts, sender, msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/channel
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/sigvalidate
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package sigvalidate provides ed25519 signature validation for MeshCore advert packets.
|
||||
package sigvalidate
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ValidateAdvert verifies the ed25519 signature on a MeshCore advert.
|
||||
// pubKey must be 32 bytes, signature must be 64 bytes.
|
||||
// The signed message is: pubKey (32) + timestamp (4 LE) + appdata.
|
||||
func ValidateAdvert(pubKey, signature []byte, timestamp uint32, appdata []byte) (bool, error) {
|
||||
if len(pubKey) != 32 {
|
||||
return false, fmt.Errorf("invalid pubkey length: %d", len(pubKey))
|
||||
}
|
||||
if len(signature) != 64 {
|
||||
return false, fmt.Errorf("invalid signature length: %d", len(signature))
|
||||
}
|
||||
|
||||
message := make([]byte, 32+4+len(appdata))
|
||||
copy(message[0:32], pubKey)
|
||||
binary.LittleEndian.PutUint32(message[32:36], timestamp)
|
||||
copy(message[36:], appdata)
|
||||
|
||||
return ed25519.Verify(ed25519.PublicKey(pubKey), message, signature), nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sigvalidate
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateAdvert_ValidSignature(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var timestamp uint32 = 1234567890
|
||||
appdata := []byte{0x02, 0x10, 0x20}
|
||||
|
||||
// Build the signed message: pubKey + timestamp(LE) + appdata
|
||||
msg := make([]byte, 32+4+len(appdata))
|
||||
copy(msg[0:32], pub)
|
||||
binary.LittleEndian.PutUint32(msg[32:36], timestamp)
|
||||
copy(msg[36:], appdata)
|
||||
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
|
||||
valid, err := ValidateAdvert([]byte(pub), sig, timestamp, appdata)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatal("expected valid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdvert_InvalidSignature(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
badSig := make([]byte, 64)
|
||||
valid, err := ValidateAdvert([]byte(pub), badSig, 100, []byte{0x01})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if valid {
|
||||
t.Fatal("expected invalid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdvert_BadPubkeyLength(t *testing.T) {
|
||||
_, err := ValidateAdvert([]byte{1, 2, 3}, make([]byte, 64), 0, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for short pubkey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdvert_BadSignatureLength(t *testing.T) {
|
||||
_, err := ValidateAdvert(make([]byte, 32), []byte{1, 2, 3}, 0, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for short signature")
|
||||
}
|
||||
}
|
||||
+215
-73
@@ -136,9 +136,14 @@
|
||||
analyticsContent.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// Re-render when distance unit or theme changes
|
||||
_themeRefreshHandler = function () { renderTab(_currentTab); };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
var _themeRefreshHandler = null;
|
||||
let _currentTab = 'overview';
|
||||
|
||||
async function loadAnalytics() {
|
||||
@@ -941,25 +946,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-row">
|
||||
<div class="analytics-card flex-1">
|
||||
<h3>Multi-Byte Hash Adopters</h3>
|
||||
<p class="text-muted">Nodes advertising with 2+ byte hash paths</p>
|
||||
${data.multiByteNodes.length ? `
|
||||
<table class="analytics-table">
|
||||
<thead><tr><th scope="col">Node</th><th scope="col">Hash Size</th><th scope="col">Adverts</th><th scope="col">Last Seen</th></tr></thead>
|
||||
<tbody>
|
||||
${data.multiByteNodes.map(n => `<tr class="clickable-row" data-action="navigate" data-value="#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}" tabindex="0" role="row">
|
||||
<td><strong>${esc(n.name)}</strong></td>
|
||||
<td><span class="badge badge-hash-${n.hashSize}">${n.hashSize}-byte</span></td>
|
||||
<td>${n.packets}</td>
|
||||
<td>${timeAgo(n.lastSeen)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<div class="text-muted" style="padding:16px">No multi-byte adopters found</div>'}
|
||||
</div>
|
||||
${renderMultiByteAdopters(data.multiByteNodes, data.multiByteCapability || [])}
|
||||
|
||||
<div class="analytics-row">
|
||||
<div class="analytics-card flex-1">
|
||||
<h3>Top Path Hops</h3>
|
||||
<table class="analytics-table">
|
||||
@@ -981,6 +970,133 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMultiByteAdopters(nodes, caps) {
|
||||
// Merge capability status into adopter nodes
|
||||
var capByPubkey = {};
|
||||
(caps || []).forEach(function(c) { capByPubkey[c.pubkey] = c; });
|
||||
|
||||
var statusIcon = { confirmed: '✅', suspected: '⚠️', unknown: '❓' };
|
||||
var statusLabel = { confirmed: 'Confirmed', suspected: 'Suspected', unknown: 'Unknown' };
|
||||
var statusColor = { confirmed: 'var(--success, #22c55e)', suspected: 'var(--warning, #eab308)', unknown: 'var(--text-muted, #888)' };
|
||||
|
||||
// Build merged rows: each adopter node gets a capability status
|
||||
var rows = (nodes || []).map(function(n) {
|
||||
var cap = capByPubkey[n.pubkey] || {};
|
||||
return {
|
||||
name: n.name, pubkey: n.pubkey || '', role: n.role || '',
|
||||
hashSize: n.hashSize, packets: n.packets, lastSeen: n.lastSeen,
|
||||
status: cap.status || 'unknown', evidence: cap.evidence || ''
|
||||
};
|
||||
});
|
||||
|
||||
// Count statuses
|
||||
var counts = { confirmed: 0, suspected: 0, unknown: 0 };
|
||||
rows.forEach(function(r) { counts[r.status] = (counts[r.status] || 0) + 1; });
|
||||
|
||||
function buildTableContent(rows, filter) {
|
||||
var filtered = filter === 'all' ? rows : rows.filter(function(r) { return r.status === filter; });
|
||||
return (filtered.length ? '<table class="analytics-table" id="mbAdoptersTable" style="margin-top:12px">' +
|
||||
'<thead><tr>' +
|
||||
'<th scope="col" data-sort="name">Node</th>' +
|
||||
'<th scope="col" data-sort="status">Status</th>' +
|
||||
'<th scope="col" data-sort="hashSize">Hash Size</th>' +
|
||||
'<th scope="col" data-sort="packets">Adverts</th>' +
|
||||
'<th scope="col" data-sort="lastSeen">Last Seen</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' +
|
||||
filtered.map(function(r) {
|
||||
return '<tr class="clickable-row" data-action="navigate" data-value="#/nodes/' + encodeURIComponent(r.pubkey) + '" tabindex="0" role="row">' +
|
||||
'<td><strong>' + esc(r.name) + '</strong></td>' +
|
||||
'<td><span style="color:' + (statusColor[r.status] || statusColor.unknown) + '">' +
|
||||
(statusIcon[r.status] || '❓') + ' ' + (statusLabel[r.status] || 'Unknown') + '</span></td>' +
|
||||
'<td><span class="badge badge-hash-' + r.hashSize + '">' + r.hashSize + '-byte</span></td>' +
|
||||
'<td>' + r.packets + '</td>' +
|
||||
'<td>' + (r.lastSeen ? timeAgo(r.lastSeen) : '—') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' : '<div class="text-muted" style="padding:16px">No adopters match this filter.</div>');
|
||||
}
|
||||
|
||||
if (!rows.length) return '<div class="analytics-row"><div class="analytics-card flex-1">' +
|
||||
'<h3>Multi-Byte Hash Adopters</h3>' +
|
||||
'<div class="text-muted" style="padding:16px">No multi-byte adopters found</div></div></div>';
|
||||
|
||||
var html = '<div class="analytics-row"><div class="analytics-card flex-1" id="mbAdoptersSection">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">' +
|
||||
'<div>' +
|
||||
'<h3 style="margin:0">Multi-Byte Hash Adopters</h3>' +
|
||||
'<p class="text-muted" style="margin:4px 0 0;font-size:0.8em">Nodes advertising with 2+ byte hash paths. ' +
|
||||
'<strong>Confirmed</strong> = seen advertising with multi-byte hash. ' +
|
||||
'<strong>Suspected</strong> = prefix appeared in a multi-byte path. ' +
|
||||
'<strong>Unknown</strong> = no multi-byte evidence yet.</p>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:4px;flex-wrap:wrap" id="mbCapFilters">' +
|
||||
'<button class="tab-btn active" data-mb-filter="all">All (' + rows.length + ')</button>' +
|
||||
'<button class="tab-btn" data-mb-filter="confirmed" style="--filter-color:var(--success, #22c55e)">✅ Confirmed (' + counts.confirmed + ')</button>' +
|
||||
'<button class="tab-btn" data-mb-filter="suspected" style="--filter-color:var(--warning, #eab308)">⚠️ Suspected (' + counts.suspected + ')</button>' +
|
||||
'<button class="tab-btn" data-mb-filter="unknown" style="--filter-color:var(--text-muted, #888)">❓ Unknown (' + counts.unknown + ')</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="mbAdoptersTableWrap">' + buildTableContent(rows, 'all') + '</div>' +
|
||||
'</div></div>';
|
||||
|
||||
// Use setTimeout for event delegation on the stable section container
|
||||
setTimeout(function() {
|
||||
var section = document.getElementById('mbAdoptersSection');
|
||||
if (!section) return;
|
||||
var currentFilter = 'all';
|
||||
|
||||
section.addEventListener('click', function handler(e) {
|
||||
var btn = e.target.closest('[data-mb-filter]');
|
||||
if (btn) {
|
||||
currentFilter = btn.dataset.mbFilter;
|
||||
// Update active state on buttons (no DOM replacement needed)
|
||||
var buttons = section.querySelectorAll('[data-mb-filter]');
|
||||
buttons.forEach(function(b) { b.classList.toggle('active', b.dataset.mbFilter === currentFilter); });
|
||||
// Replace only the table content, not the whole section
|
||||
var wrap = section.querySelector('#mbAdoptersTableWrap');
|
||||
if (wrap) wrap.innerHTML = buildTableContent(rows, currentFilter);
|
||||
return;
|
||||
}
|
||||
var th = e.target.closest('[data-sort]');
|
||||
if (th) {
|
||||
var tbody = section.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
var sortRows = Array.from(tbody.querySelectorAll('tr'));
|
||||
var col = th.dataset.sort;
|
||||
var colIdx = { name: 0, status: 1, hashSize: 2, packets: 3, lastSeen: 4 };
|
||||
var statusWeight = { 'confirmed': 0, 'suspected': 1, 'unknown': 2 };
|
||||
sortRows.sort(function(a, b) {
|
||||
var va = a.children[colIdx[col]] ? a.children[colIdx[col]].textContent.trim() : '';
|
||||
var vb = b.children[colIdx[col]] ? b.children[colIdx[col]].textContent.trim() : '';
|
||||
if (col === 'status') {
|
||||
va = statusWeight[va.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[va.toLowerCase().split(' ').pop()] : 2;
|
||||
vb = statusWeight[vb.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[vb.toLowerCase().split(' ').pop()] : 2;
|
||||
}
|
||||
if (col === 'hashSize' || col === 'packets') { va = parseInt(va) || 0; vb = parseInt(vb) || 0; }
|
||||
if (va < vb) return -1;
|
||||
if (va > vb) return 1;
|
||||
return 0;
|
||||
});
|
||||
sortRows.forEach(function(r) { tbody.appendChild(r); });
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Legacy alias for tests — delegates to renderMultiByteAdopters with empty nodes
|
||||
function renderMultiByteCapability(caps) {
|
||||
if (!caps.length) return '';
|
||||
// Convert caps to adopter-style rows for backward compat
|
||||
var fakeNodes = caps.map(function(c) {
|
||||
return { name: c.name, pubkey: c.pubkey, role: c.role, hashSize: c.maxHashSize, packets: 0, lastSeen: c.lastSeen };
|
||||
});
|
||||
return renderMultiByteAdopters(fakeNodes, caps);
|
||||
}
|
||||
|
||||
async function renderCollisionTab(el, data, collisionData) {
|
||||
el.innerHTML = `
|
||||
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
|
||||
@@ -1372,6 +1488,8 @@
|
||||
}
|
||||
|
||||
const showAppearances = bytes < 3;
|
||||
const t50 = formatDistanceRound(50);
|
||||
const t200 = formatDistanceRound(200);
|
||||
el.innerHTML = `<table class="analytics-table">
|
||||
<thead><tr>
|
||||
<th scope="col">Prefix</th>
|
||||
@@ -1383,20 +1501,20 @@
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
badge = `<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within ${t50} — likely true collision, same RF neighborhood">🏘️ Local</span>`;
|
||||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||||
} else if (c.classification === 'regional') {
|
||||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
badge = `<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes ${t50}–${t200} apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>`;
|
||||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||||
} else if (c.classification === 'distant') {
|
||||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
badge = `<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >${t200} apart — beyond typical 915MHz range">🌐 Distant</span>`;
|
||||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||||
} else {
|
||||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||||
tooltip = 'Not enough coordinate data to classify';
|
||||
}
|
||||
const nodes = c.nodes || [];
|
||||
const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : '<span class="text-muted">—</span>';
|
||||
const distStr = c.with_coords >= 2 ? formatDistanceRound(c.max_dist_km) : '<span class="text-muted">—</span>';
|
||||
return `<tr>
|
||||
<td class="mono">${c.prefix}</td>
|
||||
${showAppearances ? `<td>${(c.appearances || 0).toLocaleString()}</td>` : ''}
|
||||
@@ -1412,9 +1530,9 @@
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="text-muted" style="padding:8px;font-size:0.8em">
|
||||
<strong>🏘️ Local</strong> <50km: true prefix collision, same mesh area
|
||||
<strong>⚡ Regional</strong> 50–200km: edge of LoRa range, possible atmospheric propagation
|
||||
<strong>🌐 Distant</strong> >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||||
<strong>🏘️ Local</strong> <${t50}: true prefix collision, same mesh area
|
||||
<strong>⚡ Regional</strong> ${t50}–${t200}: edge of LoRa range, possible atmospheric propagation
|
||||
<strong>🌐 Distant</strong> >${t200}: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||||
</div>`;
|
||||
}
|
||||
async function renderSubpaths(el) {
|
||||
@@ -1545,12 +1663,12 @@
|
||||
: (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })();
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${formatDistance(km)}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
} else {
|
||||
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)} → ${esc(b.name)} (no coords)</span></div>`);
|
||||
}
|
||||
}
|
||||
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}</strong></div>`);
|
||||
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${formatDistance(total)}</strong></div>`);
|
||||
return dists.join('');
|
||||
})()}
|
||||
</div>` : ''}
|
||||
@@ -1787,16 +1905,17 @@
|
||||
let html = `<div class="analytics-grid">
|
||||
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${formatDistance(s.avgDist)}</div><div class="stat-label">Avg Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${formatDistance(s.maxDist)}</div><div class="stat-label">Max Hop Distance</div></div>
|
||||
</div>`;
|
||||
|
||||
// Category stats
|
||||
const cats = data.catStats;
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (km)</th><th scope="col">Median (km)</th><th scope="col">Min (km)</th><th scope="col">Max (km)</th></tr></thead><tbody>`;
|
||||
const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km';
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (${distUnitLabel})</th><th scope="col">Median (${distUnitLabel})</th><th scope="col">Min (${distUnitLabel})</th><th scope="col">Max (${distUnitLabel})</th></tr></thead><tbody>`;
|
||||
for (const [cat, st] of Object.entries(cats)) {
|
||||
if (!st.count) continue;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${formatDistance(st.avg)}</td><td>${formatDistance(st.median)}</td><td>${formatDistance(st.min)}</td><td>${formatDistance(st.max)}</td></tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
@@ -1813,7 +1932,7 @@
|
||||
}
|
||||
|
||||
// Top hops leaderboard
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (km)</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
const top20 = data.topHops.slice(0, 20);
|
||||
top20.forEach((h, i) => {
|
||||
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||||
@@ -1821,13 +1940,13 @@
|
||||
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
|
||||
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
|
||||
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Top paths
|
||||
if (data.topPaths.length) {
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (km)</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (${distUnitLabel})</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
data.topPaths.slice(0, 10).forEach((p, i) => {
|
||||
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
|
||||
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
|
||||
@@ -1836,7 +1955,7 @@
|
||||
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
|
||||
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
|
||||
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
html += `<tr><td>${i+1}</td><td><strong>${formatDistance(p.totalDist)}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
@@ -1864,7 +1983,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; }
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1873,6 +1992,9 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
window._analyticsChannelTbodyHtml = channelTbodyHtml;
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
window._analyticsRfNFColumnChart = rfNFColumnChart;
|
||||
window._analyticsRenderMultiByteCapability = renderMultiByteCapability;
|
||||
window._analyticsRenderMultiByteAdopters = renderMultiByteAdopters;
|
||||
}
|
||||
|
||||
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
|
||||
@@ -2353,7 +2475,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
const nodes = allNodes.filter(n => n.role === 'repeater');
|
||||
|
||||
if (nodes.length === 0) {
|
||||
el.innerHTML = `<div class="analytics-card"><p class="text-muted">No nodes in the network yet. Any prefix is available!</p></div>`;
|
||||
el.innerHTML = `<div class="analytics-card"><p class="text-muted">No repeaters in the network yet. Any prefix is available!</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2382,11 +2504,11 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
const totalNodes = nodes.length;
|
||||
let rec, recDetail;
|
||||
if (totalNodes < 20) {
|
||||
rec = '1-byte'; recDetail = `With only ${totalNodes} nodes, 1-byte prefixes have low collision risk.`;
|
||||
rec = '1-byte'; recDetail = `With only ${totalNodes} repeaters, 1-byte prefixes have low collision risk.`;
|
||||
} else if (totalNodes < 500) {
|
||||
rec = '2-byte'; recDetail = `With ${totalNodes} nodes, 2-byte prefixes are recommended to avoid collisions.`;
|
||||
rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are recommended to avoid collisions.`;
|
||||
} else {
|
||||
rec = '2-byte'; recDetail = `With ${totalNodes} nodes, 2-byte prefixes are strongly recommended.`;
|
||||
rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are strongly recommended.`;
|
||||
}
|
||||
|
||||
// URL params for pre-fill / auto-run
|
||||
@@ -2395,7 +2517,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
const initGenerate = hashParams.get('generate') || '';
|
||||
|
||||
const regionNote = regionLabel
|
||||
? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Check all nodes →</a></p>`
|
||||
? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Check all repeaters →</a></p>`
|
||||
: '';
|
||||
|
||||
el.innerHTML = `
|
||||
@@ -2408,7 +2530,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
${regionNote}
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 16px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Total nodes</div>
|
||||
<div class="analytics-stat-label">Total repeaters</div>
|
||||
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
${[1, 2, 3].map(b => `
|
||||
@@ -2924,7 +3046,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
// Render noise floor chart
|
||||
const nfEl = document.getElementById('rfDetailNFChart');
|
||||
if (nfEl && nfData.length > 1) {
|
||||
nfEl.innerHTML = rfNFLineChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT);
|
||||
nfEl.innerHTML = rfNFColumnChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT);
|
||||
} else if (nfEl) {
|
||||
nfEl.innerHTML = '<span class="text-muted">Not enough noise floor data</span>';
|
||||
}
|
||||
@@ -3188,7 +3310,13 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
return svg;
|
||||
}
|
||||
|
||||
function rfNFLineChart(data, w, h, reboots, sharedMinT, sharedMaxT) {
|
||||
/**
|
||||
* Noise floor column chart — color-coded bars (green/yellow/red) by threshold.
|
||||
* Replaces the old line chart for better discrete-sample readability.
|
||||
* Thresholds: green (< -100 dBm), yellow (-100 to -85 dBm), red (≥ -85 dBm).
|
||||
*/
|
||||
function rfNFColumnChart(data, w, h, reboots, sharedMinT, sharedMaxT) {
|
||||
if (!data || !data.length) return '<svg viewBox="0 0 1 1"></svg>';
|
||||
reboots = reboots || [];
|
||||
const pad = { top: 20, right: 40, bottom: 30, left: 55 };
|
||||
const cw = w - pad.left - pad.right;
|
||||
@@ -3199,34 +3327,33 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
const maxT = sharedMaxT != null ? sharedMaxT : Math.max(...data.map(d => new Date(d.t).getTime()));
|
||||
const minV = Math.min(...values);
|
||||
const maxV = Math.max(...values);
|
||||
const rangeV = maxV - minV || 1;
|
||||
// Guard against zero range (single data point or constant values):
|
||||
// use a ±5 dBm window so bars are visible and centered in the chart
|
||||
const rawRangeV = maxV - minV;
|
||||
const rangeV = rawRangeV || 10;
|
||||
const adjMinV = rawRangeV ? minV : minV - 5;
|
||||
const rangeT = maxT - minT || 1;
|
||||
|
||||
const sx = t => pad.left + ((t - minT) / rangeT) * cw;
|
||||
const sy = v => pad.top + ch - ((v - minV) / rangeV) * ch;
|
||||
const sy = v => pad.top + ch - ((v - adjMinV) / rangeV) * ch;
|
||||
|
||||
const pts = data.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' ');
|
||||
// Column width: proportional to chart width / data points, min 2px, gap of 1px
|
||||
const colW = Math.max(2, Math.floor(cw / data.length) - 1);
|
||||
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor line chart"><title>Noise floor over time</title>`;
|
||||
const times = data.map(d => new Date(d.t).getTime());
|
||||
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor column chart"><title>Noise floor over time</title>`;
|
||||
|
||||
// Inline style for hover highlighting
|
||||
svg += `<style>.nf-bar{transition:opacity 0.05s}.nf-bar:hover{opacity:0.75;stroke:var(--text);stroke-width:1}</style>`;
|
||||
|
||||
// Chart title
|
||||
svg += `<text x="${pad.left}" y="12" font-size="10" fill="var(--text-muted)" font-weight="600">Noise Floor dBm</text>`;
|
||||
|
||||
// Reference lines
|
||||
const refLines = [-100, -85];
|
||||
const refLabels = ['-100 warning', '-85 critical'];
|
||||
refLines.forEach((ref, i) => {
|
||||
if (ref >= minV && ref <= maxV) {
|
||||
const y = sy(ref);
|
||||
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--text-muted)" stroke-width="0.5" stroke-dasharray="4,2"/>`;
|
||||
svg += `<text x="${w - pad.right + 2}" y="${(y + 3).toFixed(1)}" font-size="9" fill="var(--text-muted)">${refLabels[i]}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Y-axis labels
|
||||
// Y-axis labels + grid lines
|
||||
const yTicks = 5;
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const v = minV + (rangeV * i / yTicks);
|
||||
const v = adjMinV + (rangeV * i / yTicks);
|
||||
const y = sy(v);
|
||||
svg += `<text x="${pad.left - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="var(--text-muted)">${v.toFixed(0)}</text>`;
|
||||
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--border)" stroke-width="0.3"/>`;
|
||||
@@ -3238,24 +3365,39 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
// X-axis labels
|
||||
svg += rfXAxisLabels(data, sx, h, pad);
|
||||
|
||||
// Data polyline
|
||||
svg += `<polyline points="${pts}" fill="none" stroke="var(--accent)" stroke-width="1.5"/>`;
|
||||
// Color-coded columns
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const t = times[i];
|
||||
const v = data[i].v;
|
||||
const x = sx(t) - colW / 2;
|
||||
const y = sy(v);
|
||||
const barH = pad.top + ch - y;
|
||||
|
||||
// Hover tooltips
|
||||
svg += rfTooltipCircles(data, sx, sy, 'NF', ' dBm');
|
||||
// Threshold color: green < -100, yellow -100 to -85, red >= -85
|
||||
let color;
|
||||
if (v < -100) color = 'var(--success, #22c55e)';
|
||||
else if (v < -85) color = 'var(--warning, #eab308)';
|
||||
else color = 'var(--danger, #ef4444)';
|
||||
|
||||
// Direct labels: min and max points
|
||||
const times = data.map(d => new Date(d.t).getTime());
|
||||
const maxIdx = values.indexOf(maxV);
|
||||
const minIdx = values.indexOf(minV);
|
||||
svg += `<circle cx="${sx(times[maxIdx]).toFixed(1)}" cy="${sy(maxV).toFixed(1)}" r="3" fill="var(--danger, red)"/>`;
|
||||
svg += `<text x="${sx(times[maxIdx]).toFixed(1)}" y="${(sy(maxV) - 6).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--danger, red)">${maxV.toFixed(1)}</text>`;
|
||||
svg += `<circle cx="${sx(times[minIdx]).toFixed(1)}" cy="${sy(minV).toFixed(1)}" r="3" fill="var(--success, green)"/>`;
|
||||
svg += `<text x="${sx(times[minIdx]).toFixed(1)}" y="${(sy(minV) + 14).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--success, green)">${minV.toFixed(1)}</text>`;
|
||||
const ts = new Date(data[i].t).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
|
||||
const tip = `NF: ${v.toFixed(1)} dBm\n${ts}`;
|
||||
|
||||
svg += `<rect class="nf-bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${colW}" height="${Math.max(0, barH).toFixed(1)}" fill="${color}" rx="0.5"><title>${tip}</title></rect>`;
|
||||
}
|
||||
|
||||
// Y-axis label
|
||||
svg += `<text x="12" y="${(h / 2)}" text-anchor="middle" font-size="10" fill="var(--text-muted)" transform="rotate(-90,12,${h/2})">dBm</text>`;
|
||||
|
||||
// Legend
|
||||
const legendY = pad.top + 2;
|
||||
const legendX = w - pad.right - 140;
|
||||
svg += `<rect x="${legendX}" y="${legendY}" width="8" height="8" fill="var(--success, #22c55e)" rx="1"/>`;
|
||||
svg += `<text x="${legendX + 11}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">< -100</text>`;
|
||||
svg += `<rect x="${legendX + 48}" y="${legendY}" width="8" height="8" fill="var(--warning, #eab308)" rx="1"/>`;
|
||||
svg += `<text x="${legendX + 59}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">-100…-85</text>`;
|
||||
svg += `<rect x="${legendX + 105}" y="${legendY}" width="8" height="8" fill="var(--danger, #ef4444)" rx="1"/>`;
|
||||
svg += `<text x="${legendX + 116}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">≥ -85</text>`;
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,46 @@ function timeAgo(iso) {
|
||||
return value + suffix + ' ago';
|
||||
}
|
||||
|
||||
function getHashParams() {
|
||||
return new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
}
|
||||
|
||||
function getDistanceUnit() {
|
||||
var stored = localStorage.getItem('meshcore-distance-unit');
|
||||
if (stored === 'km') return 'km';
|
||||
if (stored === 'mi') return 'mi';
|
||||
// 'auto' or no value — locale detection
|
||||
var milesLocales = ['en-us', 'en-gb'];
|
||||
var lang = (typeof navigator !== 'undefined' && navigator.language || '').toLowerCase();
|
||||
for (var i = 0; i < milesLocales.length; i++) {
|
||||
if (lang === milesLocales[i] || lang.startsWith(milesLocales[i] + '-')) return 'mi';
|
||||
}
|
||||
return 'km';
|
||||
}
|
||||
window.getDistanceUnit = getDistanceUnit;
|
||||
|
||||
function formatDistance(km) {
|
||||
if (km == null || isNaN(+km)) return '—';
|
||||
var d = +km;
|
||||
var unit = getDistanceUnit();
|
||||
if (unit === 'mi') {
|
||||
var mi = d / 1.60934;
|
||||
if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
|
||||
return mi.toFixed(1) + ' mi';
|
||||
}
|
||||
if (d < 1) return Math.round(d * 1000) + ' m';
|
||||
return d.toFixed(1) + ' km';
|
||||
}
|
||||
window.formatDistance = formatDistance;
|
||||
|
||||
function formatDistanceRound(km) {
|
||||
if (km == null || isNaN(+km)) return '—';
|
||||
var unit = getDistanceUnit();
|
||||
if (unit === 'mi') return Math.round(+km / 1.60934) + ' mi';
|
||||
return Math.round(+km) + ' km';
|
||||
}
|
||||
window.formatDistanceRound = formatDistanceRound;
|
||||
|
||||
function getTimestampMode() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-mode');
|
||||
if (saved === 'ago' || saved === 'absolute') return saved;
|
||||
|
||||
+62
-133
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Channel Color Quick-Assign Popover (M2, #271)
|
||||
* Channel Color Picker — Simplified popover with 8-color constrained palette (#674)
|
||||
*
|
||||
* Right-click (or long-press on mobile) a channel name in the live feed
|
||||
* or packets table to open a color picker popover.
|
||||
* Click a color dot next to channel names (channels page, live feed) to open picker.
|
||||
* Right-click on live feed items retained as power-user shortcut (desktop only).
|
||||
* No long-press. No custom color input. 8 preset colors.
|
||||
*
|
||||
* Uses ChannelColors.set/get/remove from channel-colors.js (M1).
|
||||
* Uses ChannelColors.set/get/remove from channel-colors.js.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Curated maximally-distinct palette (10 swatches, ColorBrewer-inspired)
|
||||
var PRESET_COLORS = [
|
||||
// 8 maximally-distinct colors on dark backgrounds (#674 Tufte spec)
|
||||
var CHANNEL_PALETTE = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
@@ -18,14 +19,11 @@
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#14b8a6', // teal
|
||||
'#f43f5e' // rose
|
||||
'#ec4899' // pink
|
||||
];
|
||||
|
||||
var popoverEl = null;
|
||||
var currentChannel = null;
|
||||
var longPressTimer = null;
|
||||
|
||||
function createPopover() {
|
||||
if (popoverEl) return popoverEl;
|
||||
@@ -35,27 +33,19 @@
|
||||
el.setAttribute('aria-label', 'Channel color picker');
|
||||
el.style.display = 'none';
|
||||
el.innerHTML =
|
||||
'<div class="cc-picker-header">' +
|
||||
'<span class="cc-picker-title" id="cc-picker-title"></span>' +
|
||||
'<button class="cc-picker-close" title="Close" aria-label="Close">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="cc-picker-swatches" role="group" aria-label="Color swatches"></div>' +
|
||||
'<div class="cc-picker-custom">' +
|
||||
'<label>Custom: <input type="color" class="cc-picker-input" value="#3b82f6" aria-label="Custom color"></label>' +
|
||||
'<button class="cc-picker-apply">Apply</button>' +
|
||||
'</div>' +
|
||||
'<button class="cc-picker-clear">Clear color</button>';
|
||||
el.setAttribute('aria-labelledby', 'cc-picker-title');
|
||||
|
||||
// Build swatches
|
||||
var swatchContainer = el.querySelector('.cc-picker-swatches');
|
||||
for (var i = 0; i < PRESET_COLORS.length; i++) {
|
||||
for (var i = 0; i < CHANNEL_PALETTE.length; i++) {
|
||||
var sw = document.createElement('button');
|
||||
sw.className = 'cc-swatch';
|
||||
sw.style.background = PRESET_COLORS[i];
|
||||
sw.setAttribute('data-color', PRESET_COLORS[i]);
|
||||
sw.setAttribute('aria-label', PRESET_COLORS[i]);
|
||||
sw.title = PRESET_COLORS[i];
|
||||
sw.style.background = CHANNEL_PALETTE[i];
|
||||
sw.setAttribute('data-color', CHANNEL_PALETTE[i]);
|
||||
sw.setAttribute('aria-label', CHANNEL_PALETTE[i]);
|
||||
sw.title = CHANNEL_PALETTE[i];
|
||||
sw.setAttribute('tabindex', '0');
|
||||
swatchContainer.appendChild(sw);
|
||||
}
|
||||
|
||||
@@ -66,7 +56,7 @@
|
||||
assignColor(btn.getAttribute('data-color'));
|
||||
});
|
||||
|
||||
// Keyboard navigation for swatches (arrow keys)
|
||||
// Keyboard navigation for swatches
|
||||
swatchContainer.addEventListener('keydown', function(e) {
|
||||
var btn = e.target.closest('.cc-swatch');
|
||||
if (!btn) return;
|
||||
@@ -80,12 +70,6 @@
|
||||
if (next >= 0) { swatches[next].focus(); e.preventDefault(); }
|
||||
});
|
||||
|
||||
// Event: custom apply
|
||||
el.querySelector('.cc-picker-apply').addEventListener('click', function() {
|
||||
var input = el.querySelector('.cc-picker-input');
|
||||
assignColor(input.value);
|
||||
});
|
||||
|
||||
// Event: clear
|
||||
el.querySelector('.cc-picker-clear').addEventListener('click', function() {
|
||||
if (currentChannel && window.ChannelColors) {
|
||||
@@ -95,11 +79,6 @@
|
||||
hidePopover();
|
||||
});
|
||||
|
||||
// Event: close button
|
||||
el.querySelector('.cc-picker-close').addEventListener('click', function() {
|
||||
hidePopover();
|
||||
});
|
||||
|
||||
// Prevent right-click on the popover itself
|
||||
el.addEventListener('contextmenu', function(e) { e.preventDefault(); });
|
||||
|
||||
@@ -120,23 +99,17 @@
|
||||
var el = createPopover();
|
||||
currentChannel = channel;
|
||||
|
||||
// Update title
|
||||
el.querySelector('.cc-picker-title').textContent = channel;
|
||||
|
||||
// Highlight current color
|
||||
var current = window.ChannelColors ? window.ChannelColors.get(channel) : null;
|
||||
var swatches = el.querySelectorAll('.cc-swatch');
|
||||
for (var i = 0; i < swatches.length; i++) {
|
||||
swatches[i].classList.toggle('cc-swatch-active', swatches[i].getAttribute('data-color') === current);
|
||||
}
|
||||
if (current) {
|
||||
el.querySelector('.cc-picker-input').value = current;
|
||||
}
|
||||
|
||||
// Show/hide clear button
|
||||
el.querySelector('.cc-picker-clear').style.display = current ? '' : 'none';
|
||||
|
||||
// Position — on touch devices, CSS handles bottom-sheet via @media(pointer:coarse)
|
||||
// Position
|
||||
el.style.display = '';
|
||||
var isTouch = window.matchMedia('(pointer: coarse)').matches;
|
||||
if (!isTouch) {
|
||||
@@ -188,7 +161,7 @@
|
||||
}
|
||||
// Trap Tab within the popover
|
||||
if (e.key === 'Tab' && popoverEl && popoverEl.style.display !== 'none') {
|
||||
var focusable = popoverEl.querySelectorAll('button, input, [tabindex]');
|
||||
var focusable = popoverEl.querySelectorAll('button, [tabindex]');
|
||||
if (focusable.length === 0) return;
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
@@ -200,7 +173,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh channel color styles on all visible feed items and packet rows. */
|
||||
/** Refresh channel color styles on all visible feed items, channel list, and packet rows. */
|
||||
function refreshVisibleRows() {
|
||||
if (!window.ChannelColors) return;
|
||||
|
||||
@@ -210,11 +183,28 @@
|
||||
var item = feedItems[i];
|
||||
var ch = item._ccChannel;
|
||||
if (!ch) continue;
|
||||
var style = window.ChannelColors.getRowStyle('GRP_TXT', ch);
|
||||
// Remove old channel color styles, reapply
|
||||
item.style.borderLeft = '';
|
||||
item.style.background = '';
|
||||
if (style) item.style.cssText += style;
|
||||
var color = window.ChannelColors.get(ch);
|
||||
item.style.borderLeft = color ? '3px solid ' + color : '';
|
||||
}
|
||||
|
||||
// Update color dots everywhere
|
||||
var dots = document.querySelectorAll('.ch-color-dot');
|
||||
for (var j = 0; j < dots.length; j++) {
|
||||
var dot = dots[j];
|
||||
var dotCh = dot.getAttribute('data-channel');
|
||||
if (!dotCh) continue;
|
||||
var dotColor = window.ChannelColors.get(dotCh);
|
||||
dot.style.background = dotColor || '';
|
||||
}
|
||||
|
||||
// Channel list items — update border
|
||||
var chItems = document.querySelectorAll('.ch-item[data-hash]');
|
||||
for (var k = 0; k < chItems.length; k++) {
|
||||
var chItem = chItems[k];
|
||||
var hash = chItem.getAttribute('data-hash');
|
||||
if (!hash) continue;
|
||||
var chColor = window.ChannelColors.get(hash);
|
||||
chItem.style.borderLeft = chColor ? '3px solid ' + chColor : '';
|
||||
}
|
||||
|
||||
// Packets table — trigger re-render via custom event
|
||||
@@ -222,75 +212,27 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract channel name from a packet object.
|
||||
* Returns null if no channel found or not a GRP_TXT/CHAN type.
|
||||
*/
|
||||
function extractChannel(pkt) {
|
||||
if (!pkt) return null;
|
||||
var d = pkt.decoded || {};
|
||||
var h = d.header || {};
|
||||
var p = d.payload || {};
|
||||
var type = h.payloadTypeName || '';
|
||||
if (type !== 'GRP_TXT' && type !== 'CHAN') return null;
|
||||
return p.channelName || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract channel from a packets-table decoded_json.
|
||||
*/
|
||||
function extractChannelFromDecoded(decoded) {
|
||||
if (!decoded) return null;
|
||||
var type = decoded.type || '';
|
||||
if (type !== 'GRP_TXT' && type !== 'CHAN') return null;
|
||||
return decoded.channel || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install context-menu (right-click) and long-press handlers on the live feed.
|
||||
* Install context-menu (right-click) handler on the live feed.
|
||||
* No long-press — color dots handle mobile interaction.
|
||||
*/
|
||||
function installLiveFeedHandlers() {
|
||||
var feed = document.getElementById('liveFeed');
|
||||
if (!feed) return;
|
||||
|
||||
// Click on color dot opens picker (#674)
|
||||
feed.addEventListener('click', function(e) {
|
||||
var dot = e.target.closest('.feed-color-dot');
|
||||
if (!dot) return;
|
||||
e.stopPropagation();
|
||||
var ch = dot.getAttribute('data-channel');
|
||||
if (ch) showPopover(ch, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
feed.addEventListener('contextmenu', function(e) {
|
||||
var item = e.target.closest('.live-feed-item');
|
||||
if (!item || !item._ccChannel) return;
|
||||
var ch = item._ccChannel;
|
||||
e.preventDefault();
|
||||
showPopover(ch, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Long-press for mobile
|
||||
var longPressTriggered = false;
|
||||
feed.addEventListener('touchstart', function(e) {
|
||||
var item = e.target.closest('.live-feed-item');
|
||||
if (!item || !item._ccChannel) return;
|
||||
var ch = item._ccChannel;
|
||||
if (!ch) return;
|
||||
var touch = e.touches[0];
|
||||
var tx = touch.clientX;
|
||||
var ty = touch.clientY;
|
||||
longPressTriggered = false;
|
||||
// Don't preventDefault here — it blocks scroll initiation on feed items.
|
||||
// CSS -webkit-touch-callout:none + user-select:none (on .live-feed-item)
|
||||
// already suppress native context menu and text selection.
|
||||
longPressTimer = setTimeout(function() {
|
||||
longPressTimer = null;
|
||||
longPressTriggered = true;
|
||||
showPopover(ch, tx, ty);
|
||||
}, 500);
|
||||
}, { passive: true });
|
||||
|
||||
feed.addEventListener('touchend', function(e) {
|
||||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||||
if (longPressTriggered) { e.preventDefault(); longPressTriggered = false; }
|
||||
});
|
||||
feed.addEventListener('touchmove', function() {
|
||||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||||
});
|
||||
// Prevent context menu on long-press (some browsers fire contextmenu after touch)
|
||||
feed.addEventListener('contextmenu', function(e) {
|
||||
if (longPressTriggered) e.preventDefault();
|
||||
showPopover(item._ccChannel, e.clientX, e.clientY);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,33 +246,19 @@
|
||||
table.addEventListener('contextmenu', function(e) {
|
||||
var row = e.target.closest('tr');
|
||||
if (!row) return;
|
||||
// Try to get decoded data from the row's data attribute
|
||||
var decodedStr = row.getAttribute('data-decoded');
|
||||
var decoded = null;
|
||||
if (decodedStr) {
|
||||
try { decoded = JSON.parse(decodedStr); } catch(ex) {}
|
||||
}
|
||||
// Fallback: check if the row has a chan-tag
|
||||
if (!decoded) {
|
||||
var chanTag = row.querySelector('.chan-tag');
|
||||
if (chanTag) {
|
||||
var ch = chanTag.textContent.trim();
|
||||
if (ch) {
|
||||
e.preventDefault();
|
||||
showPopover(ch, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
var chanTag = row.querySelector('.chan-tag');
|
||||
if (chanTag) {
|
||||
var ch = chanTag.textContent.trim();
|
||||
if (ch) {
|
||||
e.preventDefault();
|
||||
showPopover(ch, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
var ch = extractChannelFromDecoded(decoded);
|
||||
if (!ch) return;
|
||||
e.preventDefault();
|
||||
showPopover(ch, e.clientX, e.clientY);
|
||||
});
|
||||
}
|
||||
|
||||
// Export for use by live.js feed item creation
|
||||
// Export
|
||||
window.ChannelColorPicker = {
|
||||
install: function() {
|
||||
installLiveFeedHandlers();
|
||||
@@ -339,6 +267,7 @@
|
||||
installLiveFeed: installLiveFeedHandlers,
|
||||
installPacketsTable: installPacketsTableHandlers,
|
||||
show: showPopover,
|
||||
hide: hidePopover
|
||||
hide: hidePopover,
|
||||
PALETTE: CHANNEL_PALETTE
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
if (!channel) return '';
|
||||
var color = getChannelColor(channel);
|
||||
if (!color) return '';
|
||||
// 4px left border + 10% opacity background tint
|
||||
return 'border-left:4px solid ' + color + ';background:' + color + '1a;';
|
||||
// 3px left border only — minimal Tufte-style encoding (#674)
|
||||
return 'border-left:3px solid ' + color + ';';
|
||||
}
|
||||
|
||||
// Export to window for use by live.js and packets.js
|
||||
|
||||
+26
-3
@@ -171,8 +171,11 @@
|
||||
async function showNodeDetail(name) {
|
||||
_nodePanelTrigger = document.activeElement;
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
var _capturedHash = selectedHash;
|
||||
const node = await lookupNode(name);
|
||||
selectedNode = name;
|
||||
var _chBase = _capturedHash ? '#/channels/' + encodeURIComponent(_capturedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
|
||||
|
||||
let panel = document.getElementById('chNodePanel');
|
||||
if (!panel) {
|
||||
@@ -234,6 +237,8 @@
|
||||
const panel = document.getElementById('chNodePanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
selectedNode = null;
|
||||
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
|
||||
history.replaceState(null, '', _chRestoreUrl);
|
||||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||||
_nodePanelTrigger.focus();
|
||||
_nodePanelTrigger = null;
|
||||
@@ -314,6 +319,9 @@
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app, routeParam) {
|
||||
var _initUrlParams = getHashParams();
|
||||
var _pendingNode = _initUrlParams.get('node');
|
||||
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
@@ -347,8 +355,9 @@
|
||||
});
|
||||
|
||||
loadObserverRegions();
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
loadChannels().then(async function () {
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||||
});
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
@@ -394,6 +403,14 @@
|
||||
|
||||
// Event delegation for channel selection (touch-friendly)
|
||||
document.getElementById('chList').addEventListener('click', (e) => {
|
||||
// Color dot click — open picker, don't select channel
|
||||
const dot = e.target.closest('.ch-color-dot');
|
||||
if (dot && window.ChannelColorPicker) {
|
||||
e.stopPropagation();
|
||||
var ch = dot.getAttribute('data-channel');
|
||||
if (ch) ChannelColorPicker.show(ch, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest('.ch-item[data-hash]');
|
||||
if (item) selectChannel(item.dataset.hash);
|
||||
});
|
||||
@@ -670,12 +687,18 @@
|
||||
: `${ch.messageCount} messages`;
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
|
||||
// Channel color dot for color picker (#674)
|
||||
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
|
||||
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
|
||||
// Left border for assigned color
|
||||
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
|
||||
|
||||
return `<button class="ch-item${sel}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
return `<button class="ch-item${sel}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>
|
||||
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>
|
||||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>
|
||||
</div>
|
||||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||||
|
||||
+30
-8
@@ -33,9 +33,10 @@
|
||||
'meshcore-live-heatmap-opacity'
|
||||
];
|
||||
|
||||
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity'];
|
||||
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit'];
|
||||
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps'];
|
||||
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
|
||||
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
|
||||
|
||||
// CSS variable mapping (theme key → CSS custom property)
|
||||
var THEME_CSS_MAP = {
|
||||
@@ -503,6 +504,11 @@
|
||||
localStorage.setItem('meshcore-live-heatmap-opacity', effectiveConfig.liveHeatmapOpacity);
|
||||
}
|
||||
|
||||
// Distance unit → sync to localStorage for all pages
|
||||
if (typeof effectiveConfig.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(effectiveConfig.distanceUnit) >= 0) {
|
||||
localStorage.setItem('meshcore-distance-unit', effectiveConfig.distanceUnit);
|
||||
}
|
||||
|
||||
// Nav gradient
|
||||
if (themeSection.navBg) {
|
||||
var nav = document.querySelector('.top-nav');
|
||||
@@ -744,6 +750,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate distanceUnit
|
||||
if (key === 'distanceUnit' && DISTANCE_UNIT_VALUES.indexOf(obj[key]) === -1) {
|
||||
errors.push('Invalid distanceUnit: "' + obj[key] + '" — must be km, mi, or auto');
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors: errors };
|
||||
}
|
||||
@@ -895,7 +905,7 @@
|
||||
{ id: 'theme', label: '🎨', title: 'Theme', badge: _tabBadge(isDarkMode() ? 'themeDark' : 'theme') },
|
||||
{ id: 'nodes', label: '🎯', title: 'Colors', badge: (function () { var n = _countOverrides('nodeColors') + _countOverrides('typeColors'); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
|
||||
{ id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') },
|
||||
{ id: 'display', label: '🖥️', title: 'Display', badge: _tabBadge('timestamps') },
|
||||
{ id: 'display', label: '🖥️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
|
||||
{ id: 'export', label: '📤', title: 'Export' }
|
||||
];
|
||||
return '<div class="cust-tabs">' + tabs.map(function (t) {
|
||||
@@ -1059,6 +1069,7 @@
|
||||
|
||||
function _renderDisplay() {
|
||||
var eff = _getEffective();
|
||||
var distUnit = typeof eff.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(eff.distanceUnit) >= 0 ? eff.distanceUnit : 'auto';
|
||||
var ts = (eff.timestamps) || {};
|
||||
var tsMode = ts.defaultMode === 'absolute' ? 'absolute' : 'ago';
|
||||
var tsTz = ts.timezone === 'utc' ? 'utc' : 'local';
|
||||
@@ -1086,6 +1097,13 @@
|
||||
'<option value="locale"' + (tsFmt === 'locale' ? ' selected' : '') + '>Locale (browser)</option></select></div>' +
|
||||
(canCustom ? '<div class="cust-field" data-ts-abs="custom"' + showAbs + '><label>Custom Format' + _overrideDot('timestamps', 'customFormat') + '</label>' +
|
||||
'<input type="text" data-cv2-field="timestamps.customFormat" value="' + escAttr(customFmt) + '" placeholder="YYYY-MM-DD HH:mm:ss"></div>' : '') +
|
||||
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Distances</p>' +
|
||||
'<div class="cust-field"><label>Distance Unit' + _overrideDot(null, 'distanceUnit') + '</label>' +
|
||||
'<select data-cv2-select="distanceUnit" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
|
||||
'<option value="auto"' + (distUnit === 'auto' ? ' selected' : '') + '>Auto (browser locale)</option>' +
|
||||
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
|
||||
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -1324,12 +1342,16 @@
|
||||
container.querySelectorAll('[data-cv2-select]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var parts = sel.dataset.cv2Select.split('.');
|
||||
setOverride(parts[0], parts[1], sel.value);
|
||||
// Show/hide absolute-only fields
|
||||
if (parts[1] === 'defaultMode') {
|
||||
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
|
||||
el.style.display = sel.value === 'absolute' ? '' : 'none';
|
||||
});
|
||||
if (parts.length === 1) {
|
||||
setOverride(null, parts[0], sel.value);
|
||||
} else {
|
||||
setOverride(parts[0], parts[1], sel.value);
|
||||
// Show/hide absolute-only fields
|
||||
if (parts[1] === 'defaultMode') {
|
||||
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
|
||||
el.style.display = sel.value === 'absolute' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/* drag-manager.js — Free-form panel dragging (#608 M1)
|
||||
* State machine: IDLE → PENDING → DRAGGING → IDLE
|
||||
* Pointer events on .panel-header, transform: translate() during drag,
|
||||
* snap-to-edge on release, z-index on focus, viewport % persistence.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DEAD_ZONE = 5; // px — disambiguate click vs drag
|
||||
var SNAP_THRESHOLD = 20; // px — snap to edge on release
|
||||
var SNAP_MARGIN = 12; // px — margin when snapped
|
||||
|
||||
function DragManager() {
|
||||
this.state = 'IDLE';
|
||||
this.activePanel = null;
|
||||
this.startX = 0;
|
||||
this.startY = 0;
|
||||
this.panelStartX = 0;
|
||||
this.panelStartY = 0;
|
||||
this.preTransform = '';
|
||||
this.enabled = true;
|
||||
this.zCounter = 1000;
|
||||
this._panels = [];
|
||||
this._onKeyDown = this._handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
DragManager.prototype.register = function (panel) {
|
||||
if (!panel) return;
|
||||
var header = panel.querySelector('.panel-header');
|
||||
if (!header) return;
|
||||
this._panels.push(panel);
|
||||
var self = this;
|
||||
|
||||
header.addEventListener('pointerdown', function (e) {
|
||||
if (!self.enabled) return;
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
header.setPointerCapture(e.pointerId);
|
||||
|
||||
self.state = 'PENDING';
|
||||
self.activePanel = panel;
|
||||
self.startX = e.clientX;
|
||||
self.startY = e.clientY;
|
||||
|
||||
var rect = panel.getBoundingClientRect();
|
||||
self.panelStartX = rect.left;
|
||||
self.panelStartY = rect.top;
|
||||
self.preTransform = panel.style.transform || '';
|
||||
document.addEventListener('keydown', self._onKeyDown);
|
||||
});
|
||||
|
||||
header.addEventListener('pointermove', function (e) {
|
||||
if (self.state === 'IDLE') return;
|
||||
if (self.activePanel !== panel) return;
|
||||
var dx = e.clientX - self.startX;
|
||||
var dy = e.clientY - self.startY;
|
||||
|
||||
if (self.state === 'PENDING') {
|
||||
if (Math.hypot(dx, dy) < DEAD_ZONE) return;
|
||||
self.state = 'DRAGGING';
|
||||
panel.classList.add('is-dragging');
|
||||
panel.style.zIndex = ++self.zCounter;
|
||||
self._detachFromCorner(panel);
|
||||
}
|
||||
|
||||
panel.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
|
||||
});
|
||||
|
||||
header.addEventListener('pointerup', function (e) {
|
||||
if (self.activePanel !== panel) return;
|
||||
header.releasePointerCapture(e.pointerId);
|
||||
if (self.state === 'DRAGGING') {
|
||||
panel.classList.remove('is-dragging');
|
||||
self._finalizePosition(panel);
|
||||
}
|
||||
self._reset();
|
||||
});
|
||||
|
||||
header.addEventListener('pointercancel', function () {
|
||||
if (self.activePanel !== panel) return;
|
||||
panel.classList.remove('is-dragging');
|
||||
if (self.state === 'DRAGGING') {
|
||||
self._finalizePosition(panel);
|
||||
}
|
||||
self._reset();
|
||||
});
|
||||
};
|
||||
|
||||
DragManager.prototype._handleKeyDown = function (e) {
|
||||
if (e.key === 'Escape' && this.state === 'DRAGGING' && this.activePanel) {
|
||||
this.activePanel.classList.remove('is-dragging');
|
||||
this.activePanel.style.transform = this.preTransform;
|
||||
// Revert: re-attach to corner if it was cornered before
|
||||
var saved = localStorage.getItem('panel-drag-' + this.activePanel.id);
|
||||
if (!saved) {
|
||||
// Was in corner mode — restore corner CSS
|
||||
delete this.activePanel.dataset.dragged;
|
||||
this.activePanel.style.top = '';
|
||||
this.activePanel.style.left = '';
|
||||
this.activePanel.style.right = '';
|
||||
this.activePanel.style.bottom = '';
|
||||
this.activePanel.style.transform = '';
|
||||
// Re-apply corner position from M0
|
||||
var corner = localStorage.getItem('panel-corner-' + this.activePanel.id);
|
||||
if (corner) this.activePanel.setAttribute('data-position', corner);
|
||||
} else {
|
||||
// Was already dragged — revert to pre-drag position
|
||||
this.activePanel.style.transform = 'none';
|
||||
}
|
||||
this._reset();
|
||||
}
|
||||
};
|
||||
|
||||
DragManager.prototype._reset = function () {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
this.state = 'IDLE';
|
||||
this.activePanel = null;
|
||||
};
|
||||
|
||||
DragManager.prototype._detachFromCorner = function (panel) {
|
||||
var rect = panel.getBoundingClientRect();
|
||||
panel.removeAttribute('data-position');
|
||||
panel.dataset.dragged = 'true';
|
||||
panel.style.position = 'fixed';
|
||||
panel.style.top = rect.top + 'px';
|
||||
panel.style.left = rect.left + 'px';
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.transform = 'none';
|
||||
};
|
||||
|
||||
DragManager.prototype._finalizePosition = function (panel) {
|
||||
var rect = panel.getBoundingClientRect();
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
|
||||
var x = Math.max(0, Math.min(rect.left, vw - 40));
|
||||
var y = Math.max(0, Math.min(rect.top, vh - 40));
|
||||
|
||||
// Snap to edge
|
||||
if (x < SNAP_THRESHOLD) x = SNAP_MARGIN;
|
||||
if (y < SNAP_THRESHOLD) y = SNAP_MARGIN;
|
||||
if (x + rect.width > vw - SNAP_THRESHOLD) x = vw - rect.width - SNAP_MARGIN;
|
||||
if (y + rect.height > vh - SNAP_THRESHOLD) y = vh - rect.height - SNAP_MARGIN;
|
||||
|
||||
panel.style.top = y + 'px';
|
||||
panel.style.left = x + 'px';
|
||||
panel.style.transform = 'none';
|
||||
|
||||
this._persist(panel.id, x / vw, y / vh);
|
||||
};
|
||||
|
||||
DragManager.prototype._persist = function (id, xPct, yPct) {
|
||||
try {
|
||||
localStorage.setItem('panel-drag-' + id,
|
||||
JSON.stringify({ xPct: xPct, yPct: yPct }));
|
||||
} catch (_) { /* quota exceeded — silent */ }
|
||||
};
|
||||
|
||||
DragManager.prototype.enable = function () { this.enabled = true; };
|
||||
DragManager.prototype.disable = function () {
|
||||
this.enabled = false;
|
||||
if (this.state !== 'IDLE' && this.activePanel) {
|
||||
this.activePanel.classList.remove('is-dragging');
|
||||
this._reset();
|
||||
}
|
||||
};
|
||||
|
||||
DragManager.prototype.restorePositions = function () {
|
||||
var panels = this._panels;
|
||||
for (var i = 0; i < panels.length; i++) {
|
||||
var panel = panels[i];
|
||||
var raw = localStorage.getItem('panel-drag-' + panel.id);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
var pos = JSON.parse(raw);
|
||||
var x = pos.xPct * window.innerWidth;
|
||||
var y = pos.yPct * window.innerHeight;
|
||||
panel.removeAttribute('data-position');
|
||||
panel.dataset.dragged = 'true';
|
||||
panel.style.position = 'fixed';
|
||||
panel.style.top = y + 'px';
|
||||
panel.style.left = x + 'px';
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.transform = 'none';
|
||||
} catch (_) {
|
||||
localStorage.removeItem('panel-drag-' + panel.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
DragManager.prototype.handleResize = function () {
|
||||
var panels = document.querySelectorAll('.live-overlay[data-dragged="true"]');
|
||||
for (var i = 0; i < panels.length; i++) {
|
||||
var panel = panels[i];
|
||||
var rect = panel.getBoundingClientRect();
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
var x = rect.left, y = rect.top, moved = false;
|
||||
if (rect.right > vw) { x = vw - rect.width - SNAP_MARGIN; moved = true; }
|
||||
if (rect.bottom > vh) { y = vh - rect.height - SNAP_MARGIN; moved = true; }
|
||||
if (x < 0) { x = SNAP_MARGIN; moved = true; }
|
||||
if (y < 0) { y = SNAP_MARGIN; moved = true; }
|
||||
if (moved) {
|
||||
panel.style.left = x + 'px';
|
||||
panel.style.top = y + 'px';
|
||||
this._persist(panel.id, x / vw, y / vh);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Export
|
||||
window.DragManager = DragManager;
|
||||
})();
|
||||
+8
-3
@@ -302,14 +302,19 @@
|
||||
<button class="mnc-btn" data-action="packets" data-key="${mn.pubkey}">View packets →</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const is404 = err && err.message && err.message.includes('404');
|
||||
const statusIcon = is404 ? '📡' : '❓';
|
||||
const statusMsg = is404
|
||||
? 'Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet'
|
||||
: 'Could not load data';
|
||||
return `<div class="my-node-card silent" data-key="${mn.pubkey}" tabindex="0" role="button">
|
||||
<div class="mnc-header">
|
||||
<div class="mnc-status">❓</div>
|
||||
<div class="mnc-status">${statusIcon}</div>
|
||||
<div class="mnc-name">${escapeHtml(mn.name || truncate(mn.pubkey, 12))}</div>
|
||||
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove" aria-label="Remove ${escapeAttr(mn.name || truncate(mn.pubkey, 12))} from My Mesh">✕</button>
|
||||
</div>
|
||||
<div class="mnc-status-text">Could not load data</div>
|
||||
<div class="mnc-status-text">${statusMsg}</div>
|
||||
</div>`;
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="drag-manager.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
+116
-8
@@ -19,6 +19,36 @@
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Panel header (non-scrolling) ---- */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* ---- Panel content (scrollable) ---- */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-feed .panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.live-legend .panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* ---- Header / Stats ---- */
|
||||
@@ -106,7 +136,6 @@
|
||||
right: 12px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 10px;
|
||||
@@ -126,16 +155,12 @@
|
||||
left: 12px;
|
||||
width: 360px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.live-feed-item {
|
||||
@@ -198,9 +223,6 @@
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
@@ -778,3 +800,89 @@
|
||||
}
|
||||
.nav-pin-btn:hover { opacity: 0.8; }
|
||||
.nav-pin-btn.pinned { opacity: 1; filter: drop-shadow(0 0 4px rgba(59,130,246,0.5)); }
|
||||
|
||||
/* ========== Panel Corner Positioning (#608 M0) ========== */
|
||||
/* Corner positions — applied via data-position attribute on .live-overlay panels */
|
||||
.live-overlay[data-position="tl"] { top: 64px; left: 12px; bottom: auto; right: auto; }
|
||||
.live-overlay[data-position="tr"] { top: 64px; right: 12px; bottom: auto; left: auto; }
|
||||
.live-overlay[data-position="bl"] { bottom: 58px; left: 12px; top: auto; right: auto; }
|
||||
.live-overlay[data-position="br"] { bottom: 58px; right: 12px; top: auto; left: auto; }
|
||||
|
||||
/* Override hide animations for positioned panels — slide toward nearest edge */
|
||||
.live-overlay[data-position="tl"].hidden,
|
||||
.live-overlay[data-position="bl"].hidden { transform: translateX(-100%); }
|
||||
.live-overlay[data-position="tr"].hidden,
|
||||
.live-overlay[data-position="br"].hidden { transform: translateX(100%); }
|
||||
.live-overlay[data-position].hidden { opacity: 0; pointer-events: none; visibility: hidden; }
|
||||
|
||||
/* Corner toggle button */
|
||||
.panel-corner-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.panel-corner-btn:hover { opacity: 1; background: color-mix(in srgb, var(--text) 12%, transparent); }
|
||||
.panel-corner-btn:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* On mobile, corner toggle is not useful (panels are hidden or bottom-sheet) */
|
||||
@media (max-width: 640px) {
|
||||
.panel-corner-btn { display: none !important; }
|
||||
.live-overlay[data-position] {
|
||||
top: unset !important; bottom: unset !important;
|
||||
left: unset !important; right: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── Drag Manager (#608 M1) ── */
|
||||
|
||||
/* Panel header as drag handle — desktop pointer devices only */
|
||||
@media (pointer: fine) {
|
||||
.live-overlay .panel-header {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.live-overlay.is-dragging .panel-header {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.live-overlay .panel-header:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
}
|
||||
|
||||
/* Panel during drag */
|
||||
.live-overlay.is-dragging {
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
will-change: transform;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Freely placed panel — no corner transition animations */
|
||||
.live-overlay[data-dragged="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-overlay.is-dragging,
|
||||
.live-overlay[data-dragged="true"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+262
-27
@@ -30,6 +30,7 @@
|
||||
let _lcdClockInterval = null;
|
||||
let _rateCounterInterval = null;
|
||||
let _pruneInterval = null;
|
||||
let _feedTimestampInterval = null;
|
||||
let activeNodeDetailKey = null;
|
||||
|
||||
// === VCR State Machine ===
|
||||
@@ -58,6 +59,92 @@
|
||||
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
|
||||
};
|
||||
|
||||
/* ---- Panel Corner Positioning (#608 M0) ---- */
|
||||
var PANEL_DEFAULTS = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
|
||||
var CORNER_CYCLE = ['tl', 'tr', 'br', 'bl'];
|
||||
var CORNER_ARROWS = { tl: '↘', tr: '↙', bl: '↗', br: '↖' };
|
||||
var CORNER_LABELS = { tl: 'top-left', tr: 'top-right', bl: 'bottom-left', br: 'bottom-right' };
|
||||
var PANEL_NAMES = { liveFeed: 'Feed', liveLegend: 'Legend', liveNodeDetail: 'Node detail' };
|
||||
|
||||
function getPanelPositions() {
|
||||
var pos = {};
|
||||
for (var id in PANEL_DEFAULTS) {
|
||||
try { pos[id] = localStorage.getItem('panel-corner-' + id) || PANEL_DEFAULTS[id]; }
|
||||
catch (_) { pos[id] = PANEL_DEFAULTS[id]; }
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
function nextAvailableCorner(panelId, desired, allPositions) {
|
||||
var idx = CORNER_CYCLE.indexOf(desired);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var candidate = CORNER_CYCLE[(idx + i) % 4];
|
||||
var occupied = false;
|
||||
for (var otherId in allPositions) {
|
||||
if (otherId !== panelId && allPositions[otherId] === candidate) { occupied = true; break; }
|
||||
}
|
||||
if (!occupied) return candidate;
|
||||
}
|
||||
return desired; // all occupied (impossible with 3 panels, 4 corners)
|
||||
}
|
||||
|
||||
function applyPanelPosition(id, corner) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.setAttribute('data-position', corner);
|
||||
var btn = el.querySelector('.panel-corner-btn');
|
||||
if (btn) {
|
||||
btn.textContent = CORNER_ARROWS[corner];
|
||||
btn.setAttribute('aria-label',
|
||||
'Move ' + (PANEL_NAMES[id] || 'panel') + ' to next corner (currently ' + CORNER_LABELS[corner] + ')');
|
||||
}
|
||||
}
|
||||
|
||||
function initPanelPositions() {
|
||||
var positions = getPanelPositions();
|
||||
for (var id in positions) {
|
||||
applyPanelPosition(id, positions[id]);
|
||||
}
|
||||
// Wire up click handlers on corner buttons
|
||||
var btns = document.querySelectorAll('.panel-corner-btn[data-panel]');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var panelId = this.getAttribute('data-panel');
|
||||
onCornerClick(panelId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onCornerClick(panelId) {
|
||||
var positions = getPanelPositions();
|
||||
var current = positions[panelId];
|
||||
var nextIdx = (CORNER_CYCLE.indexOf(current) + 1) % 4;
|
||||
var next = nextAvailableCorner(panelId, CORNER_CYCLE[nextIdx], positions);
|
||||
try { localStorage.setItem('panel-corner-' + panelId, next); } catch (_) { /* quota */ }
|
||||
applyPanelPosition(panelId, next);
|
||||
// Announce for screen readers
|
||||
var announce = document.getElementById('panelPositionAnnounce');
|
||||
if (announce) announce.textContent = (PANEL_NAMES[panelId] || 'Panel') + ' moved to ' + CORNER_LABELS[next];
|
||||
}
|
||||
|
||||
function resetPanelPositions() {
|
||||
for (var id in PANEL_DEFAULTS) {
|
||||
try { localStorage.removeItem('panel-corner-' + id); } catch (_) { /* ignore */ }
|
||||
applyPanelPosition(id, PANEL_DEFAULTS[id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
window._panelCorner = {
|
||||
PANEL_DEFAULTS: PANEL_DEFAULTS, CORNER_CYCLE: CORNER_CYCLE,
|
||||
getPanelPositions: getPanelPositions, nextAvailableCorner: nextAvailableCorner,
|
||||
applyPanelPosition: applyPanelPosition, onCornerClick: onCornerClick,
|
||||
resetPanelPositions: resetPanelPositions
|
||||
};
|
||||
}
|
||||
|
||||
function formatLiveTimestampHtml(isoLike) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : '—');
|
||||
@@ -754,16 +841,27 @@
|
||||
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-overlay live-feed" id="liveFeed" aria-live="polite" aria-relevant="additions" role="log">
|
||||
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
||||
<div class="live-overlay live-feed" id="liveFeed">
|
||||
<div class="panel-header">
|
||||
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
|
||||
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
||||
</div>
|
||||
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log"></div>
|
||||
</div>
|
||||
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
|
||||
<div class="live-overlay live-node-detail hidden" id="liveNodeDetail">
|
||||
<button class="feed-hide-btn" id="nodeDetailClose" title="Close">✕</button>
|
||||
<div id="nodeDetailContent"></div>
|
||||
<div class="panel-header">
|
||||
<button class="panel-corner-btn" data-panel="liveNodeDetail" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
|
||||
<button class="feed-hide-btn" id="nodeDetailClose" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="panel-content" id="nodeDetailContent"></div>
|
||||
</div>
|
||||
<button class="legend-toggle-btn" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
|
||||
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
||||
<div class="panel-header">
|
||||
<button class="panel-corner-btn" data-panel="liveLegend" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
@@ -774,9 +872,11 @@
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list" id="roleLegendList"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VCR Bar -->
|
||||
<div class="sr-only" id="panelPositionAnnounce" aria-live="polite"></div>
|
||||
<div class="vcr-bar" id="vcrBar">
|
||||
<div class="vcr-controls">
|
||||
<button id="vcrRewindBtn" class="vcr-btn" title="Rewind" aria-label="Rewind">⏪</button>
|
||||
@@ -1060,6 +1160,50 @@
|
||||
}
|
||||
|
||||
// Populate role legend from shared roles.js
|
||||
// Initialize panel corner positions (#608 M0)
|
||||
initPanelPositions();
|
||||
|
||||
// Initialize DragManager for free-form panel dragging (#608 M1)
|
||||
if (window.DragManager) {
|
||||
var dragMgr = new DragManager();
|
||||
var dragPanels = ['liveFeed', 'liveLegend', 'liveNodeDetail'];
|
||||
for (var di = 0; di < dragPanels.length; di++) {
|
||||
dragMgr.register(document.getElementById(dragPanels[di]));
|
||||
}
|
||||
dragMgr.restorePositions();
|
||||
|
||||
// Responsive gate: disable drag below medium breakpoint or on touch
|
||||
var dragMql = window.matchMedia('(pointer: fine) and (min-width: 768px)');
|
||||
function onDragMediaChange(e) {
|
||||
if (!e.matches) {
|
||||
// Revert dragged panels to corner positions
|
||||
document.querySelectorAll('.live-overlay[data-dragged="true"]').forEach(function (p) {
|
||||
delete p.dataset.dragged;
|
||||
p.style.transform = '';
|
||||
p.style.top = '';
|
||||
p.style.left = '';
|
||||
p.style.right = '';
|
||||
p.style.bottom = '';
|
||||
});
|
||||
initPanelPositions();
|
||||
dragMgr.disable();
|
||||
} else {
|
||||
dragMgr.enable();
|
||||
dragMgr.restorePositions();
|
||||
}
|
||||
}
|
||||
dragMql.addEventListener('change', onDragMediaChange);
|
||||
// Initial check
|
||||
if (!dragMql.matches) dragMgr.disable();
|
||||
|
||||
// Resize clamping (debounced)
|
||||
var resizeTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function () { dragMgr.handleResize(); }, 200);
|
||||
});
|
||||
}
|
||||
|
||||
const roleLegendList = document.getElementById('roleLegendList');
|
||||
if (roleLegendList) {
|
||||
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
|
||||
@@ -1233,6 +1377,13 @@
|
||||
// Prune stale nodes every 60 seconds
|
||||
_pruneInterval = setInterval(pruneStaleNodes, 60000);
|
||||
|
||||
// Refresh relative timestamps in feed every 10 seconds (#701)
|
||||
_feedTimestampInterval = setInterval(function() {
|
||||
document.querySelectorAll('.feed-time[data-ts]').forEach(function(el) {
|
||||
el.innerHTML = formatLiveTimestampHtml(Number(el.dataset.ts));
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
// Auto-hide nav with pin toggle (#62)
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
|
||||
@@ -1502,7 +1653,9 @@
|
||||
function rebuildFeedList() {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (!feed) return;
|
||||
feed.querySelectorAll('.live-feed-item').forEach(el => el.remove());
|
||||
const feedContent = feed.querySelector('.panel-content');
|
||||
if (!feedContent) return;
|
||||
feedContent.querySelectorAll('.live-feed-item').forEach(el => el.remove());
|
||||
feedDedup.clear();
|
||||
|
||||
// Aggregate VCR buffer by hash, then create one feed item per unique hash
|
||||
@@ -1550,6 +1703,10 @@
|
||||
const hopStr = longestHops.length ? `<span class="feed-hops">${longestHops.length}⇢</span>` : '';
|
||||
const obsBadge = group.count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${group.count}</span>` : '';
|
||||
|
||||
var _ccPayload = (pkt.decoded || {}).payload || {};
|
||||
var _ccChan1 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload.channel || null) : null;
|
||||
var dotHtml1 = _ccChan1 ? _feedColorDot(_ccChan1) : '';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item';
|
||||
item.setAttribute('tabindex', '0');
|
||||
@@ -1559,13 +1716,13 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${dotHtml1}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
<span class="feed-time" data-ts="${group.latestTs || Date.now()}">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
`;
|
||||
var _ccD = (pkt.decoded || {}), _ccH = (_ccD.header || {}), _ccP = (_ccD.payload || {}); if (_ccH.payloadTypeName === 'GRP_TXT' || _ccH.payloadTypeName === 'CHAN') item._ccChannel = _ccP.channelName || null; // channel color picker (#271 M2)
|
||||
if (_ccChan1) item._ccChannel = _ccChan1; // channel color picker (#674)
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
feedContent.appendChild(item);
|
||||
|
||||
// Register in dedup map so replay and live updates work
|
||||
if (group.hash) {
|
||||
@@ -1899,11 +2056,66 @@
|
||||
}
|
||||
}
|
||||
firstPathDone = true;
|
||||
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
|
||||
// For TRACE packets, split at hopsCompleted: solid for completed, dashed for remaining
|
||||
var hopsCompleted = decoded.path && decoded.path.hopsCompleted;
|
||||
if (typeName === 'TRACE' && hopsCompleted != null && hopsCompleted < allPaths[ai].hopPositions.length) {
|
||||
var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1);
|
||||
var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted);
|
||||
if (completedPositions.length >= 2) {
|
||||
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop);
|
||||
} else if (completedPositions.length === 1) {
|
||||
pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName);
|
||||
}
|
||||
if (remainingPositions.length >= 2) {
|
||||
drawDashedPath(remainingPositions, color);
|
||||
}
|
||||
} else {
|
||||
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a static dashed/ghosted line for unreached TRACE hops
|
||||
function drawDashedPath(hopPositions, color) {
|
||||
var GHOST_TIMEOUT_MS = 10000;
|
||||
var ghostColor = getComputedStyle(document.documentElement).getPropertyValue('--trace-ghost-color').trim() || '#94a3b8';
|
||||
if (!pathsLayer) return;
|
||||
for (var i = 0; i < hopPositions.length - 1; i++) {
|
||||
var from = hopPositions[i].pos;
|
||||
var to = hopPositions[i + 1].pos;
|
||||
var line = L.polyline([from, to], {
|
||||
color: color, weight: 2, opacity: 0.25, dashArray: '6, 8'
|
||||
}).addTo(pathsLayer);
|
||||
// Pulse the unreached hop nodes as ghost markers
|
||||
if (i > 0) {
|
||||
var hp = hopPositions[i];
|
||||
if (!nodeMarkers[hp.key]) {
|
||||
var ghost = L.circleMarker(hp.pos, {
|
||||
radius: 3, fillColor: ghostColor, fillOpacity: 0.2, color: color, weight: 1, opacity: 0.3
|
||||
}).addTo(pathsLayer);
|
||||
setTimeout((function(g) { return function() { if (pathsLayer.hasLayer(g)) pathsLayer.removeLayer(g); }; })(ghost), GHOST_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
// Remove dashed line after timeout
|
||||
setTimeout((function(l) { return function() { if (pathsLayer.hasLayer(l)) pathsLayer.removeLayer(l); }; })(line), GHOST_TIMEOUT_MS);
|
||||
}
|
||||
// Ghost marker for the final unreached hop
|
||||
var last = hopPositions[hopPositions.length - 1];
|
||||
if (!nodeMarkers[last.key]) {
|
||||
var ghostEnd = L.circleMarker(last.pos, {
|
||||
radius: 4, fillColor: ghostColor, fillOpacity: 0.25, color: color, weight: 1, opacity: 0.35
|
||||
}).addTo(pathsLayer);
|
||||
setTimeout(function() { if (pathsLayer.hasLayer(ghostEnd)) pathsLayer.removeLayer(ghostEnd); }, GHOST_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHopPositions(hops, payload, resolvedPath) {
|
||||
// Hoist sender GPS guard once — reject (0,0) as "no GPS"
|
||||
const hasValidGps = payload.lat != null && payload.lon != null
|
||||
&& !(payload.lat === 0 && payload.lon === 0);
|
||||
const senderLat = hasValidGps ? payload.lat : null;
|
||||
const senderLon = hasValidGps ? payload.lon : null;
|
||||
|
||||
// Prefer server-side resolved_path when available
|
||||
var resolvedMap;
|
||||
if (resolvedPath && resolvedPath.length === hops.length && window.HopResolver && HopResolver.ready()) {
|
||||
@@ -1911,19 +2123,14 @@
|
||||
// Fill in any null entries from client-side fallback, preserving sender GPS context
|
||||
var nullHops = hops.filter(function(h, i) { return !resolvedPath[i] && !resolvedMap[h]; });
|
||||
if (nullHops.length) {
|
||||
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
|
||||
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
|
||||
var fallback = HopResolver.resolve(nullHops, originLat, originLon, null, null, null);
|
||||
var fallback = HopResolver.resolve(nullHops, senderLat, senderLon, null, null, null);
|
||||
for (var k in fallback) resolvedMap[k] = fallback[k];
|
||||
}
|
||||
} else {
|
||||
// Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing
|
||||
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
|
||||
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
|
||||
|
||||
// Use HopResolver if available and initialized, otherwise fall back to simple lookup
|
||||
resolvedMap = (window.HopResolver && HopResolver.ready())
|
||||
? HopResolver.resolve(hops, originLat, originLon, null, null, null)
|
||||
? HopResolver.resolve(hops, senderLat, senderLon, null, null, null)
|
||||
: {};
|
||||
}
|
||||
|
||||
@@ -1942,7 +2149,7 @@
|
||||
});
|
||||
|
||||
// Add sender position as anchor if available
|
||||
if (payload.pubKey && originLat != null) {
|
||||
if (payload.pubKey && senderLat != null) {
|
||||
const existing = raw.find(p => p.key === payload.pubKey);
|
||||
if (!existing) {
|
||||
raw.unshift({ key: payload.pubKey, pos: [payload.lat, payload.lon], name: payload.name || payload.pubKey.slice(0, 8), known: true });
|
||||
@@ -2500,9 +2707,22 @@
|
||||
function _getChannelStyle(pkt) {
|
||||
if (!window.ChannelColors) return '';
|
||||
var d = pkt.decoded || {};
|
||||
var h = d.header || {};
|
||||
var p = d.payload || {};
|
||||
return window.ChannelColors.getRowStyle(h.payloadTypeName || '', p.channelName || null);
|
||||
var typeName = p.type || (d.header || {}).payloadTypeName || '';
|
||||
var ch = p.channel || null;
|
||||
return window.ChannelColors.getRowStyle(typeName, ch);
|
||||
}
|
||||
|
||||
/** Build a clickable 12×12 color dot for a channel feed item (#674). */
|
||||
function _feedColorDot(channel) {
|
||||
if (!channel || !window.ChannelColors) return '';
|
||||
var c = window.ChannelColors.get(channel);
|
||||
var bg = c || 'transparent';
|
||||
var border = c ? c : 'var(--border-color, #555)';
|
||||
var style = c
|
||||
? 'background:' + bg + ';border:1px solid ' + border
|
||||
: 'background:transparent;border:1px dashed ' + border;
|
||||
return '<span class="feed-color-dot" data-channel="' + escapeHtml(channel) + '" style="display:inline-block;width:12px;height:12px;border-radius:50%;' + style + ';cursor:pointer;vertical-align:middle;margin-left:4px;flex-shrink:0" title="Set color for ' + escapeHtml(channel) + '"></span>';
|
||||
}
|
||||
|
||||
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
|
||||
@@ -2510,6 +2730,9 @@
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
|
||||
var _ccPayload2 = (pkt.decoded || {}).payload || {};
|
||||
var _ccChan = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload2.channel || null) : null;
|
||||
var dotHtml = _ccChan ? _feedColorDot(_ccChan) : '';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item';
|
||||
item.setAttribute('tabindex', '0');
|
||||
@@ -2521,11 +2744,11 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${dotHtml}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
<span class="feed-time" data-ts="${pkt._ts || Date.now()}">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
var _ccD = (pkt.decoded || {}), _ccH = (_ccD.header || {}), _ccP = (_ccD.payload || {}); if (_ccH.payloadTypeName === 'GRP_TXT' || _ccH.payloadTypeName === 'CHAN') item._ccChannel = _ccP.channelName || null; // channel color picker (#271 M2)
|
||||
if (_ccChan) item._ccChannel = _ccChan; // channel color picker (#674)
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
}
|
||||
@@ -2538,7 +2761,8 @@
|
||||
const DEDUP_WINDOW = 30000;
|
||||
|
||||
function addFeedItem(icon, typeName, payload, hops, color, pkt) {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
const feedPanel = document.getElementById('liveFeed');
|
||||
const feed = feedPanel ? feedPanel.querySelector('.panel-content') : null;
|
||||
if (!feed) return;
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
|
||||
|
||||
@@ -2568,6 +2792,13 @@
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => entry.element.classList.remove('live-feed-enter')));
|
||||
// Re-add to DOM top (works even if it was trimmed out)
|
||||
feed.prepend(entry.element);
|
||||
// Update timestamp to latest observation (#701)
|
||||
var _dedupTimeSpan = entry.element.querySelector('.feed-time');
|
||||
if (_dedupTimeSpan) {
|
||||
var _dedupNow = pkt._ts || Date.now();
|
||||
_dedupTimeSpan.setAttribute('data-ts', _dedupNow);
|
||||
_dedupTimeSpan.innerHTML = formatLiveTimestampHtml(_dedupNow);
|
||||
}
|
||||
entry.pkt.observation_count = entry.count;
|
||||
return;
|
||||
}
|
||||
@@ -2580,6 +2811,9 @@
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = incomingObs > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${incomingObs}</span>` : '';
|
||||
var _ccPayload3 = (pkt.decoded || {}).payload || {};
|
||||
var _ccChan3 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload3.channel || null) : null;
|
||||
var dotHtml3 = _ccChan3 ? _feedColorDot(_ccChan3) : '';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item live-feed-enter';
|
||||
@@ -2593,11 +2827,11 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${dotHtml3}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
<span class="feed-time" data-ts="${pkt._ts || Date.now()}">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
var _ccD = (pkt.decoded || {}), _ccH = (_ccD.header || {}), _ccP = (_ccD.payload || {}); if (_ccH.payloadTypeName === 'GRP_TXT' || _ccH.payloadTypeName === 'CHAN') item._ccChannel = _ccP.channelName || null; // channel color picker (#271 M2)
|
||||
if (_ccChan3) item._ccChannel = _ccChan3; // channel color picker (#674)
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.prepend(item);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => item.classList.remove('live-feed-enter')));
|
||||
@@ -2672,6 +2906,7 @@
|
||||
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
|
||||
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
|
||||
if (_pruneInterval) { clearInterval(_pruneInterval); _pruneInterval = null; }
|
||||
if (_feedTimestampInterval) { clearInterval(_feedTimestampInterval); _feedTimestampInterval = null; }
|
||||
if (_affinityInterval) { clearInterval(_affinityInterval); _affinityInterval = null; }
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
if (map) { map.remove(); map = null; }
|
||||
|
||||
+51
-22
@@ -78,6 +78,18 @@
|
||||
{ key: 'sensor', label: 'Sensors' },
|
||||
];
|
||||
|
||||
function buildNodesQuery(tab, searchStr) {
|
||||
var parts = [];
|
||||
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
|
||||
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildNodesQuery = buildNodesQuery;
|
||||
|
||||
function updateNodesUrl() {
|
||||
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
|
||||
}
|
||||
|
||||
function renderNodeTimestampHtml(isoString) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
|
||||
@@ -198,7 +210,7 @@
|
||||
var scoreTitle = 'Observations: ' + nb.count;
|
||||
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
|
||||
var distanceCell = nb.distance_km != null
|
||||
? Number(nb.distance_km).toFixed(1) + ' km'
|
||||
? formatDistance(Number(nb.distance_km))
|
||||
: '<span class="text-muted">—</span>';
|
||||
var showOnMap = nb.pubkey
|
||||
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
|
||||
@@ -221,12 +233,12 @@
|
||||
function renderNeighborTable(neighbors, limit) {
|
||||
return '<table class="data-table neighbor-sort-table" style="font-size:12px">' +
|
||||
'<thead><tr>' +
|
||||
'<th scope="col" data-sort="name">Neighbor</th>' +
|
||||
'<th scope="col" data-sort="role">Role</th>' +
|
||||
'<th scope="col" data-sort="score" data-type="number" data-sort-default="desc">Score</th>' +
|
||||
'<th scope="col" data-sort="count" data-type="number" data-sort-default="desc">Obs</th>' +
|
||||
'<th scope="col" data-sort="last_seen" data-type="number" data-sort-default="desc">Last Seen</th>' +
|
||||
'<th scope="col" data-sort="distance" data-type="number">Distance</th>' +
|
||||
'<th scope="col" data-sort-key="name">Neighbor</th>' +
|
||||
'<th scope="col" data-sort-key="role">Role</th>' +
|
||||
'<th scope="col" data-sort-key="score" data-type="numeric" data-sort-default="desc">Score</th>' +
|
||||
'<th scope="col" data-sort-key="count" data-type="numeric" data-sort-default="desc">Obs</th>' +
|
||||
'<th scope="col" data-sort-key="last_seen" data-type="numeric" data-sort-default="desc">Last Seen</th>' +
|
||||
'<th scope="col" data-sort-key="distance" data-type="numeric">Distance</th>' +
|
||||
'<th scope="col">Conf</th><th scope="col"></th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + renderNeighborRows(neighbors, limit) + '</tbody></table>';
|
||||
@@ -329,6 +341,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset list-view state to defaults, then override from URL params
|
||||
activeTab = 'all';
|
||||
search = '';
|
||||
const _listUrlParams = getHashParams();
|
||||
const _urlTab = _listUrlParams.get('tab');
|
||||
const _urlSearch = _listUrlParams.get('search');
|
||||
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
|
||||
if (_urlSearch) search = _urlSearch;
|
||||
|
||||
app.innerHTML = `<div class="nodes-page">
|
||||
<div class="nodes-topbar">
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
|
||||
@@ -344,8 +365,14 @@
|
||||
RegionFilter.init(document.getElementById('nodesRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; loadNodes(); });
|
||||
|
||||
if (search) {
|
||||
var _si = document.getElementById('nodeSearch');
|
||||
if (_si) _si.value = search;
|
||||
}
|
||||
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
updateNodesUrl();
|
||||
loadNodes();
|
||||
}, 250));
|
||||
|
||||
@@ -471,11 +498,11 @@
|
||||
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
|
||||
<table class="data-table observer-sort-table" style="font-size:12px">
|
||||
<thead><tr>
|
||||
<th scope="col" data-sort="observer">Observer</th>
|
||||
<th scope="col" data-sort="region">Region</th>
|
||||
<th scope="col" data-sort="packets" data-type="number" data-sort-default="desc">Packets</th>
|
||||
<th scope="col" data-sort="snr" data-type="number" data-sort-default="desc">Avg SNR</th>
|
||||
<th scope="col" data-sort="rssi" data-type="number" data-sort-default="desc">Avg RSSI</th>
|
||||
<th scope="col" data-sort-key="observer">Observer</th>
|
||||
<th scope="col" data-sort-key="region">Region</th>
|
||||
<th scope="col" data-sort-key="packets" data-type="numeric" data-sort-default="desc">Packets</th>
|
||||
<th scope="col" data-sort-key="snr" data-type="numeric" data-sort-default="desc">Avg SNR</th>
|
||||
<th scope="col" data-sort-key="rssi" data-type="numeric" data-sort-default="desc">Avg RSSI</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${observers.map(o => `<tr>
|
||||
@@ -521,10 +548,12 @@
|
||||
let hashSizeBadge = '';
|
||||
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
|
||||
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
|
||||
const hs = ((pb >> 6) & 0x3) + 1;
|
||||
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
|
||||
const hsFg = hs === 2 ? '#064e3b' : '#fff';
|
||||
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
|
||||
if ((pb & 0x3F) !== 0) {
|
||||
const hs = ((pb >> 6) & 0x3) + 1;
|
||||
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
|
||||
const hsFg = hs === 2 ? '#064e3b' : '#fff';
|
||||
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
|
||||
}
|
||||
}
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
|
||||
@@ -886,11 +915,11 @@
|
||||
</div>
|
||||
<table class="data-table" id="nodesTable">
|
||||
<thead><tr>
|
||||
<th scope="col" data-sort="name">Name</th>
|
||||
<th scope="col" class="col-pubkey" data-sort="public_key">Public Key</th>
|
||||
<th scope="col" data-sort="role">Role</th>
|
||||
<th scope="col" data-sort="last_seen" data-sort-default="desc">Last Seen</th>
|
||||
<th scope="col" data-sort="advert_count" data-sort-default="desc">Adverts</th>
|
||||
<th scope="col" data-sort-key="name">Name</th>
|
||||
<th scope="col" class="col-pubkey" data-sort-key="public_key">Public Key</th>
|
||||
<th scope="col" data-sort-key="role">Role</th>
|
||||
<th scope="col" data-sort-key="last_seen" data-sort-default="desc">Last Seen</th>
|
||||
<th scope="col" data-sort-key="advert_count" data-sort-default="desc">Adverts</th>
|
||||
</tr></thead>
|
||||
<tbody id="nodesBody"></tbody>
|
||||
</table>`;
|
||||
@@ -899,7 +928,7 @@
|
||||
const nodeTabs = document.getElementById('nodeTabs');
|
||||
initTabBar(nodeTabs);
|
||||
el.querySelectorAll('.node-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); });
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
|
||||
});
|
||||
|
||||
// Filter changes
|
||||
|
||||
+49
-4
@@ -37,6 +37,22 @@
|
||||
let _packetSortColumn = null;
|
||||
let _packetSortDirection = 'desc';
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
var _pendingUrlRegion = null;
|
||||
|
||||
var DEFAULT_TIME_WINDOW = 15;
|
||||
|
||||
function buildPacketsQuery(timeWindowMin, regionParam) {
|
||||
var parts = [];
|
||||
if (timeWindowMin && timeWindowMin !== DEFAULT_TIME_WINDOW) parts.push('timeWindow=' + timeWindowMin);
|
||||
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildPacketsQuery = buildPacketsQuery;
|
||||
|
||||
function updatePacketsUrl() {
|
||||
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
}
|
||||
|
||||
let filtersBuilt = false;
|
||||
let _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
@@ -316,6 +332,17 @@
|
||||
filters.node = routeParam;
|
||||
}
|
||||
}
|
||||
|
||||
// Read URL params (router strips query from routeParam; read from location.hash)
|
||||
var _initUrlParams = getHashParams();
|
||||
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
|
||||
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
|
||||
savedTimeWindowMin = _urlTimeWindow;
|
||||
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
|
||||
}
|
||||
var _urlRegion = _initUrlParams.get('region');
|
||||
if (_urlRegion) _pendingUrlRegion = _urlRegion;
|
||||
|
||||
app.innerHTML = `<div class="split-layout detail-collapsed">
|
||||
<div class="panel-left" id="pktLeft" aria-live="polite" aria-relevant="additions removals"></div>
|
||||
<div class="panel-right empty" id="pktRight" aria-live="polite">
|
||||
@@ -758,7 +785,11 @@
|
||||
|
||||
// Init shared RegionFilter component
|
||||
RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true });
|
||||
RegionFilter.onChange(function() { loadPackets(); });
|
||||
if (_pendingUrlRegion) {
|
||||
RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean));
|
||||
_pendingUrlRegion = null;
|
||||
}
|
||||
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
|
||||
|
||||
// --- Packet Filter Language ---
|
||||
(function() {
|
||||
@@ -908,6 +939,7 @@
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
updatePacketsUrl();
|
||||
loadPackets();
|
||||
});
|
||||
|
||||
@@ -1489,6 +1521,13 @@
|
||||
} else {
|
||||
result = TableSort.comparators.text(va, vb);
|
||||
}
|
||||
// Stable tiebreaker: sort by timestamp (desc) when primary values are equal
|
||||
if (result === 0 && !isDate) {
|
||||
result = TableSort.comparators.date(
|
||||
a.timestamp || a.first_seen || '',
|
||||
b.timestamp || b.first_seen || ''
|
||||
) * -1; // desc (newest first)
|
||||
}
|
||||
return dir * result;
|
||||
});
|
||||
}
|
||||
@@ -1723,7 +1762,7 @@
|
||||
|
||||
// Parse hash size from path byte
|
||||
const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN;
|
||||
const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1);
|
||||
const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1);
|
||||
|
||||
const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0;
|
||||
const typeName = payloadTypeName(pkt.payload_type);
|
||||
@@ -1945,7 +1984,7 @@
|
||||
const pathByte0 = parseInt(buf.slice(2, 4), 16);
|
||||
const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1);
|
||||
const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F);
|
||||
rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`);
|
||||
rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), hashCountVal === 0 ? `hash_count=0 (direct advert)` : `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`);
|
||||
|
||||
// Transport codes
|
||||
let off = 2;
|
||||
@@ -1973,7 +2012,7 @@
|
||||
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload');
|
||||
|
||||
if (decoded.type === 'ADVERT') {
|
||||
rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
|
||||
if (hashCountVal !== 0) rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
|
||||
rows += fieldRow(off, 'Public Key (32B)', truncate(decoded.pubKey || '', 24), '');
|
||||
rows += fieldRow(off + 32, 'Timestamp (4B)', decoded.timestampISO || '', 'Unix: ' + (decoded.timestamp || ''));
|
||||
rows += fieldRow(off + 36, 'Signature (64B)', truncate(decoded.signature || '', 24), '');
|
||||
@@ -2135,6 +2174,12 @@
|
||||
html += kv(k, String(v));
|
||||
}
|
||||
}
|
||||
// Special handling for advert signature validation
|
||||
if (h.payloadType === 4 && p.signatureValid !== undefined) {
|
||||
const status = p.signatureValid ? 'Valid' : 'Invalid';
|
||||
const badgeClass = p.signatureValid ? 'badge-success' : 'badge-danger';
|
||||
html += kv('Signature', `<span class="badge ${badgeClass}">${status}</span>`);
|
||||
}
|
||||
html += '</div></div>';
|
||||
|
||||
// Raw hex
|
||||
|
||||
+2
-1
@@ -91,8 +91,9 @@
|
||||
const ps = server.packetStore;
|
||||
html += `<h3>In-Memory Packet Store</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${ps.inMemory.toLocaleString()}</div><div class="perf-label">Packets in RAM</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Memory Used</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.trackedMB}MB</div><div class="perf-label">Tracked Memory</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.maxMB}MB</div><div class="perf-label">Memory Limit</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Heap (debug)</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.queries.toLocaleString()}</div><div class="perf-label">Queries Served</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.inserts.toLocaleString()}</div><div class="perf-label">Live Inserts</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.evicted.toLocaleString()}</div><div class="perf-label">Evicted</div></div>
|
||||
|
||||
+11
-1
@@ -6,6 +6,7 @@
|
||||
var _regions = {}; // { code: label }
|
||||
var _selected = null; // Set of selected region codes, null = all
|
||||
var _listeners = [];
|
||||
var _container = null;
|
||||
var _loaded = false;
|
||||
|
||||
function loadFromStorage() {
|
||||
@@ -199,11 +200,19 @@
|
||||
/** Initialize filter in a container, fetch regions, render, return promise.
|
||||
* Options: { dropdown: true } to force dropdown mode regardless of region count */
|
||||
async function initFilter(container, opts) {
|
||||
_container = container;
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
|
||||
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
|
||||
function setSelected(codesArray) {
|
||||
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
|
||||
saveToStorage();
|
||||
if (_container) render(_container);
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
@@ -213,6 +222,7 @@
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions
|
||||
fetchRegions: fetchRegions,
|
||||
setSelected: setSelected
|
||||
};
|
||||
})();
|
||||
|
||||
+57
-91
@@ -30,6 +30,7 @@
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-1);
|
||||
--hover-bg: rgba(0,0,0, 0.04);
|
||||
--trace-ghost-color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC
|
||||
@@ -55,6 +56,7 @@
|
||||
--input-bg: #1e1e34;
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--trace-ghost-color: #94a3b8;
|
||||
--section-bg: #1e1e34;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +80,7 @@
|
||||
--input-bg: #1e1e34;
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--trace-ghost-color: #94a3b8;
|
||||
--section-bg: #1e1e34;
|
||||
}
|
||||
|
||||
@@ -1184,6 +1187,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
|
||||
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
|
||||
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
|
||||
.badge-success { background: #22c55e20; color: var(--status-green); }
|
||||
.badge-danger { background: #ef444420; color: var(--status-red); }
|
||||
.badge-hash-3 { background: #3b82f620; color: var(--accent); }
|
||||
.timeline-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; }
|
||||
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||
@@ -2039,132 +2044,93 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
}
|
||||
|
||||
/* Channel Color Picker Popover (M2, #271) */
|
||||
/* === Channel Color Picker (#674) === */
|
||||
.cc-picker-popover {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--surface-1, #1e1e2e);
|
||||
border: 1px solid var(--border, #444);
|
||||
z-index: 9999;
|
||||
background: var(--bg-secondary, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-width: 200px;
|
||||
max-width: 260px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.cc-picker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cc-picker-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.cc-picker-close:hover { color: var(--text-primary, #e0e0e0); }
|
||||
.cc-picker-swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cc-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cc-swatch:hover { border-color: var(--text-primary, #e0e0e0); }
|
||||
.cc-swatch:hover { border-color: rgba(255,255,255,0.6); }
|
||||
.cc-swatch:focus-visible { border-color: #fff; outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
|
||||
.cc-swatch-active { border-color: #fff; box-shadow: 0 0 0 1px rgba(255,255,255,0.5); }
|
||||
.cc-swatch-active { border-color: #fff; }
|
||||
.cc-picker-clear {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #888);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.cc-picker-clear:hover { color: var(--text-primary, #e0e0e0); }
|
||||
|
||||
/* Mobile: larger touch targets, hide native color picker, safe areas */
|
||||
/* Color dot affordance (#674) */
|
||||
.ch-color-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(255,255,255,0.3);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ch-color-dot:not([style*="background"]) {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
border-color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* Mobile bottom-sheet + larger touch targets (#674) */
|
||||
@media (pointer: coarse) {
|
||||
.ch-color-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.cc-swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.cc-picker-swatches {
|
||||
gap: 8px;
|
||||
}
|
||||
.cc-picker-custom {
|
||||
display: none !important;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.cc-picker-popover {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
top: auto !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.live-feed-item {
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
.cc-picker-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cc-picker-custom label {
|
||||
color: var(--muted, #888);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cc-picker-input {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
.cc-picker-apply {
|
||||
background: var(--accent, #3b82f6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cc-picker-apply:hover { opacity: 0.85; }
|
||||
.cc-picker-clear {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #444);
|
||||
color: var(--muted, #888);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.cc-picker-clear:hover { color: var(--text-primary, #e0e0e0); border-color: var(--text-primary, #e0e0e0); }
|
||||
|
||||
/* === #630 — Mobile Accessibility Fixes === */
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for channel color picker fix (#674)
|
||||
*
|
||||
* Verifies:
|
||||
* 1. _ccChannel is set correctly for GRP_TXT packets (flat decoded structure)
|
||||
* 2. _ccChannel is NOT set for non-GRP_TXT packets
|
||||
* 3. Channel color picker palette is 8 colors
|
||||
* 4. getRowStyle uses border-left only (no background tint)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, msg) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
console.log(` ✓ ${msg}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` ✗ ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test 1: _ccChannel extraction logic (simulates live.js behavior) ---
|
||||
console.log('\n=== _ccChannel assignment from flat decoded structure ===');
|
||||
|
||||
// Simulate the fixed logic from live.js — uses payload.channel (name string),
|
||||
// NOT payload.channelHash (numeric byte). Channel colors are keyed by channel
|
||||
// name (e.g. "public", "#test") matching the channels API hash field.
|
||||
function extractCcChannel(typeName, pkt) {
|
||||
var _ccPayload = (pkt.decoded || {}).payload || {};
|
||||
if (typeName === 'GRP_TXT' || typeName === 'CHAN') {
|
||||
return _ccPayload.channel || null;
|
||||
}
|
||||
return undefined; // not set
|
||||
}
|
||||
|
||||
// CHAN with channel name (normal case — ingestor-decrypted WS broadcast)
|
||||
var chanPkt = {
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'CHAN' },
|
||||
payload: { type: 'CHAN', channel: '#test', channelHash: 217, text: 'hello' }
|
||||
}
|
||||
};
|
||||
assert(extractCcChannel('CHAN', chanPkt) === '#test', 'CHAN with channel="#test" → _ccChannel="#test"');
|
||||
|
||||
// CHAN with "public" channel
|
||||
var publicPkt = {
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'CHAN' },
|
||||
payload: { type: 'CHAN', channel: 'public', text: 'hi' }
|
||||
}
|
||||
};
|
||||
assert(extractCcChannel('CHAN', publicPkt) === 'public', 'CHAN with channel="public" → _ccChannel="public"');
|
||||
|
||||
// GRP_TXT without channel (encrypted, no decryption)
|
||||
var encryptedPkt = {
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { type: 'GRP_TXT', channelHash: 5, mac: 'ab12', encryptedData: 'ff' }
|
||||
}
|
||||
};
|
||||
assert(extractCcChannel('GRP_TXT', encryptedPkt) === null, 'GRP_TXT without channel field → null');
|
||||
|
||||
// Non-GRP_TXT packet — should not set _ccChannel
|
||||
var advertPkt = {
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'ADVERT' },
|
||||
payload: { type: 'ADVERT', name: 'Node1' }
|
||||
}
|
||||
};
|
||||
assert(extractCcChannel('ADVERT', advertPkt) === undefined, 'ADVERT → _ccChannel not set');
|
||||
|
||||
// Empty decoded
|
||||
var emptyPkt = { decoded: {} };
|
||||
assert(extractCcChannel('GRP_TXT', emptyPkt) === null, 'GRP_TXT with empty payload → null');
|
||||
|
||||
// --- Test 2: _getChannelStyle fix (simulates fixed logic) ---
|
||||
console.log('\n=== _getChannelStyle with flat structure ===');
|
||||
|
||||
function simulateGetChannelStyle(pkt, channelColors) {
|
||||
var d = pkt.decoded || {};
|
||||
var h = d.header || {};
|
||||
var p = d.payload || {};
|
||||
var ch = p.channel || null;
|
||||
var typeName = h.payloadTypeName || '';
|
||||
if (typeName !== 'GRP_TXT' && typeName !== 'CHAN') return '';
|
||||
if (!ch) return '';
|
||||
var color = channelColors[ch] || null;
|
||||
if (!color) return '';
|
||||
return 'border-left:3px solid ' + color + ';';
|
||||
}
|
||||
|
||||
var colors = { '#test': '#ef4444' };
|
||||
assert(
|
||||
simulateGetChannelStyle(chanPkt, colors) === 'border-left:3px solid #ef4444;',
|
||||
'getChannelStyle returns border-left for assigned color'
|
||||
);
|
||||
assert(
|
||||
simulateGetChannelStyle(chanPkt, {}) === '',
|
||||
'getChannelStyle returns empty for unassigned channel'
|
||||
);
|
||||
assert(
|
||||
simulateGetChannelStyle(advertPkt, colors) === '',
|
||||
'getChannelStyle returns empty for non-GRP_TXT'
|
||||
);
|
||||
|
||||
// --- Test 3: channel-colors.js getRowStyle uses border-left only ---
|
||||
console.log('\n=== channel-colors.js getRowStyle ===');
|
||||
|
||||
const ccSource = fs.readFileSync(path.join(__dirname, 'public', 'channel-colors.js'), 'utf8');
|
||||
const ccCtx = {
|
||||
window: {},
|
||||
localStorage: {
|
||||
_data: {},
|
||||
getItem(k) { return this._data[k] || null; },
|
||||
setItem(k, v) { this._data[k] = v; }
|
||||
}
|
||||
};
|
||||
vm.createContext(ccCtx);
|
||||
vm.runInContext(ccSource, ccCtx);
|
||||
|
||||
// Set a color
|
||||
ccCtx.window.ChannelColors.set('5', '#3b82f6');
|
||||
var style = ccCtx.window.ChannelColors.getRowStyle('GRP_TXT', '5');
|
||||
assert(style === 'border-left:3px solid #3b82f6;', 'getRowStyle returns border-left:3px (no background tint)');
|
||||
assert(!style.includes('background'), 'getRowStyle has no background property');
|
||||
|
||||
var noStyle = ccCtx.window.ChannelColors.getRowStyle('GRP_TXT', '99');
|
||||
assert(noStyle === '', 'getRowStyle returns empty for unassigned channel');
|
||||
|
||||
var advertStyle = ccCtx.window.ChannelColors.getRowStyle('ADVERT', '5');
|
||||
assert(advertStyle === '', 'getRowStyle returns empty for non-GRP_TXT type');
|
||||
|
||||
// --- Test 4: channel-color-picker.js palette ---
|
||||
console.log('\n=== channel-color-picker.js palette ===');
|
||||
|
||||
const pickerSource = fs.readFileSync(path.join(__dirname, 'public', 'channel-color-picker.js'), 'utf8');
|
||||
const pickerCtx = {
|
||||
window: { ChannelColors: ccCtx.window.ChannelColors, matchMedia: () => ({ matches: false }) },
|
||||
document: {
|
||||
createElement: () => ({
|
||||
className: '', style: {}, innerHTML: '',
|
||||
setAttribute: () => {},
|
||||
querySelector: () => ({ textContent: '', style: {}, addEventListener: () => {} }),
|
||||
querySelectorAll: () => [],
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {}
|
||||
}),
|
||||
body: { appendChild: () => {}, style: {} },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
activeElement: null
|
||||
},
|
||||
setTimeout: (fn) => fn(),
|
||||
Array: Array
|
||||
};
|
||||
vm.createContext(pickerCtx);
|
||||
vm.runInContext(pickerSource, pickerCtx);
|
||||
|
||||
assert(pickerCtx.window.ChannelColorPicker != null, 'ChannelColorPicker exported');
|
||||
assert(Array.isArray(pickerCtx.window.ChannelColorPicker.PALETTE), 'PALETTE is exported');
|
||||
assert(pickerCtx.window.ChannelColorPicker.PALETTE.length === 8, 'PALETTE has exactly 8 colors');
|
||||
|
||||
// Verify no teal/rose in palette
|
||||
var palette = pickerCtx.window.ChannelColorPicker.PALETTE;
|
||||
assert(!palette.includes('#14b8a6'), 'No teal in palette');
|
||||
assert(!palette.includes('#f43f5e'), 'No rose in palette');
|
||||
|
||||
// --- Summary ---
|
||||
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,454 @@
|
||||
/* test-drag-manager.js — Unit tests for DragManager (#608 M1) */
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
// Minimal DOM shim
|
||||
function makePanel(id) {
|
||||
const listeners = {};
|
||||
const style = {};
|
||||
const dataset = {};
|
||||
const classList = {
|
||||
_set: new Set(),
|
||||
add(c) { this._set.add(c); },
|
||||
remove(c) { this._set.delete(c); },
|
||||
contains(c) { return this._set.has(c); }
|
||||
};
|
||||
let attrs = {};
|
||||
const header = {
|
||||
_listeners: {},
|
||||
addEventListener(ev, fn) {
|
||||
if (!this._listeners[ev]) this._listeners[ev] = [];
|
||||
this._listeners[ev].push(fn);
|
||||
},
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
_fire(ev, data) {
|
||||
(this._listeners[ev] || []).forEach(fn => fn(data));
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: id,
|
||||
style: style,
|
||||
dataset: dataset,
|
||||
classList: classList,
|
||||
querySelector(sel) {
|
||||
if (sel === '.panel-header') return header;
|
||||
return null;
|
||||
},
|
||||
getAttribute(k) { return attrs[k] || null; },
|
||||
setAttribute(k, v) { attrs[k] = v; },
|
||||
removeAttribute(k) { delete attrs[k]; },
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
left: parseFloat(style.left) || 0,
|
||||
top: parseFloat(style.top) || 0,
|
||||
right: (parseFloat(style.left) || 0) + 300,
|
||||
bottom: (parseFloat(style.top) || 0) + 200,
|
||||
width: 300,
|
||||
height: 200
|
||||
};
|
||||
},
|
||||
_header: header
|
||||
};
|
||||
}
|
||||
|
||||
// Mock globals
|
||||
const storage = {};
|
||||
const mockWindow = {
|
||||
innerWidth: 1920,
|
||||
innerHeight: 1080,
|
||||
DragManager: null,
|
||||
matchMedia() { return { matches: true, addEventListener() {} }; },
|
||||
addEventListener() {}
|
||||
};
|
||||
const mockDocument = {
|
||||
addEventListener(ev, fn) {
|
||||
if (!mockDocument._listeners) mockDocument._listeners = {};
|
||||
if (!mockDocument._listeners[ev]) mockDocument._listeners[ev] = [];
|
||||
mockDocument._listeners[ev].push(fn);
|
||||
},
|
||||
removeEventListener(ev, fn) {
|
||||
if (mockDocument._listeners && mockDocument._listeners[ev]) {
|
||||
mockDocument._listeners[ev] = mockDocument._listeners[ev].filter(f => f !== fn);
|
||||
}
|
||||
},
|
||||
querySelectorAll() { return []; }
|
||||
};
|
||||
const mockLocalStorage = {
|
||||
_data: {},
|
||||
getItem(k) { return this._data[k] || null; },
|
||||
setItem(k, v) { this._data[k] = v; },
|
||||
removeItem(k) { delete this._data[k]; },
|
||||
clear() { this._data = {}; }
|
||||
};
|
||||
|
||||
// Load DragManager
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'drag-manager.js'), 'utf8');
|
||||
const ctx = vm.createContext({
|
||||
window: mockWindow,
|
||||
document: mockDocument,
|
||||
localStorage: mockLocalStorage,
|
||||
Math: Math,
|
||||
JSON: JSON,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
parseFloat: parseFloat
|
||||
});
|
||||
vm.runInContext(src, ctx);
|
||||
const DragManager = ctx.window.DragManager;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
mockLocalStorage.clear();
|
||||
fn();
|
||||
passed++;
|
||||
console.log(' ✓ ' + name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(' ✗ ' + name + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('DragManager tests:');
|
||||
|
||||
test('constructor initializes IDLE state', () => {
|
||||
const dm = new DragManager();
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.strictEqual(dm.enabled, true);
|
||||
});
|
||||
|
||||
test('register adds panel', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('testPanel');
|
||||
dm.register(panel);
|
||||
assert.strictEqual(dm._panels.length, 1);
|
||||
});
|
||||
|
||||
test('register ignores null panel', () => {
|
||||
const dm = new DragManager();
|
||||
dm.register(null);
|
||||
assert.strictEqual(dm._panels.length, 0);
|
||||
});
|
||||
|
||||
test('pointerdown transitions to PENDING', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'PENDING');
|
||||
assert.strictEqual(dm.activePanel, panel);
|
||||
});
|
||||
|
||||
test('pointerdown ignores non-left button', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 2, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
});
|
||||
|
||||
test('pointerdown ignores button clicks', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest(sel) { return sel === 'button' ? {} : null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
});
|
||||
|
||||
test('pointermove within dead zone stays PENDING', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 103, clientY: 102 });
|
||||
assert.strictEqual(dm.state, 'PENDING');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
});
|
||||
|
||||
test('pointermove beyond dead zone transitions to DRAGGING', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
panel.setAttribute('data-position', 'bl');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 110, clientY: 110 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
assert.ok(panel.classList.contains('is-dragging'));
|
||||
assert.strictEqual(panel.getAttribute('data-position'), null); // removed
|
||||
assert.strictEqual(panel.dataset.dragged, 'true');
|
||||
});
|
||||
|
||||
test('pointerup after drag finalizes position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
panel.setAttribute('data-position', 'bl');
|
||||
ctx.window.innerWidth = 1920;
|
||||
ctx.window.innerHeight = 1080;
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 200, clientY: 300 });
|
||||
panel._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
// Should have persisted
|
||||
const saved = JSON.parse(mockLocalStorage.getItem('panel-drag-p1'));
|
||||
assert.ok(saved.xPct >= 0);
|
||||
assert.ok(saved.yPct >= 0);
|
||||
});
|
||||
|
||||
test('pointerup from PENDING (click) does not finalize', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointerup', { pointerId: 1 });
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.strictEqual(mockLocalStorage.getItem('panel-drag-p1'), null);
|
||||
});
|
||||
|
||||
test('disable prevents drag', () => {
|
||||
const dm = new DragManager();
|
||||
dm.disable();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
});
|
||||
|
||||
test('snap-to-edge works within threshold', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
panel.setAttribute('data-position', 'tl');
|
||||
ctx.window.innerWidth = 1920;
|
||||
ctx.window.innerHeight = 1080;
|
||||
dm.register(panel);
|
||||
|
||||
// Simulate drag to near top-left edge
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 500, clientY: 500,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 510, clientY: 510 }); // trigger DRAGGING
|
||||
|
||||
// Panel is now detached; set its position near edge
|
||||
panel.style.left = '5px';
|
||||
panel.style.top = '10px';
|
||||
|
||||
panel._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
// Should have snapped to margin (12px)
|
||||
assert.strictEqual(panel.style.left, '12px');
|
||||
assert.strictEqual(panel.style.top, '12px');
|
||||
});
|
||||
|
||||
test('restorePositions applies saved viewport percentages', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p2');
|
||||
panel.setAttribute('data-position', 'br');
|
||||
ctx.window.innerWidth = 1000;
|
||||
ctx.window.innerHeight = 800;
|
||||
dm.register(panel);
|
||||
|
||||
mockLocalStorage.setItem('panel-drag-p2', JSON.stringify({ xPct: 0.5, yPct: 0.25 }));
|
||||
dm.restorePositions();
|
||||
|
||||
assert.strictEqual(panel.style.left, '500px');
|
||||
assert.strictEqual(panel.style.top, '200px');
|
||||
assert.strictEqual(panel.dataset.dragged, 'true');
|
||||
assert.strictEqual(panel.getAttribute('data-position'), null);
|
||||
});
|
||||
|
||||
test('handleResize clamps panels inside viewport', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p3');
|
||||
dm.register(panel);
|
||||
|
||||
// Simulate a dragged panel that's now off-screen
|
||||
panel.dataset.dragged = 'true';
|
||||
panel.style.left = '1800px';
|
||||
panel.style.top = '900px';
|
||||
ctx.window.innerWidth = 1000;
|
||||
ctx.window.innerHeight = 600;
|
||||
|
||||
// Need querySelectorAll to return this panel
|
||||
const origQSA = mockDocument.querySelectorAll;
|
||||
mockDocument.querySelectorAll = function (sel) {
|
||||
if (sel === '.live-overlay[data-dragged="true"]') return [panel];
|
||||
return [];
|
||||
};
|
||||
|
||||
dm.handleResize();
|
||||
mockDocument.querySelectorAll = origQSA;
|
||||
|
||||
// Should be clamped
|
||||
const left = parseFloat(panel.style.left);
|
||||
const top = parseFloat(panel.style.top);
|
||||
assert.ok(left + 300 <= 1000, 'left clamped: ' + left);
|
||||
assert.ok(top + 200 <= 600, 'top clamped: ' + top);
|
||||
});
|
||||
|
||||
test('Escape during drag reverts to corner position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('esc1');
|
||||
panel.setAttribute('data-position', 'bl');
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 120, clientY: 120 }); // trigger DRAGGING
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
// Simulate Escape
|
||||
dm._handleKeyDown({ key: 'Escape' });
|
||||
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
assert.strictEqual(panel.dataset.dragged, undefined);
|
||||
assert.strictEqual(panel.style.transform, '');
|
||||
});
|
||||
|
||||
test('Escape during drag reverts to saved position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('esc2');
|
||||
ctx.window.innerWidth = 1000;
|
||||
ctx.window.innerHeight = 800;
|
||||
dm.register(panel);
|
||||
|
||||
// Pre-save a dragged position
|
||||
mockLocalStorage.setItem('panel-drag-esc2', JSON.stringify({ xPct: 0.3, yPct: 0.4 }));
|
||||
dm.restorePositions();
|
||||
assert.strictEqual(panel.dataset.dragged, 'true');
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 400, clientY: 400,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 500, clientY: 500 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
dm._handleKeyDown({ key: 'Escape' });
|
||||
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.strictEqual(panel.style.transform, 'none');
|
||||
});
|
||||
|
||||
test('pointercancel during drag finalizes position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('pc1');
|
||||
panel.setAttribute('data-position', 'tl');
|
||||
ctx.window.innerWidth = 1920;
|
||||
ctx.window.innerHeight = 1080;
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 200, clientY: 200 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
panel._header._fire('pointercancel', {});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
});
|
||||
|
||||
test('z-index increments on drag', () => {
|
||||
const dm = new DragManager();
|
||||
const p1 = makePanel('z1');
|
||||
const p2 = makePanel('z2');
|
||||
dm.register(p1);
|
||||
dm.register(p2);
|
||||
|
||||
// Drag p1
|
||||
p1._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
p1._header._fire('pointermove', { clientX: 110, clientY: 110 });
|
||||
const z1 = parseInt(p1.style.zIndex);
|
||||
p1._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
// Drag p2
|
||||
p2._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
p2._header._fire('pointermove', { clientX: 110, clientY: 110 });
|
||||
const z2 = parseInt(p2.style.zIndex);
|
||||
p2._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
assert.ok(z2 > z1, 'z2 (' + z2 + ') should be greater than z1 (' + z1 + ')');
|
||||
assert.ok(z1 >= 1001, 'z1 should be >= 1001');
|
||||
});
|
||||
|
||||
test('disable mid-drag resets state', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('dis1');
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 120, clientY: 120 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
dm.disable();
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
assert.strictEqual(dm.enabled, false);
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed > 0) process.exit(1);
|
||||
+60
-1
@@ -1488,7 +1488,7 @@ async function run() {
|
||||
const hasTable = await page.$('#fullNeighborsContent .data-table');
|
||||
if (hasTable) {
|
||||
// Check columns
|
||||
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent));
|
||||
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent.trim().replace(/\s*[▲▼]\s*$/, '')));
|
||||
assert(headers.includes('Neighbor'), 'Should have Neighbor column');
|
||||
assert(headers.includes('Role'), 'Should have Role column');
|
||||
assert(headers.includes('Score'), 'Should have Score column');
|
||||
@@ -1627,6 +1627,65 @@ async function run() {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// --- Group: Deep linking (#536) ---
|
||||
|
||||
// Test: nodes tab deep link
|
||||
await test('Nodes tab deep link restores active tab', async () => {
|
||||
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const activeTab = await page.$('.node-tab.active');
|
||||
assert(activeTab, 'No active tab found');
|
||||
const tabText = await activeTab.textContent();
|
||||
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: nodes tab click updates URL
|
||||
await test('Nodes tab click updates URL', async () => {
|
||||
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-tab', { timeout: 8000 });
|
||||
const roomTab = await page.$('.node-tab[data-tab="room"]');
|
||||
assert(roomTab, 'Room tab (data-tab="room") not found — nodes page may not have rendered or tab selector changed');
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: packets timeWindow deep link
|
||||
await test('Packets timeWindow deep link restores dropdown', async () => {
|
||||
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
const val = await page.$eval('#fTimeWindow', el => el.value);
|
||||
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: timeWindow change updates URL
|
||||
await test('Packets timeWindow change updates URL', async () => {
|
||||
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
|
||||
await page.selectOption('#fTimeWindow', '30');
|
||||
await page.waitForTimeout(300);
|
||||
const url = page.url();
|
||||
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
|
||||
});
|
||||
|
||||
// Test: channels selected channel survives refresh (already implemented, verify it still works)
|
||||
await test('Channels channel selection is URL-addressable', async () => {
|
||||
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
|
||||
const firstChannel = await page.$('.ch-item');
|
||||
if (firstChannel) {
|
||||
await firstChannel.click();
|
||||
await page.waitForTimeout(500);
|
||||
const url = page.url();
|
||||
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Summary
|
||||
|
||||
@@ -75,6 +75,7 @@ function makeSandbox() {
|
||||
};
|
||||
})(),
|
||||
location: { hash: '' },
|
||||
getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); },
|
||||
CustomEvent: class CustomEvent {},
|
||||
Map,
|
||||
Promise,
|
||||
@@ -2078,6 +2079,151 @@ console.log('\n=== analytics.js: sortChannels ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== analytics.js: rfNFColumnChart =====
|
||||
console.log('\n=== analytics.js: rfNFColumnChart ===');
|
||||
{
|
||||
function makeAnalyticsSandbox2() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
ctx.registerPage = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.timeAgo = (iso) => iso ? 'x ago' : '—';
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.connectWS = () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.IATA_COORDS_GEO = {};
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) {
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const ctx2 = makeAnalyticsSandbox2();
|
||||
const rfNFColumnChart = ctx2.window._analyticsRfNFColumnChart;
|
||||
|
||||
test('rfNFColumnChart is exposed', () => assert.ok(rfNFColumnChart, '_analyticsRfNFColumnChart must be exposed'));
|
||||
|
||||
test('returns SVG string with column bars', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 },
|
||||
{ t: '2024-01-01T00:10:00Z', v: -80 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('<svg'), 'should produce SVG');
|
||||
assert.ok(svg.includes('class="nf-bar"'), 'should have column bars');
|
||||
assert.ok(svg.includes('Noise floor column chart'), 'should have aria label');
|
||||
});
|
||||
|
||||
test('color-codes bars by threshold', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 }, // green (< -100)
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 }, // yellow (-100 to -85)
|
||||
{ t: '2024-01-01T00:10:00Z', v: -80 }, // red (>= -85)
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('var(--success'), 'green bar for < -100');
|
||||
assert.ok(svg.includes('var(--warning'), 'yellow bar for -100 to -85');
|
||||
assert.ok(svg.includes('var(--danger'), 'red bar for >= -85');
|
||||
});
|
||||
|
||||
test('includes hover tooltips in bars', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -105 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('<title>NF: -105.0 dBm'), 'tooltip with dBm value');
|
||||
});
|
||||
|
||||
test('handles empty data gracefully', () => {
|
||||
const svg = rfNFColumnChart([], 700, 180, []);
|
||||
assert.ok(svg.includes('<svg'), 'should return empty SVG');
|
||||
});
|
||||
|
||||
test('handles single data point with visible bar', () => {
|
||||
const data = [{ t: '2024-01-01T00:00:00Z', v: -100 }];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('class="nf-bar"'), 'should render single bar');
|
||||
// Bar must have non-zero height (division-by-zero guard)
|
||||
const m = svg.match(/height="([\d.]+)"/);
|
||||
assert.ok(m && parseFloat(m[1]) > 0, 'single data point bar must have non-zero height');
|
||||
assert.ok(!svg.includes('NaN'), 'must not contain NaN');
|
||||
});
|
||||
|
||||
test('handles constant values with visible bars', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -95 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 },
|
||||
{ t: '2024-01-01T00:10:00Z', v: -95 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
const heights = [...svg.matchAll(/class="nf-bar"[^>]*height="([\d.]+)"/g)].map(m => parseFloat(m[1]));
|
||||
assert.strictEqual(heights.length, 3, 'should render 3 bars');
|
||||
assert.ok(heights.every(h => h > 0), 'all bars must have non-zero height');
|
||||
assert.ok(!svg.includes('NaN'), 'must not contain NaN');
|
||||
});
|
||||
|
||||
test('includes legend', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -90 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(svg.includes('< -100'), 'legend has green label');
|
||||
assert.ok(svg.includes('-100…-85'), 'legend has yellow label');
|
||||
assert.ok(svg.includes('≥ -85'), 'legend has red label');
|
||||
});
|
||||
|
||||
test('no reference lines (removed per spec)', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -80 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
assert.ok(!svg.includes('-100 warning'), 'no -100 warning reference line');
|
||||
assert.ok(!svg.includes('-85 critical'), 'no -85 critical reference line');
|
||||
assert.ok(!svg.includes('stroke-dasharray="4,2"'), 'no dashed reference lines');
|
||||
});
|
||||
|
||||
test('renders all bars even with time gaps', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -110 },
|
||||
{ t: '2024-01-01T06:00:00Z', v: -95 }, // 6h gap
|
||||
{ t: '2024-01-01T06:05:00Z', v: -80 },
|
||||
];
|
||||
const svg = rfNFColumnChart(data, 700, 180, []);
|
||||
const barCount = (svg.match(/class="nf-bar"/g) || []).length;
|
||||
assert.strictEqual(barCount, 3, 'all 3 bars rendered despite time gap');
|
||||
});
|
||||
|
||||
test('respects shared time axis', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -100 },
|
||||
{ t: '2024-01-01T00:05:00Z', v: -95 },
|
||||
];
|
||||
const minT = new Date('2023-12-31T00:00:00Z').getTime();
|
||||
const maxT = new Date('2024-01-02T00:00:00Z').getTime();
|
||||
const svg = rfNFColumnChart(data, 700, 180, [], minT, maxT);
|
||||
assert.ok(svg.includes('class="nf-bar"'), 'renders with shared time axis');
|
||||
});
|
||||
|
||||
test('renders reboot markers when reboots provided', () => {
|
||||
const data = [
|
||||
{ t: '2024-01-01T00:00:00Z', v: -105 },
|
||||
{ t: '2024-01-01T01:00:00Z', v: -95 },
|
||||
];
|
||||
const reboots = [new Date('2024-01-01T00:30:00Z').getTime()];
|
||||
const svg = rfNFColumnChart(data, 700, 180, reboots);
|
||||
assert.ok(svg.includes('reboot'), 'should render reboot marker');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== CUSTOMIZE-V2.JS: core behavior =====
|
||||
console.log('\n=== customize-v2.js: core behavior ===');
|
||||
@@ -4516,6 +4662,423 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== REGION-FILTER.JS: setSelected =====
|
||||
console.log('\n=== region-filter.js: setSelected ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
|
||||
|
||||
// Patch createElement to return an object with style property
|
||||
const origCreate = ctx.document.createElement;
|
||||
ctx.document.createElement = () => ({
|
||||
id: '', textContent: '', innerHTML: '',
|
||||
style: {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
onclick: null,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
});
|
||||
|
||||
loadInCtx(ctx, 'public/region-filter.js');
|
||||
|
||||
const RF = ctx.RegionFilter;
|
||||
|
||||
test('setSelected sets region codes', async () => {
|
||||
await RF.init(ctx.document.createElement('div'));
|
||||
RF.setSelected(['US-SFO', 'US-LAX']);
|
||||
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
|
||||
});
|
||||
|
||||
test('setSelected with null clears selection', async () => {
|
||||
await RF.init(ctx.document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected(null);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
|
||||
test('setSelected with empty array clears selection', async () => {
|
||||
await RF.init(ctx.document.createElement('div'));
|
||||
RF.setSelected(['US-SFO']);
|
||||
RF.setSelected([]);
|
||||
assert.strictEqual(RF.getRegionParam(), '');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: buildNodesQuery =====
|
||||
console.log('\n=== nodes.js: buildNodesQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
// Provide required globals for nodes.js IIFE to execute
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.qrcode = null;
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/nodes.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildNodesQuery = ctx.buildNodesQuery;
|
||||
|
||||
if (buildNodesQuery) {
|
||||
test('buildNodesQuery: all tab + no search = empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', ''), '');
|
||||
});
|
||||
test('buildNodesQuery: repeater tab only', () => {
|
||||
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
|
||||
});
|
||||
test('buildNodesQuery: search only (all tab)', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
|
||||
});
|
||||
test('buildNodesQuery: tab + search combined', () => {
|
||||
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
|
||||
});
|
||||
test('buildNodesQuery: null search treated as empty', () => {
|
||||
assert.strictEqual(buildNodesQuery('all', null), '');
|
||||
});
|
||||
test('buildNodesQuery: sensor tab', () => {
|
||||
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PACKETS.JS: buildPacketsQuery =====
|
||||
console.log('\n=== packets.js: buildPacketsQuery ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.observerMap = new Map();
|
||||
ctx.getParsedPath = () => [];
|
||||
ctx.getParsedDecoded = () => ({});
|
||||
ctx.clearParsedCache = () => {};
|
||||
ctx.escapeHtml = (s) => s;
|
||||
ctx.timeAgo = () => '';
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'ago';
|
||||
ctx.copyToClipboard = () => {};
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.initTabBar = () => {};
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
const src2 = fs.readFileSync('public/packets.js', 'utf8');
|
||||
vm.runInContext(src2, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
} catch (e) {
|
||||
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
|
||||
}
|
||||
|
||||
const buildPacketsQuery = ctx.buildPacketsQuery;
|
||||
|
||||
if (buildPacketsQuery) {
|
||||
test('buildPacketsQuery: default (15min, no region) = empty string', () => {
|
||||
assert.strictEqual(buildPacketsQuery(15, ''), '');
|
||||
});
|
||||
test('buildPacketsQuery: non-default timeWindow', () => {
|
||||
assert.strictEqual(buildPacketsQuery(60, ''), '?timeWindow=60');
|
||||
});
|
||||
test('buildPacketsQuery: region only', () => {
|
||||
assert.strictEqual(buildPacketsQuery(15, 'US-SFO'), '?region=US-SFO');
|
||||
});
|
||||
test('buildPacketsQuery: timeWindow + region', () => {
|
||||
assert.strictEqual(buildPacketsQuery(30, 'US-SFO,US-LAX'), '?timeWindow=30®ion=US-SFO%2CUS-LAX');
|
||||
});
|
||||
test('buildPacketsQuery: timeWindow=0 treated as default', () => {
|
||||
assert.strictEqual(buildPacketsQuery(0, ''), '');
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠️ buildPacketsQuery not exposed — skipping');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== APP.JS: formatDistance / getDistanceUnit =====
|
||||
console.log('\n=== app.js: formatDistance ===');
|
||||
{
|
||||
function makeDistCtx(localeLang, storageUnit) {
|
||||
const ctx = makeSandbox();
|
||||
if (storageUnit !== undefined) ctx.localStorage.setItem('meshcore-distance-unit', storageUnit);
|
||||
ctx.navigator = { language: localeLang || 'en-BE' };
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('formatDistance: km mode, 12.3 km', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(12.3), '12.3 km');
|
||||
});
|
||||
test('formatDistance: km mode, sub-1km shows meters', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(0.45), '450 m');
|
||||
});
|
||||
test('formatDistance: mi mode, 12.3 km → 7.6 mi', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistance(12.3), '7.6 mi');
|
||||
});
|
||||
test('formatDistance: auto + en-US locale → mi', () => {
|
||||
const ctx = makeDistCtx('en-US', 'auto');
|
||||
assert.strictEqual(ctx.getDistanceUnit(), 'mi');
|
||||
});
|
||||
test('formatDistance: auto + en-GB locale → mi', () => {
|
||||
const ctx = makeDistCtx('en-GB', 'auto');
|
||||
assert.strictEqual(ctx.getDistanceUnit(), 'mi');
|
||||
});
|
||||
test('formatDistance: auto + fr-BE locale → km', () => {
|
||||
const ctx = makeDistCtx('fr-BE', 'auto');
|
||||
assert.strictEqual(ctx.getDistanceUnit(), 'km');
|
||||
});
|
||||
test('formatDistance: null input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(null), '—');
|
||||
});
|
||||
test('formatDistanceRound: 50 km → "50 km"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(50), '50 km');
|
||||
});
|
||||
test('formatDistanceRound: 50 km in mi mode → "31 mi"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistanceRound(50), '31 mi');
|
||||
});
|
||||
test('formatDistanceRound: 200 km in mi mode → "124 mi"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistanceRound(200), '124 mi');
|
||||
});
|
||||
test('formatDistance: 0 in km mode → "0 m"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(0), '0 m');
|
||||
});
|
||||
test('formatDistance: 0 in mi mode → "0 ft"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistance(0), '0 ft');
|
||||
});
|
||||
test('formatDistance: NaN input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance(NaN), '—');
|
||||
});
|
||||
test('formatDistance: "abc" input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistance('abc'), '—');
|
||||
});
|
||||
test('formatDistanceRound: null input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(null), '—');
|
||||
});
|
||||
test('formatDistanceRound: NaN input returns —', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(NaN), '—');
|
||||
});
|
||||
test('formatDistanceRound: 0 in km mode → "0 km"', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'km');
|
||||
assert.strictEqual(ctx.formatDistanceRound(0), '0 km');
|
||||
});
|
||||
test('formatDistance: mi mode sub-0.1mi shows feet', () => {
|
||||
const ctx = makeDistCtx('en-BE', 'mi');
|
||||
assert.strictEqual(ctx.formatDistance(0.01), '33 ft');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== analytics.js: renderMultiByteCapability =====
|
||||
console.log('\n=== analytics.js: renderMultiByteCapability ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { /* IIFE side-effects ok */ }
|
||||
|
||||
const render = ctx.window._analyticsRenderMultiByteCapability;
|
||||
test('renderMultiByteCapability is exposed', () => assert.ok(render, '_analyticsRenderMultiByteCapability must be exposed'));
|
||||
|
||||
if (render) {
|
||||
test('empty array returns empty string', () => {
|
||||
assert.strictEqual(render([]), '');
|
||||
});
|
||||
|
||||
test('renders confirmed status with green indicator', () => {
|
||||
const html = render([{ pubkey: 'aabb', name: 'RepA', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' }]);
|
||||
assert.ok(html.includes('✅'), 'should contain confirmed icon');
|
||||
assert.ok(html.includes('Confirmed'), 'should contain Confirmed label');
|
||||
assert.ok(html.includes('--success'), 'should use --success CSS var for green');
|
||||
});
|
||||
|
||||
test('renders suspected status with yellow indicator', () => {
|
||||
const html = render([{ pubkey: 'ccdd', name: 'RepB', role: 'repeater', status: 'suspected', evidence: 'path', maxHashSize: 2, lastSeen: '' }]);
|
||||
assert.ok(html.includes('⚠️'), 'should contain suspected icon');
|
||||
assert.ok(html.includes('Suspected'), 'should contain Suspected label');
|
||||
assert.ok(html.includes('--warning'), 'should use --warning CSS var for yellow');
|
||||
});
|
||||
|
||||
test('renders unknown status with gray indicator', () => {
|
||||
const html = render([{ pubkey: 'eeff', name: 'RepC', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' }]);
|
||||
assert.ok(html.includes('❓'), 'should contain unknown icon');
|
||||
assert.ok(html.includes('Unknown'), 'should contain Unknown label');
|
||||
assert.ok(html.includes('--text-muted'), 'should use --text-muted CSS var for gray');
|
||||
});
|
||||
|
||||
test('renders all three statuses together', () => {
|
||||
const caps = [
|
||||
{ pubkey: 'aa11', name: 'R1', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 3, lastSeen: '' },
|
||||
{ pubkey: 'bb22', name: 'R2', role: 'repeater', status: 'suspected', evidence: 'path', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'cc33', name: 'R3', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' },
|
||||
];
|
||||
const html = render(caps);
|
||||
assert.ok(html.includes('R1'), 'should contain R1');
|
||||
assert.ok(html.includes('R2'), 'should contain R2');
|
||||
assert.ok(html.includes('R3'), 'should contain R3');
|
||||
assert.ok(html.includes('3-byte'), 'should show 3-byte badge');
|
||||
assert.ok(html.includes('2-byte'), 'should show 2-byte badge');
|
||||
assert.ok(html.includes('1-byte'), 'should show 1-byte badge');
|
||||
});
|
||||
|
||||
test('filter buttons show correct counts', () => {
|
||||
const caps = [
|
||||
{ pubkey: 'a1', name: 'C1', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'a2', name: 'C2', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'b1', name: 'S1', role: 'repeater', status: 'suspected', evidence: 'path', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'c1', name: 'U1', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' },
|
||||
];
|
||||
const html = render(caps);
|
||||
assert.ok(html.includes('All (4)'), 'should show total count 4');
|
||||
assert.ok(html.includes('Confirmed (2)'), 'should show 2 confirmed');
|
||||
assert.ok(html.includes('Suspected (1)'), 'should show 1 suspected');
|
||||
assert.ok(html.includes('Unknown (1)'), 'should show 1 unknown');
|
||||
});
|
||||
|
||||
test('evidence labels map to status display', () => {
|
||||
const html = render([
|
||||
{ pubkey: 'a1', name: 'R1', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'b1', name: 'R2', role: 'repeater', status: 'suspected', evidence: 'path', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'c1', name: 'R3', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' },
|
||||
]);
|
||||
assert.ok(html.includes('Confirmed'), 'confirmed status should be shown');
|
||||
assert.ok(html.includes('Suspected'), 'suspected status should be shown');
|
||||
assert.ok(html.includes('Unknown'), 'unknown status should be shown');
|
||||
});
|
||||
|
||||
test('table rows link to node detail', () => {
|
||||
const html = render([{ pubkey: 'aabbccdd', name: 'Rep1', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' }]);
|
||||
assert.ok(html.includes('#/nodes/aabbccdd'), 'row should link to node detail page');
|
||||
});
|
||||
|
||||
test('node names are HTML-escaped', () => {
|
||||
const html = render([{ pubkey: 'x1', name: '<script>alert(1)</script>', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' }]);
|
||||
assert.ok(!html.includes('<script>'), 'should escape HTML in name');
|
||||
});
|
||||
|
||||
test('table has sortable column headers', () => {
|
||||
const html = render([{ pubkey: 'a1', name: 'R1', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' }]);
|
||||
assert.ok(html.includes('data-sort="status"'), 'status column should be sortable');
|
||||
assert.ok(html.includes('data-sort="name"'), 'name column should be sortable');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== analytics.js: renderMultiByteAdopters (integrated) =====
|
||||
console.log('\n=== analytics.js: renderMultiByteAdopters ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { /* IIFE side-effects ok */ }
|
||||
|
||||
const renderAdopters = ctx.window._analyticsRenderMultiByteAdopters;
|
||||
test('renderMultiByteAdopters is exposed', () => assert.ok(renderAdopters, '_analyticsRenderMultiByteAdopters must be exposed'));
|
||||
|
||||
if (renderAdopters) {
|
||||
test('empty nodes returns no-adopters message', () => {
|
||||
const html = renderAdopters([], []);
|
||||
assert.ok(html.includes('No multi-byte adopters found'), 'should show empty message');
|
||||
});
|
||||
|
||||
test('integrates capability status into adopter rows', () => {
|
||||
const nodes = [
|
||||
{ name: 'NodeA', pubkey: 'aa11', role: 'repeater', hashSize: 2, packets: 5, lastSeen: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
const caps = [
|
||||
{ pubkey: 'aa11', name: 'NodeA', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' },
|
||||
];
|
||||
const html = renderAdopters(nodes, caps);
|
||||
assert.ok(html.includes('✅'), 'should show confirmed icon');
|
||||
assert.ok(html.includes('Confirmed'), 'should show Confirmed label');
|
||||
assert.ok(html.includes('2-byte'), 'should show hash size badge');
|
||||
});
|
||||
|
||||
test('filter buttons have text labels with counts', () => {
|
||||
const nodes = [
|
||||
{ name: 'N1', pubkey: 'a1', role: 'repeater', hashSize: 2, packets: 3, lastSeen: '' },
|
||||
{ name: 'N2', pubkey: 'b1', role: 'repeater', hashSize: 2, packets: 1, lastSeen: '' },
|
||||
];
|
||||
const caps = [
|
||||
{ pubkey: 'a1', name: 'N1', role: 'repeater', status: 'confirmed', evidence: 'advert', maxHashSize: 2, lastSeen: '' },
|
||||
{ pubkey: 'b1', name: 'N2', role: 'repeater', status: 'suspected', evidence: 'path', maxHashSize: 2, lastSeen: '' },
|
||||
];
|
||||
const html = renderAdopters(nodes, caps);
|
||||
assert.ok(html.includes('Confirmed (1)'), 'should show "Confirmed (1)"');
|
||||
assert.ok(html.includes('Suspected (1)'), 'should show "Suspected (1)"');
|
||||
assert.ok(html.includes('Unknown (0)'), 'should show "Unknown (0)"');
|
||||
assert.ok(html.includes('All (2)'), 'should show total "All (2)"');
|
||||
});
|
||||
|
||||
test('nodes without capability data default to unknown', () => {
|
||||
const nodes = [
|
||||
{ name: 'Orphan', pubkey: 'zz99', role: 'repeater', hashSize: 2, packets: 1, lastSeen: '' },
|
||||
];
|
||||
const html = renderAdopters(nodes, []); // no caps
|
||||
assert.ok(html.includes('❓'), 'should show unknown icon');
|
||||
assert.ok(html.includes('Unknown'), 'should show Unknown label');
|
||||
});
|
||||
|
||||
test('integrated table has Status column', () => {
|
||||
const nodes = [
|
||||
{ name: 'R1', pubkey: 'a1', role: 'repeater', hashSize: 2, packets: 1, lastSeen: '' },
|
||||
];
|
||||
const html = renderAdopters(nodes, []);
|
||||
assert.ok(html.includes('Status'), 'should have Status column header');
|
||||
assert.ok(html.includes('data-sort="status"'), 'Status should be sortable');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
@@ -593,6 +593,40 @@ console.log('\n=== live.js: formatLiveTimestampHtml ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Feed timestamp refresh — data-ts attribute and selector (#701) =====
|
||||
console.log('\n=== live.js: feed timestamp refresh (#701) ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox({ withAppJs: true });
|
||||
const fmt = ctx.window._liveFormatLiveTimestampHtml;
|
||||
|
||||
test('formatLiveTimestampHtml returns different text for different ages', () => {
|
||||
const recent = fmt(Date.now() - 5000);
|
||||
const older = fmt(Date.now() - 120000);
|
||||
// Both should produce valid HTML
|
||||
assert.ok(recent.includes('timestamp-text'), 'recent should have timestamp-text');
|
||||
assert.ok(older.includes('timestamp-text'), 'older should have timestamp-text');
|
||||
});
|
||||
|
||||
test('formatLiveTimestampHtml accepts numeric ms timestamp', () => {
|
||||
const ts = Date.now() - 45000;
|
||||
const html = fmt(ts);
|
||||
assert.ok(html.includes('timestamp-text'), 'numeric ms timestamp should render');
|
||||
// Re-calling with same ts should produce same result (idempotent refresh)
|
||||
const html2 = fmt(ts);
|
||||
assert.strictEqual(html, html2, 'same input should produce same output');
|
||||
});
|
||||
|
||||
test('feed-time template with data-ts round-trips correctly', () => {
|
||||
// Verify that Number(dataset.ts) fed back to fmt produces valid output
|
||||
const ts = Date.now() - 30000;
|
||||
const tsStr = String(ts);
|
||||
const reparsed = Number(tsStr);
|
||||
assert.strictEqual(reparsed, ts, 'data-ts round-trip should preserve value');
|
||||
const html = fmt(reparsed);
|
||||
assert.ok(html.includes('timestamp-text'), 'round-tripped timestamp should render');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== resolveHopPositions =====
|
||||
console.log('\n=== live.js: resolveHopPositions ===');
|
||||
{
|
||||
|
||||
+10
-1
@@ -629,10 +629,19 @@ console.log('\n=== packets.js: buildFieldTable ===');
|
||||
});
|
||||
|
||||
test('buildFieldTable hash_size calculation', () => {
|
||||
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4
|
||||
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4, but hash_count = 0
|
||||
// Since #653: when hashCount == 0, shows "hash_count=0 (direct advert)" instead of hash_size
|
||||
const pkt = { raw_hex: '00C0', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('hash_count=0 (direct advert)'));
|
||||
});
|
||||
|
||||
test('buildFieldTable hash_size shown when hash_count > 0', () => {
|
||||
// Path byte 0xC1 → bits 7-6 = 3 → hash_size = 4, hash_count = 1
|
||||
const pkt = { raw_hex: '00C1aabbccdd', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('hash_size=4'));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Tests for panel corner positioning (#608 M0)
|
||||
* Tests the pure logic functions extracted from live.js
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Minimal DOM/browser stubs
|
||||
function createContext() {
|
||||
const storage = {};
|
||||
const elements = {};
|
||||
const listeners = {};
|
||||
|
||||
const mockEl = () => ({
|
||||
style: {}, textContent: '', innerHTML: '',
|
||||
classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||
appendChild(c){ return c; }, removeChild(){ }, insertBefore(c){ return c; },
|
||||
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){},
|
||||
addEventListener(){}, removeEventListener(){},
|
||||
querySelector(){ return null; }, querySelectorAll(){ return []; },
|
||||
getBoundingClientRect(){ return {top:0,left:0,right:0,bottom:0,width:0,height:0}; },
|
||||
closest(){ return null; }, matches(){ return false; },
|
||||
children: [], childNodes: [], parentNode: null, parentElement: null,
|
||||
focus(){}, blur(){}, click(){}, scrollTo(){},
|
||||
dataset: {}, offsetWidth: 0, offsetHeight: 0,
|
||||
getContext(){ return { clearRect(){}, fillRect(){}, beginPath(){}, moveTo(){}, lineTo(){}, stroke(){}, fill(){}, arc(){}, save(){}, restore(){}, translate(){}, rotate(){}, scale(){}, drawImage(){}, measureText(){ return {width:0}; }, createLinearGradient(){ return {addColorStop(){}}; }, canvas: {width:0,height:0} }; },
|
||||
width: 0, height: 0,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: (id) => elements[id] || null,
|
||||
querySelectorAll: (sel) => {
|
||||
const results = [];
|
||||
for (const id in elements) {
|
||||
const el = elements[id];
|
||||
if (el._btns) results.push(...el._btns);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
querySelector: () => null,
|
||||
documentElement: { getAttribute: () => null, style: {} },
|
||||
addEventListener: () => {},
|
||||
createElement: () => mockEl(),
|
||||
createElementNS: () => mockEl(),
|
||||
createTextNode: (t) => ({ textContent: t }),
|
||||
createDocumentFragment: () => ({ appendChild(){}, children: [] }),
|
||||
body: { appendChild(){}, removeChild(){}, style: {}, classList: { add(){}, remove(){} } },
|
||||
head: { appendChild(){} },
|
||||
},
|
||||
localStorage: {
|
||||
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
|
||||
setItem: (k, v) => { storage[k] = String(v); },
|
||||
removeItem: (k) => { delete storage[k]; }
|
||||
},
|
||||
_storage: storage,
|
||||
_elements: elements,
|
||||
_addElement: function(id) {
|
||||
const attrs = {};
|
||||
const btns = [];
|
||||
elements[id] = {
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
getAttribute: (k) => attrs[k] || null,
|
||||
querySelector: (sel) => {
|
||||
if (sel === '.panel-corner-btn') return btns[0] || null;
|
||||
return null;
|
||||
},
|
||||
_attrs: attrs,
|
||||
_btns: btns,
|
||||
_addBtn: function(panelId) {
|
||||
const btnAttrs = { 'data-panel': panelId };
|
||||
const btn = {
|
||||
textContent: '',
|
||||
setAttribute: (k, v) => { btnAttrs[k] = v; },
|
||||
getAttribute: (k) => btnAttrs[k] || null,
|
||||
addEventListener: () => {},
|
||||
_attrs: btnAttrs
|
||||
};
|
||||
btns.push(btn);
|
||||
return btn;
|
||||
}
|
||||
};
|
||||
return elements[id];
|
||||
}
|
||||
};
|
||||
|
||||
// Self-references
|
||||
ctx.window = ctx;
|
||||
ctx.self = ctx;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadLiveModule(ctx) {
|
||||
// Load the REAL live.js in a VM context and return window._panelCorner.
|
||||
// This tests the actual code, not a copy (per AGENTS.md "test the real code, not copies").
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
|
||||
|
||||
// Minimal stubs for live.js dependencies (only what's needed to avoid errors)
|
||||
ctx.registerPage = () => {};
|
||||
ctx.escapeHtml = (s) => String(s || '');
|
||||
ctx.timeAgo = () => '—';
|
||||
ctx.getParsedPath = () => [];
|
||||
ctx.getParsedDecoded = () => ({});
|
||||
ctx.TYPE_COLORS = { ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280', REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6' };
|
||||
ctx.ROLE_COLORS = {};
|
||||
ctx.ROLE_LABELS = {};
|
||||
ctx.ROLE_STYLE = {};
|
||||
ctx.ROLE_SORT = [];
|
||||
ctx.formatTimestampWithTooltip = () => '';
|
||||
ctx.getTimestampMode = () => 'relative';
|
||||
ctx.console = console;
|
||||
ctx.setTimeout = setTimeout;
|
||||
ctx.clearTimeout = clearTimeout;
|
||||
ctx.setInterval = setInterval;
|
||||
ctx.clearInterval = clearInterval;
|
||||
ctx.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
ctx.cancelAnimationFrame = clearTimeout;
|
||||
ctx.matchMedia = () => ({ matches: false, addEventListener: () => {} });
|
||||
ctx.navigator = { userAgent: '' };
|
||||
ctx.performance = { now: () => Date.now() };
|
||||
ctx.L = undefined;
|
||||
ctx.MutationObserver = class { observe() {} disconnect() {} };
|
||||
ctx.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
ctx.IntersectionObserver = class { observe() {} disconnect() {} };
|
||||
ctx.Image = class {};
|
||||
ctx.AudioContext = undefined;
|
||||
ctx.HTMLElement = class {};
|
||||
ctx.Event = class {};
|
||||
ctx.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
ctx.Number = Number; ctx.String = String; ctx.Array = Array; ctx.Object = Object;
|
||||
ctx.JSON = JSON; ctx.Math = Math; ctx.Date = Date; ctx.RegExp = RegExp;
|
||||
ctx.Error = Error; ctx.Map = Map; ctx.Set = Set; ctx.WeakMap = WeakMap;
|
||||
ctx.parseInt = parseInt; ctx.parseFloat = parseFloat;
|
||||
ctx.isNaN = isNaN; ctx.isFinite = isFinite;
|
||||
ctx.encodeURIComponent = encodeURIComponent;
|
||||
ctx.decodeURIComponent = decodeURIComponent;
|
||||
ctx.Promise = Promise; ctx.Symbol = Symbol;
|
||||
ctx.queueMicrotask = queueMicrotask;
|
||||
|
||||
// Self-references needed for the IIFE
|
||||
ctx.self = ctx;
|
||||
ctx.globalThis = ctx;
|
||||
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(src, ctx, { timeout: 3000 });
|
||||
return ctx.window._panelCorner;
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(' ✓ ' + name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(' ✗ ' + name);
|
||||
console.log(' ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nPanel Corner Positioning Tests (#608 M0)\n');
|
||||
|
||||
// --- nextAvailableCorner ---
|
||||
console.log('nextAvailableCorner:');
|
||||
|
||||
test('returns desired corner when available', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
|
||||
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tl', positions), 'tl');
|
||||
});
|
||||
|
||||
test('skips occupied corner', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
|
||||
// liveFeed wants 'tr' but liveNodeDetail is there → should get 'br'? No, liveLegend is at br → skip to bl? No liveFeed is at bl → skip to tl
|
||||
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tr', positions), 'bl');
|
||||
// Wait — liveFeed IS liveFeed, so bl is not occupied by "another" panel
|
||||
// Actually liveFeed wants tr → tr occupied by nodeDetail → try br → occupied by legend → try bl → that's liveFeed itself (excluded from "occupied") → bl is free
|
||||
});
|
||||
|
||||
test('skips multiple occupied corners', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
const positions = { liveFeed: 'tl', liveLegend: 'tr', liveNodeDetail: 'br' };
|
||||
// liveFeed wants 'tr' → occupied by legend → try 'br' → occupied by nodeDetail → try 'bl' → free
|
||||
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tr', positions), 'bl');
|
||||
});
|
||||
|
||||
test('returns desired when only self occupies it', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
|
||||
// liveFeed wants bl — it's "occupied" by liveFeed itself, which is excluded
|
||||
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'bl', positions), 'bl');
|
||||
});
|
||||
|
||||
// --- getPanelPositions ---
|
||||
console.log('\ngetPanelPositions:');
|
||||
|
||||
test('returns defaults when nothing in localStorage', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
const pos = pc.getPanelPositions();
|
||||
assert.strictEqual(pos.liveFeed, 'bl');
|
||||
assert.strictEqual(pos.liveLegend, 'br');
|
||||
assert.strictEqual(pos.liveNodeDetail, 'tr');
|
||||
});
|
||||
|
||||
test('returns saved positions from localStorage', () => {
|
||||
const ctx = createContext();
|
||||
ctx.localStorage.setItem('panel-corner-liveFeed', 'tl');
|
||||
ctx.localStorage.setItem('panel-corner-liveLegend', 'bl');
|
||||
const pc = loadLiveModule(ctx);
|
||||
const pos = pc.getPanelPositions();
|
||||
assert.strictEqual(pos.liveFeed, 'tl');
|
||||
assert.strictEqual(pos.liveLegend, 'bl');
|
||||
assert.strictEqual(pos.liveNodeDetail, 'tr'); // still default
|
||||
});
|
||||
|
||||
// --- applyPanelPosition ---
|
||||
console.log('\napplyPanelPosition:');
|
||||
|
||||
test('sets data-position attribute on element', () => {
|
||||
const ctx = createContext();
|
||||
const el = ctx._addElement('liveFeed');
|
||||
el._addBtn('liveFeed');
|
||||
const pc = loadLiveModule(ctx);
|
||||
pc.applyPanelPosition('liveFeed', 'tr');
|
||||
assert.strictEqual(el._attrs['data-position'], 'tr');
|
||||
});
|
||||
|
||||
test('updates button text and aria-label', () => {
|
||||
const ctx = createContext();
|
||||
const el = ctx._addElement('liveFeed');
|
||||
const btn = el._addBtn('liveFeed');
|
||||
const pc = loadLiveModule(ctx);
|
||||
pc.applyPanelPosition('liveFeed', 'tr');
|
||||
assert.strictEqual(btn.textContent, '↙');
|
||||
assert.ok(btn._attrs['aria-label'].includes('top-right'));
|
||||
});
|
||||
|
||||
test('handles missing element gracefully', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
// Should not throw
|
||||
pc.applyPanelPosition('nonexistent', 'tl');
|
||||
});
|
||||
|
||||
// --- onCornerClick ---
|
||||
console.log('\nonCornerClick:');
|
||||
|
||||
test('cycles from default bl to tl for feed', () => {
|
||||
const ctx = createContext();
|
||||
const el = ctx._addElement('liveFeed');
|
||||
el._addBtn('liveFeed');
|
||||
ctx._addElement('liveLegend');
|
||||
ctx._addElement('liveNodeDetail');
|
||||
ctx._addElement('panelPositionAnnounce');
|
||||
ctx._elements.panelPositionAnnounce.textContent = '';
|
||||
const pc = loadLiveModule(ctx);
|
||||
// Feed defaults to bl, cycle: bl → tl (next in cycle after bl is tl)
|
||||
pc.onCornerClick('liveFeed');
|
||||
assert.strictEqual(ctx._storage['panel-corner-liveFeed'], 'tl');
|
||||
assert.strictEqual(el._attrs['data-position'], 'tl');
|
||||
});
|
||||
|
||||
test('collision avoidance: skips occupied corner', () => {
|
||||
const ctx = createContext();
|
||||
ctx._addElement('liveFeed');
|
||||
const legendEl = ctx._addElement('liveLegend');
|
||||
legendEl._addBtn('liveLegend');
|
||||
ctx._addElement('liveNodeDetail');
|
||||
ctx._addElement('panelPositionAnnounce');
|
||||
ctx._elements.panelPositionAnnounce.textContent = '';
|
||||
const pc = loadLiveModule(ctx);
|
||||
// Legend defaults to br. Click → next is bl. But bl is occupied by feed → skip to tl
|
||||
pc.onCornerClick('liveLegend');
|
||||
assert.strictEqual(ctx._storage['panel-corner-liveLegend'], 'tl');
|
||||
});
|
||||
|
||||
// --- resetPanelPositions ---
|
||||
console.log('\nresetPanelPositions:');
|
||||
|
||||
test('clears localStorage and restores defaults', () => {
|
||||
const ctx = createContext();
|
||||
ctx.localStorage.setItem('panel-corner-liveFeed', 'tr');
|
||||
ctx.localStorage.setItem('panel-corner-liveLegend', 'tl');
|
||||
const feedEl = ctx._addElement('liveFeed');
|
||||
feedEl._addBtn('liveFeed');
|
||||
const legendEl = ctx._addElement('liveLegend');
|
||||
legendEl._addBtn('liveLegend');
|
||||
const detailEl = ctx._addElement('liveNodeDetail');
|
||||
detailEl._addBtn('liveNodeDetail');
|
||||
const pc = loadLiveModule(ctx);
|
||||
pc.resetPanelPositions();
|
||||
assert.strictEqual(ctx._storage['panel-corner-liveFeed'], undefined);
|
||||
assert.strictEqual(feedEl._attrs['data-position'], 'bl');
|
||||
assert.strictEqual(legendEl._attrs['data-position'], 'br');
|
||||
assert.strictEqual(detailEl._attrs['data-position'], 'tr');
|
||||
});
|
||||
|
||||
// --- Corner cycle order ---
|
||||
console.log('\nCorner cycle order:');
|
||||
|
||||
test('full cycle: tl → tr → br → bl → tl', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
const cycle = pc.CORNER_CYCLE;
|
||||
assert.strictEqual(cycle.join(','), 'tl,tr,br,bl');
|
||||
});
|
||||
|
||||
test('defaults match expected panel positions', () => {
|
||||
const ctx = createContext();
|
||||
const pc = loadLiveModule(ctx);
|
||||
assert.strictEqual(pc.PANEL_DEFAULTS.liveFeed, 'bl');
|
||||
assert.strictEqual(pc.PANEL_DEFAULTS.liveLegend, 'br');
|
||||
assert.strictEqual(pc.PANEL_DEFAULTS.liveNodeDetail, 'tr');
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user