mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 01:24:06 +00:00
Merge branch 'feat/geofilter-m3-customizer' of https://github.com/efiten/meshcore-analyzer into feat/geofilter-m3-customizer
This commit is contained in:
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"1178 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"2 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"39.03%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"40.02%","color":"red"}
|
||||
|
||||
@@ -103,6 +103,7 @@ jobs:
|
||||
node test-observer-iata-1188.js
|
||||
node test-pull-to-reconnect-1091.js
|
||||
node test-channel-fluid-layout.js
|
||||
node test-issue-1279-p2-code-filter.js
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
@@ -264,6 +265,9 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
|
||||
@@ -43,6 +43,17 @@ scripts/ — Tooling (coverage collector, fixture capture, frontend in
|
||||
2. Go server (`cmd/server/`) polls SQLite for new packets, broadcasts via WebSocket
|
||||
3. Frontend fetches via REST API (`/api/*`), filters/sorts client-side
|
||||
|
||||
### Read/Write Separation Invariant (#1283)
|
||||
- **All DB writes live in `cmd/ingestor/`.** INSERT / UPDATE / DELETE / VACUUM /
|
||||
schema migrations / retention all run in the ingestor process.
|
||||
- **`cmd/server/` is read-only.** It opens SQLite with `mode=ro` and must not
|
||||
acquire a write lock. Adding a write-side helper (e.g. a `cachedRW`-style
|
||||
RW connection) regresses this invariant and races the ingestor → SQLITE_BUSY.
|
||||
- Enforcement: `cmd/server/readonly_invariant_test.go` reflect-asserts that
|
||||
`PruneOldPackets`, `PruneOldMetrics`, and `RemoveStaleObservers` are NOT
|
||||
methods on the server's `*DB`. If you need a new write, add it to
|
||||
`cmd/ingestor/`.
|
||||
|
||||
### What's Deprecated (DO NOT TOUCH)
|
||||
The following were part of the old Node.js backend and have been removed:
|
||||
- `server.js`, `db.js`, `decoder.js`, `server-helpers.js`, `packet-store.js`, `iata-coords.js`
|
||||
|
||||
+15
-3
@@ -99,9 +99,21 @@ func (f *ForeignAdvertConfig) IsDropMode() bool {
|
||||
|
||||
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
ObserverDays int `json:"observerDays"`
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
NodeDays int `json:"nodeDays"`
|
||||
ObserverDays int `json:"observerDays"`
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
// PacketDays is the retention window for transmissions (#1283).
|
||||
// Ownership moved from cmd/server to cmd/ingestor; 0 disables.
|
||||
PacketDays int `json:"packetDays"`
|
||||
}
|
||||
|
||||
// PacketDaysOrZero returns the configured retention.packetDays or 0
|
||||
// (disabled) if not set.
|
||||
func (c *Config) PacketDaysOrZero() int {
|
||||
if c.Retention != nil && c.Retention.PacketDays > 0 {
|
||||
return c.Retention.PacketDays
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MetricsConfig controls observer metrics collection.
|
||||
|
||||
+245
-8
@@ -126,6 +126,11 @@ type Payload struct {
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
// GRP_DATA (PAYLOAD_TYPE_GRP_DATA=0x06) inner fields, decoded after
|
||||
// channel decrypt per firmware/src/helpers/BaseChatMesh.cpp:382-385.
|
||||
DataType *int `json:"dataType,omitempty"`
|
||||
DataLen *int `json:"dataLen,omitempty"`
|
||||
DecryptedBlob string `json:"decryptedBlob,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Sender string `json:"sender,omitempty"`
|
||||
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
|
||||
@@ -137,6 +142,23 @@ type Payload struct {
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// MULTIPART (PAYLOAD_TYPE_MULTIPART=0x0A) inner fields, decoded per
|
||||
// firmware/src/Mesh.cpp:289 — byte0 = (remaining<<4) | inner_type.
|
||||
Remaining *int `json:"remaining,omitempty"`
|
||||
InnerType *int `json:"innerType,omitempty"`
|
||||
InnerTypeName string `json:"innerTypeName,omitempty"`
|
||||
InnerAckCrc string `json:"innerAckCrc,omitempty"`
|
||||
InnerPayload string `json:"innerPayload,omitempty"`
|
||||
// CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per
|
||||
// firmware/src/Mesh.cpp:69 — byte0 high-bit marks zero-hop direct subset.
|
||||
CtrlFlags string `json:"ctrlFlags,omitempty"`
|
||||
CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"`
|
||||
CtrlLength *int `json:"ctrlLength,omitempty"`
|
||||
// RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per
|
||||
// firmware/src/Mesh.cpp:577 (createRawData). Exposes the bare envelope
|
||||
// shape (length + leading tag) so consumers can triage by app id.
|
||||
RawLength *int `json:"rawLength,omitempty"`
|
||||
FirstByteTag string `json:"firstByteTag,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
@@ -343,6 +365,17 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
|
||||
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
|
||||
// Only sensor nodes (advType=4) carry telemetry bytes.
|
||||
//
|
||||
// Firmware derivation (see firmware/src/helpers/SensorMesh.h and the
|
||||
// SensorHost::handleAdvert path in firmware/src/helpers/SensorMesh.cpp:
|
||||
// the sensor builds appdata as <flags+adv_type><pubkey?><name\0>
|
||||
// followed by two little-endian uint16 fields appended verbatim:
|
||||
// appdata[name_end+0..1] = battery voltage in millivolts (uint16 LE,
|
||||
// valid 0 < mv ≤ 10000)
|
||||
// appdata[name_end+2..3] = temperature × 100 (int16 LE, divide by 100
|
||||
// for °C; valid raw -5000..10000 → -50..100 °C)
|
||||
// We accept only adverts whose flags.Sensor bit is set (firmware
|
||||
// AdvertDataHelpers.h:7-12, ADV_TYPE_SENSOR=4) before parsing telemetry.
|
||||
if p.Flags.Sensor && off+4 <= len(appdata) {
|
||||
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
|
||||
@@ -512,6 +545,185 @@ func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeGrpData decodes PAYLOAD_TYPE_GRP_DATA (0x06). Outer envelope is the
|
||||
// same shape as GRP_TXT (channel_hash(1)+MAC(2)+ciphertext) — see
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:476,500. When the channel key matches,
|
||||
// the decrypted inner is parsed per firmware/src/helpers/BaseChatMesh.cpp:382-385
|
||||
// as data_type(uint16 LE) + data_len(1) + blob(data_len).
|
||||
func decodeGrpData(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_DATA", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
channelHash := int(buf[0])
|
||||
channelHashHex := fmt.Sprintf("%02X", buf[0])
|
||||
mac := hex.EncodeToString(buf[1:3])
|
||||
encryptedData := hex.EncodeToString(buf[3:])
|
||||
|
||||
hasKeys := len(channelKeys) > 0
|
||||
if hasKeys && len(encryptedData) >= 10 {
|
||||
for name, key := range channelKeys {
|
||||
plain, err := decryptChannelBlock(encryptedData, mac, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Inner: data_type(uint16 LE) + data_len(1) + blob (firmware:382-385).
|
||||
if len(plain) < 3 {
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
Error: "inner too short",
|
||||
}
|
||||
}
|
||||
dataType := int(binary.LittleEndian.Uint16(plain[0:2]))
|
||||
dataLen := int(plain[2])
|
||||
if 3+dataLen > len(plain) {
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
DataType: &dataType,
|
||||
DataLen: &dataLen,
|
||||
Error: "inner data_len exceeds buffer",
|
||||
}
|
||||
}
|
||||
blob := hex.EncodeToString(plain[3 : 3+dataLen])
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
DataType: &dataType,
|
||||
DataLen: &dataLen,
|
||||
DecryptedBlob: blob,
|
||||
}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decryption_failed",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "no_key",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMultipart decodes PAYLOAD_TYPE_MULTIPART (0x0A) per
|
||||
// firmware/src/Mesh.cpp:287-310. byte0 = (remaining<<4) | inner_type;
|
||||
// when inner_type == PAYLOAD_TYPE_ACK the next 4 bytes are an ack_crc.
|
||||
func decodeMultipart(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "MULTIPART", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
remaining := int(buf[0] >> 4)
|
||||
innerType := int(buf[0] & 0x0F)
|
||||
innerName := payloadTypeNames[innerType]
|
||||
if innerName == "" {
|
||||
innerName = "UNKNOWN"
|
||||
}
|
||||
p := Payload{
|
||||
Type: "MULTIPART",
|
||||
Remaining: &remaining,
|
||||
InnerType: &innerType,
|
||||
InnerTypeName: innerName,
|
||||
}
|
||||
if innerType == PayloadACK && len(buf) >= 5 {
|
||||
// ack_crc is little-endian; surface as canonical big-endian hex
|
||||
// to match decodeAck's extraHash convention.
|
||||
crc := binary.LittleEndian.Uint32(buf[1:5])
|
||||
p.InnerAckCrc = fmt.Sprintf("%08x", crc)
|
||||
} else if len(buf) > 1 {
|
||||
p.InnerPayload = hex.EncodeToString(buf[1:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per
|
||||
// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset).
|
||||
func decodeControl(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
zeroHop := buf[0]&0x80 != 0
|
||||
length := len(buf)
|
||||
return Payload{
|
||||
Type: "CONTROL",
|
||||
CtrlFlags: fmt.Sprintf("%02x", buf[0]),
|
||||
CtrlZeroHop: &zeroHop,
|
||||
CtrlLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). Application-defined
|
||||
// payload per firmware/src/Mesh.cpp:577 (createRawData); we only surface the
|
||||
// envelope shape (total length + leading tag byte).
|
||||
func decodeRawCustom(buf []byte) Payload {
|
||||
length := len(buf)
|
||||
p := Payload{
|
||||
Type: "RAW_CUSTOM",
|
||||
RawLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
if length > 0 {
|
||||
p.FirstByteTag = fmt.Sprintf("%02X", buf[0])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decryptChannelBlock performs the MAC verify + AES-128-ECB decrypt step shared
|
||||
// by GRP_TXT and GRP_DATA, returning the raw plaintext block (no further
|
||||
// parsing). See firmware/src/helpers/BaseChatMesh.cpp:376-391.
|
||||
func decryptChannelBlock(ciphertextHex, macHex, channelKeyHex string) ([]byte, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calc := h.Sum(nil)
|
||||
if calc[0] != macBytes[0] || calc[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -571,12 +783,20 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string, v
|
||||
return decodeAdvert(buf, validateSignatures)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf, channelKeys)
|
||||
case PayloadGRP_DATA:
|
||||
return decodeGrpData(buf, channelKeys)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
case PayloadMULTIPART:
|
||||
return decodeMultipart(buf)
|
||||
case PayloadCONTROL:
|
||||
return decodeControl(buf)
|
||||
case PayloadRAW_CUSTOM:
|
||||
return decodeRawCustom(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
@@ -824,8 +1044,13 @@ func ValidateAdvert(p *Payload) (bool, string) {
|
||||
|
||||
if p.Flags != nil {
|
||||
role := advertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
// Accept canonical labels plus "none" (ADV_TYPE_NONE=0) and the
|
||||
// "type-N" placeholders we now return for ADV_TYPE 5-15 (FUTURE)
|
||||
// — see firmware/src/helpers/AdvertDataHelpers.h:7-12.
|
||||
validRoles := map[string]bool{
|
||||
"repeater": true, "companion": true, "room": true, "sensor": true, "none": true,
|
||||
}
|
||||
if !validRoles[role] && !strings.HasPrefix(role, "type-") {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
@@ -845,17 +1070,29 @@ func sanitizeName(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// advertRole returns a stable role label for an advert. Follows firmware
|
||||
// ADV_TYPE_* constants in firmware/src/helpers/AdvertDataHelpers.h:7-12:
|
||||
// 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR, 5-15 FUTURE.
|
||||
// Previously this coerced both 0 (NONE) and 5-15 (FUTURE) to "companion",
|
||||
// silently relabelling unknown/reserved types — see issue #1279 P1 #3.
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
if f == nil {
|
||||
return "companion"
|
||||
}
|
||||
switch f.Type {
|
||||
case 0:
|
||||
return "none"
|
||||
case 1:
|
||||
return "companion"
|
||||
case 2:
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
case 3:
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
case 4:
|
||||
return "sensor"
|
||||
default:
|
||||
return fmt.Sprintf("type-%d", f.Type)
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
|
||||
@@ -631,21 +631,28 @@ func TestDecodeEncryptedPayloadValid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePayloadGRPData(t *testing.T) {
|
||||
// GRP_DATA (0x06) decoder added for #1279 P0 #1 — envelope only when no
|
||||
// channel key matches (firmware/src/helpers/BaseChatMesh.cpp:500).
|
||||
buf := []byte{0x01, 0x02, 0x03}
|
||||
p := decodePayload(PayloadGRP_DATA, buf, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("type=%s, want UNKNOWN", p.Type)
|
||||
}
|
||||
if p.RawHex != "010203" {
|
||||
t.Errorf("rawHex=%s, want 010203", p.RawHex)
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Errorf("type=%s, want GRP_DATA", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadRAWCustom(t *testing.T) {
|
||||
// #1279 P2 #5: RAW_CUSTOM (0x0F) now exposes envelope shape (length +
|
||||
// first-byte tag) per firmware/src/Mesh.cpp:577 (createRawData).
|
||||
buf := []byte{0xFF, 0xFE}
|
||||
p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("type=%s, want UNKNOWN", p.Type)
|
||||
if p.Type != "RAW_CUSTOM" {
|
||||
t.Errorf("type=%s, want RAW_CUSTOM", p.Type)
|
||||
}
|
||||
if p.RawLength == nil || *p.RawLength != 2 {
|
||||
t.Errorf("rawLength missing or wrong, want 2")
|
||||
}
|
||||
if p.FirstByteTag != "FF" {
|
||||
t.Errorf("firstByteTag=%q, want FF", p.FirstByteTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,18 +1104,18 @@ func TestDecodeHeaderUnknownTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePayloadMultipart(t *testing.T) {
|
||||
// MULTIPART (0x0A) falls through to default → UNKNOWN
|
||||
// MULTIPART (0x0A) now decoded — #1279 P0 #2 (firmware/src/Mesh.cpp:289).
|
||||
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type)
|
||||
if p.Type != "MULTIPART" {
|
||||
t.Errorf("MULTIPART type=%s, want MULTIPART", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadControl(t *testing.T) {
|
||||
// CONTROL (0x0B) falls through to default → UNKNOWN
|
||||
// CONTROL (0x0B) now decoded — #1279 P1 #4 (firmware/src/Mesh.cpp:69).
|
||||
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type)
|
||||
if p.Type != "CONTROL" {
|
||||
t.Errorf("CONTROL type=%s, want CONTROL", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P2 item 5: ingestor RAW_CUSTOM exposure.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeRawCustomExposesLengthAndTag(t *testing.T) {
|
||||
// header = (1<<6)|(0x0F<<2)|1 = 0x7D ; path byte = 0x00 ; payload = A5 DE AD BE EF
|
||||
hexStr := "7D00A5DEADBEEF"
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "RAW_CUSTOM" {
|
||||
t.Fatalf("payload type = %q, want RAW_CUSTOM", pkt.Payload.Type)
|
||||
}
|
||||
if pkt.Payload.RawLength == nil || *pkt.Payload.RawLength != 5 {
|
||||
got := -1
|
||||
if pkt.Payload.RawLength != nil {
|
||||
got = *pkt.Payload.RawLength
|
||||
}
|
||||
t.Errorf("RawLength=%d, want 5", got)
|
||||
}
|
||||
if !strings.EqualFold(pkt.Payload.FirstByteTag, "A5") {
|
||||
t.Errorf("FirstByteTag=%q, want A5", pkt.Payload.FirstByteTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P0+P1 decoder additions.
|
||||
//
|
||||
// Each test uses firmware-derived wire vectors:
|
||||
// - GRP_DATA outer: firmware/src/helpers/BaseChatMesh.cpp:500 (createGroupDatagram)
|
||||
// - GRP_DATA inner: firmware/src/helpers/BaseChatMesh.cpp:382-385
|
||||
// - MULTIPART byte0: firmware/src/Mesh.cpp:289
|
||||
// - MULTIPART ACK inner: firmware/src/Mesh.cpp:292-307
|
||||
// - CONTROL byte0 flags: firmware/src/Mesh.cpp:69 + createControlData at Mesh.cpp:609
|
||||
// - advertRole label rules: firmware/src/helpers/AdvertDataHelpers.h:7-12
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- P0 #1: GRP_DATA decoder ---
|
||||
|
||||
// buildChannelEncrypted encrypts arbitrary inner bytes with the channel
|
||||
// key/MAC scheme firmware uses for both GRP_TXT and GRP_DATA (see
|
||||
// BaseChatMesh.cpp:376-391: AES-128-ECB, HMAC-SHA256-trunc-2 MAC).
|
||||
func buildChannelEncrypted(channelKeyHex string, inner []byte) (ctHex, macHex string) {
|
||||
key, _ := hex.DecodeString(channelKeyHex)
|
||||
plain := append([]byte{}, inner...)
|
||||
pad := aes.BlockSize - (len(plain) % aes.BlockSize)
|
||||
if pad != aes.BlockSize {
|
||||
plain = append(plain, make([]byte, pad)...)
|
||||
}
|
||||
block, _ := aes.NewCipher(key)
|
||||
ct := make([]byte, len(plain))
|
||||
for i := 0; i < len(plain); i += aes.BlockSize {
|
||||
block.Encrypt(ct[i:i+aes.BlockSize], plain[i:i+aes.BlockSize])
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
copy(secret, key)
|
||||
h := hmac.New(sha256.New, secret)
|
||||
h.Write(ct)
|
||||
mac := h.Sum(nil)
|
||||
return hex.EncodeToString(ct), hex.EncodeToString(mac[:2])
|
||||
}
|
||||
|
||||
func TestDecodeGrpDataNoKey(t *testing.T) {
|
||||
// Envelope alone (no key in store).
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11}
|
||||
p := decodeGrpData(buf, nil)
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Fatalf("type=%q want GRP_DATA", p.Type)
|
||||
}
|
||||
if p.ChannelHash != 0xAA {
|
||||
t.Errorf("channelHash=%d want 170", p.ChannelHash)
|
||||
}
|
||||
if p.ChannelHashHex != "AA" {
|
||||
t.Errorf("channelHashHex=%q want AA", p.ChannelHashHex)
|
||||
}
|
||||
if p.MAC != "bbcc" {
|
||||
t.Errorf("mac=%q want bbcc", p.MAC)
|
||||
}
|
||||
if p.EncryptedData != "ddeeff11" {
|
||||
t.Errorf("encryptedData=%q want ddeeff11", p.EncryptedData)
|
||||
}
|
||||
if p.DecryptionStatus != "no_key" {
|
||||
t.Errorf("decryptionStatus=%q want no_key", p.DecryptionStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGrpDataDecryptedInner(t *testing.T) {
|
||||
// Inner per BaseChatMesh.cpp:382-385: data_type(uint16 LE) + data_len(1) + blob.
|
||||
key := "2cc3d22840e086105ad73443da2cacb8"
|
||||
blob := []byte{0x10, 0x20, 0x30, 0x40, 0x50}
|
||||
inner := []byte{0x34, 0x12, byte(len(blob))} // data_type = 0x1234
|
||||
inner = append(inner, blob...)
|
||||
ctHex, macHex := buildChannelEncrypted(key, inner)
|
||||
|
||||
buf := []byte{0xAB}
|
||||
mb, _ := hex.DecodeString(macHex)
|
||||
buf = append(buf, mb...)
|
||||
cb, _ := hex.DecodeString(ctHex)
|
||||
buf = append(buf, cb...)
|
||||
|
||||
p := decodeGrpData(buf, map[string]string{"test": key})
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Fatalf("type=%q want GRP_DATA", p.Type)
|
||||
}
|
||||
if p.DecryptionStatus != "decrypted" {
|
||||
t.Fatalf("decryptionStatus=%q want decrypted", p.DecryptionStatus)
|
||||
}
|
||||
if p.DataType == nil || *p.DataType != 0x1234 {
|
||||
t.Errorf("dataType=%v want 0x1234", p.DataType)
|
||||
}
|
||||
if p.DataLen == nil || *p.DataLen != 5 {
|
||||
t.Errorf("dataLen=%v want 5", p.DataLen)
|
||||
}
|
||||
if p.DecryptedBlob != hex.EncodeToString(blob) {
|
||||
t.Errorf("decryptedBlob=%q want %q", p.DecryptedBlob, hex.EncodeToString(blob))
|
||||
}
|
||||
if p.Channel != "test" {
|
||||
t.Errorf("channel=%q want test", p.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
// --- P0 #2: MULTIPART decoder ---
|
||||
|
||||
func TestDecodeMultipartAck(t *testing.T) {
|
||||
// remaining=3, inner_type=PAYLOAD_TYPE_ACK(0x03), ack_crc=0xDEADBEEF.
|
||||
// byte0 = (3<<4) | 3 = 0x33; next 4 bytes are LE crc.
|
||||
buf := []byte{0x33, 0xEF, 0xBE, 0xAD, 0xDE}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Type != "MULTIPART" {
|
||||
t.Fatalf("type=%q want MULTIPART", p.Type)
|
||||
}
|
||||
if p.Remaining == nil || *p.Remaining != 3 {
|
||||
t.Errorf("remaining=%v want 3", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x03 {
|
||||
t.Errorf("innerType=%v want 3", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "ACK" {
|
||||
t.Errorf("innerTypeName=%q want ACK", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerAckCrc != "deadbeef" {
|
||||
t.Errorf("innerAckCrc=%q want deadbeef", p.InnerAckCrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMultipartNonAck(t *testing.T) {
|
||||
// remaining=2, inner_type=0x02 (TXT_MSG), arbitrary inner payload.
|
||||
buf := []byte{0x22, 0x01, 0x02, 0x03}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Remaining == nil || *p.Remaining != 2 {
|
||||
t.Errorf("remaining=%v want 2", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x02 {
|
||||
t.Errorf("innerType=%v want 2", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "TXT_MSG" {
|
||||
t.Errorf("innerTypeName=%q want TXT_MSG", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerPayload != "010203" {
|
||||
t.Errorf("innerPayload=%q want 010203", p.InnerPayload)
|
||||
}
|
||||
if p.InnerAckCrc != "" {
|
||||
t.Errorf("non-ACK should not surface innerAckCrc, got %q", p.InnerAckCrc)
|
||||
}
|
||||
}
|
||||
|
||||
// --- P1 #3: advertRole label fix ---
|
||||
|
||||
func TestAdvertRoleLabelsRawType(t *testing.T) {
|
||||
// Firmware: ADV_TYPE_NONE=0, CHAT=1, REPEATER=2, ROOM=3, SENSOR=4, 5..15 FUTURE.
|
||||
cases := []struct {
|
||||
typ int
|
||||
want string
|
||||
}{
|
||||
{0, "none"},
|
||||
{1, "companion"},
|
||||
{2, "repeater"},
|
||||
{3, "room"},
|
||||
{4, "sensor"},
|
||||
{5, "type-5"},
|
||||
{15, "type-15"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := advertRole(&AdvertFlags{Type: tc.typ, Repeater: tc.typ == 2, Room: tc.typ == 3, Sensor: tc.typ == 4})
|
||||
if got != tc.want {
|
||||
t.Errorf("advertRole(type=%d) = %q, want %q", tc.typ, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- P1 #4: CONTROL byte0 flags ---
|
||||
|
||||
func TestDecodeControlZeroHop(t *testing.T) {
|
||||
// byte0 = 0x81 (high-bit set ⇒ zero-hop), followed by 3 app bytes.
|
||||
buf := []byte{0x81, 0xAA, 0xBB, 0xCC}
|
||||
p := decodeControl(buf)
|
||||
if p.Type != "CONTROL" {
|
||||
t.Fatalf("type=%q want CONTROL", p.Type)
|
||||
}
|
||||
if p.CtrlFlags != "81" {
|
||||
t.Errorf("ctrlFlags=%q want 81", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || !*p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want true", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 4 {
|
||||
t.Errorf("ctrlLength=%v want 4", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlMultiHop(t *testing.T) {
|
||||
// byte0 = 0x01 (high-bit clear ⇒ not zero-hop subset).
|
||||
buf := []byte{0x01, 0x42}
|
||||
p := decodeControl(buf)
|
||||
if p.CtrlFlags != "01" {
|
||||
t.Errorf("ctrlFlags=%q want 01", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || *p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want false", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 2 {
|
||||
t.Errorf("ctrlLength=%v want 2", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
|
||||
// silence unused-import diagnostics for stub-phase builds
|
||||
var _ = binary.LittleEndian
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestIngestorPruneOldPackets enforces #1283: the writer for
|
||||
// transmissions retention lives on the ingestor's *Store. Before the fix,
|
||||
// this lived on cmd/server/*DB and raced with ingestor INSERTs. After
|
||||
// the fix, ingestor owns it and runs it on its own write-locked handle.
|
||||
func TestIngestorPruneOldPackets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "prune.db")
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
old := time.Now().UTC().AddDate(0, 0, -10).Format(time.RFC3339)
|
||||
new := time.Now().UTC().Format(time.RFC3339)
|
||||
for i, ts := range []string{old, old, new} {
|
||||
_, err := store.db.Exec(
|
||||
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (?, ?, ?, 0, 1, 1, '{}')`,
|
||||
"AA", "h"+string(rune('a'+i)), ts,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed tx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := store.PruneOldPackets(5)
|
||||
if err != nil {
|
||||
t.Fatalf("PruneOldPackets: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("expected 2 pruned, got %d", n)
|
||||
}
|
||||
|
||||
var remaining int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM transmissions`).Scan(&remaining); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Fatalf("expected 1 transmission remaining, got %d", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestorVacuumOnStartupMigratesNONEtoINCREMENTAL exercises the
|
||||
// scenario that originally broke in #1283: a fresh DB with
|
||||
// auto_vacuum=NONE, vacuumOnStartup=true, no contention from a server
|
||||
// process. The ingestor must complete the VACUUM and flip auto_vacuum to
|
||||
// INCREMENTAL. Before the fix, the migration ran inside cmd/server and
|
||||
// hit SQLITE_BUSY because the ingestor (sharing the container) was
|
||||
// already writing.
|
||||
func TestIngestorVacuumOnStartupMigratesNONEtoINCREMENTAL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "vac.db")
|
||||
|
||||
// Create a NONE-auto_vacuum DB (simulates an older deployment).
|
||||
seed, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.SetMaxOpenConns(1)
|
||||
if _, err := seed.Exec(`CREATE TABLE dummy(id INTEGER PRIMARY KEY)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before int
|
||||
seed.QueryRow("PRAGMA auto_vacuum").Scan(&before)
|
||||
if before != 0 {
|
||||
t.Fatalf("precondition: auto_vacuum=%d, want 0", before)
|
||||
}
|
||||
seed.Close()
|
||||
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &Config{DB: &DBConfig{VacuumOnStartup: true}}
|
||||
store.CheckAutoVacuum(cfg)
|
||||
|
||||
var after int
|
||||
if err := store.db.QueryRow("PRAGMA auto_vacuum").Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 after ingestor VACUUM, got %d", after)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,19 @@ func main() {
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
store.PruneDroppedPackets(metricsDays)
|
||||
|
||||
// Packet (transmissions) retention: previously lived in cmd/server,
|
||||
// moved to ingestor in #1283 to eliminate cross-process write
|
||||
// contention (SQLITE_BUSY). 0 = disabled.
|
||||
packetDays := cfg.PacketDaysOrZero()
|
||||
if packetDays > 0 {
|
||||
if n, err := store.PruneOldPackets(packetDays); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[prune] startup pruned %d transmissions older than %d days", n, packetDays)
|
||||
}
|
||||
}
|
||||
|
||||
vacuumPages := cfg.IncrementalVacuumPages()
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
|
||||
@@ -111,6 +124,22 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Daily ticker for transmission retention (#1283).
|
||||
var packetRetentionTicker *time.Ticker
|
||||
if packetDays > 0 {
|
||||
packetRetentionTicker = time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range packetRetentionTicker.C {
|
||||
if n, err := store.PruneOldPackets(packetDays); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", packetDays)
|
||||
}
|
||||
|
||||
// Periodic stats logging (every 5 minutes)
|
||||
statsTicker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
@@ -253,6 +282,9 @@ func main() {
|
||||
log.Println("Shutting down...")
|
||||
retentionTicker.Stop()
|
||||
metricsRetentionTicker.Stop()
|
||||
if packetRetentionTicker != nil {
|
||||
packetRetentionTicker.Stop()
|
||||
}
|
||||
statsTicker.Stop()
|
||||
stopWatchdog()
|
||||
store.LogStats() // final stats on shutdown
|
||||
|
||||
@@ -480,18 +480,20 @@ func TestEpochToISO(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdvertRole(t *testing.T) {
|
||||
// advertRole now keys off AdvertFlags.Type (firmware ADV_TYPE_*) — see
|
||||
// firmware/src/helpers/AdvertDataHelpers.h:7-12 and issue #1279 P1 #3.
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *AdvertFlags
|
||||
want string
|
||||
}{
|
||||
{"repeater", &AdvertFlags{Repeater: true}, "repeater"},
|
||||
{"room", &AdvertFlags{Room: true}, "room"},
|
||||
{"sensor", &AdvertFlags{Sensor: true}, "sensor"},
|
||||
{"companion (default)", &AdvertFlags{Chat: true}, "companion"},
|
||||
{"companion (no flags)", &AdvertFlags{}, "companion"},
|
||||
{"repeater takes priority", &AdvertFlags{Repeater: true, Room: true}, "repeater"},
|
||||
{"room before sensor", &AdvertFlags{Room: true, Sensor: true}, "room"},
|
||||
{"none (type 0)", &AdvertFlags{Type: 0}, "none"},
|
||||
{"companion (type 1)", &AdvertFlags{Type: 1, Chat: true}, "companion"},
|
||||
{"repeater (type 2)", &AdvertFlags{Type: 2, Repeater: true}, "repeater"},
|
||||
{"room (type 3)", &AdvertFlags{Type: 3, Room: true}, "room"},
|
||||
{"sensor (type 4)", &AdvertFlags{Type: 4, Sensor: true}, "sensor"},
|
||||
{"future type-5", &AdvertFlags{Type: 5}, "type-5"},
|
||||
{"nil flags falls back to companion", nil, "companion"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PruneOldPackets deletes transmissions (and their child observations)
|
||||
// older than `days`. Returns count of transmissions deleted.
|
||||
//
|
||||
// Owned by the ingestor per #1283: the writer process is the only one
|
||||
// allowed to hold the DB write lock; previously this lived in
|
||||
// cmd/server/db.go and raced ingestor INSERTs (SQLITE_BUSY).
|
||||
func (s *Store) PruneOldPackets(days int) (int64, error) {
|
||||
if days <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete child observations first (no CASCADE in SQLite).
|
||||
if _, err := tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
|
||||
SELECT id FROM transmissions WHERE first_seen < ?
|
||||
)`, cutoff); err != nil {
|
||||
return 0, fmt.Errorf("prune observations: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune transmissions: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("prune commit: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -146,13 +146,15 @@ func (r *analyticsRecomputer) ComputeRuns() int64 {
|
||||
// per-endpoint recompute interval from config.json. Zero values fall
|
||||
// back to the defaultInterval passed to StartAnalyticsRecomputers.
|
||||
type AnalyticsRecomputeIntervals struct {
|
||||
Topology time.Duration
|
||||
RF time.Duration
|
||||
Distance time.Duration
|
||||
Channels time.Duration
|
||||
HashCollisions time.Duration
|
||||
HashSizes time.Duration
|
||||
Roles time.Duration
|
||||
Topology time.Duration
|
||||
RF time.Duration
|
||||
Distance time.Duration
|
||||
Channels time.Duration
|
||||
HashCollisions time.Duration
|
||||
HashSizes time.Duration
|
||||
Roles time.Duration
|
||||
ObserversClockSkew time.Duration
|
||||
NodesClockSkew time.Duration
|
||||
}
|
||||
|
||||
func pickInterval(override, def time.Duration) time.Duration {
|
||||
@@ -224,10 +226,19 @@ func (s *PacketStore) StartAnalyticsRecomputers(defaultInterval time.Duration, o
|
||||
"roles", pickInterval(ov.Roles, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsRoles() },
|
||||
)
|
||||
s.recompObserversClockSkew = newAnalyticsRecomputer(
|
||||
"observers-clock-skew", pickInterval(ov.ObserversClockSkew, defaultInterval),
|
||||
func() interface{} { return s.computeObserverCalibrations() },
|
||||
)
|
||||
s.recompNodesClockSkew = newAnalyticsRecomputer(
|
||||
"nodes-clock-skew", pickInterval(ov.NodesClockSkew, defaultInterval),
|
||||
func() interface{} { return s.computeFleetClockSkew() },
|
||||
)
|
||||
all := []*analyticsRecomputer{
|
||||
s.recompTopology, s.recompRF, s.recompDistance,
|
||||
s.recompChannels, s.recompHashCollisions, s.recompHashSizes,
|
||||
s.recompRoles,
|
||||
s.recompObserversClockSkew, s.recompNodesClockSkew,
|
||||
}
|
||||
s.analyticsRecomputerMu.Unlock()
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestBridgeScore_HandleNodesSurface verifies that /api/nodes
|
||||
// includes a `bridge_score` field on repeater rows after the bridge
|
||||
// recomputer has run. Drives the line-graph A-B-C-D through the full
|
||||
// pipeline: insert nodes, populate the neighbor graph, force a
|
||||
// recompute, hit the handler, parse the response. Issue #672 axis 2.
|
||||
func TestBridgeScore_HandleNodesSurface(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
// handleNodes/db.GetNodes selects a foreign_advert column not in
|
||||
// the minimal capability-test schema.
|
||||
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Four repeater nodes in a line.
|
||||
pks := []string{
|
||||
"aaaa000000000000000000000000000000000000000000000000000000000000",
|
||||
"bbbb000000000000000000000000000000000000000000000000000000000000",
|
||||
"cccc000000000000000000000000000000000000000000000000000000000000",
|
||||
"dddd000000000000000000000000000000000000000000000000000000000000",
|
||||
}
|
||||
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
for _, pk := range pks {
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, 'repeater', 37.5, -122.0, ?, ?, 10)`,
|
||||
pk, "node-"+pk[:4], recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
// Build neighbor graph with the line A-B-C-D. Add each edge
|
||||
// `count` times so its time-decayed Score saturates.
|
||||
g := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
obs := "obs-test"
|
||||
snr := 5.0
|
||||
for i := 0; i < 10; i++ {
|
||||
g.upsertEdge(pks[0], pks[1], "aa", obs, &snr, now)
|
||||
g.upsertEdge(pks[1], pks[2], "bb", obs, &snr, now)
|
||||
g.upsertEdge(pks[2], pks[3], "cc", obs, &snr, now)
|
||||
}
|
||||
store.graph.Store(g)
|
||||
|
||||
// Direct invocation of the recomputer's compute path — bypassing
|
||||
// StartBridgeScoreRecomputer's package-level once-flag (which is
|
||||
// problematic across tests).
|
||||
recomputeBridgeScoresSafe(store)
|
||||
|
||||
snap := store.GetBridgeScoreMap()
|
||||
if len(snap) == 0 {
|
||||
t.Fatalf("expected non-empty bridge score snapshot, got empty")
|
||||
}
|
||||
// Sanity: middle nodes b/c must be positive, ends must be zero.
|
||||
if snap[pks[1]] <= 0 || snap[pks[2]] <= 0 {
|
||||
t.Errorf("middle nodes should have positive bridge: b=%v c=%v",
|
||||
snap[pks[1]], snap[pks[2]])
|
||||
}
|
||||
if snap[pks[0]] != 0 || snap[pks[3]] != 0 {
|
||||
t.Errorf("end nodes should have zero bridge: a=%v d=%v",
|
||||
snap[pks[0]], snap[pks[3]])
|
||||
}
|
||||
|
||||
// Wire a Server, call handleNodes, parse the response.
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("handleNodes status: want 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
gotBy := map[string]map[string]interface{}{}
|
||||
for _, n := range resp.Nodes {
|
||||
if pk, _ := n["public_key"].(string); pk != "" {
|
||||
gotBy[pk] = n
|
||||
}
|
||||
}
|
||||
for _, pk := range pks {
|
||||
n, ok := gotBy[pk]
|
||||
if !ok {
|
||||
t.Errorf("node %s missing from response", pk[:4])
|
||||
continue
|
||||
}
|
||||
if _, has := n["bridge_score"]; !has {
|
||||
t.Errorf("node %s: bridge_score field absent from response", pk[:4])
|
||||
}
|
||||
}
|
||||
// Middle node B must report a non-zero bridge_score; end node A
|
||||
// must report exactly zero. These two assertions together prevent
|
||||
// a "field present but always 0" regression.
|
||||
if v, _ := gotBy[pks[1]]["bridge_score"].(float64); v <= 0 {
|
||||
t.Errorf("middle node B bridge_score in API response should be > 0, got %v", v)
|
||||
}
|
||||
if v, _ := gotBy[pks[0]]["bridge_score"].(float64); v != 0 {
|
||||
t.Errorf("end node A bridge_score in API response should be 0, got %v", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package main: bridge-axis recomputer (issue #672 axis 2 of 4).
|
||||
//
|
||||
// Steady-state background loop that recomputes the per-pubkey bridge
|
||||
// centrality score over the in-memory NeighborGraph and stores the
|
||||
// resulting map atomically. handleNodes reads via a single atomic
|
||||
// load — no lock contention with ingest or with other recomputers
|
||||
// (same pattern as #1240 / #1248).
|
||||
//
|
||||
// Interval default: 5 minutes. The graph itself rebuilds asynchronously
|
||||
// on its own schedule (path_inspect.go); a 5-minute cadence here is
|
||||
// well within the freshness budget for a structural metric (centrality
|
||||
// changes slowly — a new edge or evicted node nudges scores by
|
||||
// fractions of a percent).
|
||||
//
|
||||
// Cost (Brandes + Dijkstra): O(V · (E + V log V)). Staging-scale ~600
|
||||
// nodes / ~2 000 edges ≈ ~4.8M ops, well under 100 ms in practice. On
|
||||
// host-fleet scale (5 000 nodes / 30 000 edges) it is still seconds,
|
||||
// running in a background goroutine off the request path.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bridgeRecomputerDefaultInterval is how often the bridge score map is
|
||||
// rebuilt. 5 minutes mirrors analytics_recomputer (#1240) and
|
||||
// repeater_enrich_recomputer (#1262); centrality is a slow-moving
|
||||
// structural signal and does not warrant tighter cadence.
|
||||
const bridgeRecomputerDefaultInterval = 5 * time.Minute
|
||||
|
||||
// bridgeRecompStartedMu serializes start of the bridge recomputer.
|
||||
// We do not currently expose Stop publicly — the goroutine lives for
|
||||
// the lifetime of the process — but keeping the started flag local
|
||||
// (instead of on PacketStore) avoids further field churn in store.go.
|
||||
var (
|
||||
bridgeRecompStartedMu sync.Mutex
|
||||
bridgeRecompStarted bool
|
||||
)
|
||||
|
||||
// StartBridgeScoreRecomputer launches the bridge-centrality recomputer
|
||||
// (issue #672 axis 2). It performs an initial synchronous compute so
|
||||
// that the very first /api/nodes after server start hits a populated
|
||||
// snapshot instead of returning bridge_score=0 for every node, then
|
||||
// reschedules every `interval` (default 5min if <= 0).
|
||||
//
|
||||
// Idempotent: subsequent calls are no-ops and return a no-op stop
|
||||
// closure.
|
||||
func (s *PacketStore) StartBridgeScoreRecomputer(interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = bridgeRecomputerDefaultInterval
|
||||
}
|
||||
|
||||
bridgeRecompStartedMu.Lock()
|
||||
if bridgeRecompStarted {
|
||||
bridgeRecompStartedMu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
bridgeRecompStarted = true
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
bridgeRecompStartedMu.Unlock()
|
||||
|
||||
// Initial synchronous prewarm — see comment above.
|
||||
recomputeBridgeScoresSafe(s)
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
defer close(done)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
recomputeBridgeScoresSafe(s)
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
close(stop)
|
||||
})
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeBridgeScoresSafe runs ComputeBridgeScores over the current
|
||||
// neighbor graph and installs the result. Panics in compute are
|
||||
// swallowed (defensive) so the goroutine never dies; the previous
|
||||
// snapshot remains valid.
|
||||
func recomputeBridgeScoresSafe(s *PacketStore) {
|
||||
defer func() { _ = recover() }()
|
||||
graph := s.graph.Load()
|
||||
if graph == nil {
|
||||
// No graph yet — install an empty map so readers get a defined
|
||||
// zero rather than a nil sentinel (handleNodes treats both as
|
||||
// 0.0, but an explicit empty snapshot avoids "is this ready
|
||||
// yet?" confusion in operator-facing tooling).
|
||||
empty := map[string]float64{}
|
||||
s.bridgeScoreMap.Store(&empty)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
edges := bridgeEdgesFromGraph(graph, now)
|
||||
scores := ComputeBridgeScores(edges)
|
||||
s.bridgeScoreMap.Store(&scores)
|
||||
}
|
||||
|
||||
// bridgeEdgesFromGraph snapshots the NeighborGraph into a flat slice
|
||||
// of BridgeEdge tuples with weight = Score(now) * Confidence(), per
|
||||
// the convention established by #1235. Edges with unresolved B
|
||||
// endpoints (no concrete pubkey yet — only a hop prefix) are skipped:
|
||||
// they contribute no betweenness signal because the second endpoint
|
||||
// is unknown.
|
||||
func bridgeEdgesFromGraph(graph *NeighborGraph, now time.Time) []BridgeEdge {
|
||||
all := graph.AllEdges()
|
||||
out := make([]BridgeEdge, 0, len(all))
|
||||
for _, e := range all {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if e.NodeA == "" || e.NodeB == "" {
|
||||
// Unresolved (prefix-only) — no defined second endpoint.
|
||||
continue
|
||||
}
|
||||
w := e.Score(now) * e.Confidence()
|
||||
if w < bridgeMinWeightEpsilon {
|
||||
continue
|
||||
}
|
||||
out = append(out, BridgeEdge{A: e.NodeA, B: e.NodeB, Weight: w})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetBridgeScore returns the bridge centrality score for a pubkey in
|
||||
// [0, 1], or 0 if the recomputer has not run yet or the pubkey is not
|
||||
// in the graph. Lookup is case-insensitive (the score map keys are
|
||||
// lowercase, matching byPathHop convention).
|
||||
func (s *PacketStore) GetBridgeScore(pubkey string) float64 {
|
||||
if pubkey == "" {
|
||||
return 0
|
||||
}
|
||||
snap := s.bridgeScoreMap.Load()
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
m := *snap
|
||||
if v, ok := m[pubkey]; ok {
|
||||
return v
|
||||
}
|
||||
// Try lowercase form.
|
||||
lc := pubkey
|
||||
for i := 0; i < len(lc); i++ {
|
||||
if lc[i] >= 'A' && lc[i] <= 'Z' {
|
||||
b := []byte(pubkey)
|
||||
for j := i; j < len(b); j++ {
|
||||
if b[j] >= 'A' && b[j] <= 'Z' {
|
||||
b[j] += 'a' - 'A'
|
||||
}
|
||||
}
|
||||
lc = string(b)
|
||||
break
|
||||
}
|
||||
}
|
||||
if v, ok := m[lc]; ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetBridgeScoreMap returns a defensive copy-by-reference of the
|
||||
// current bridge score snapshot. Nil-safe: returns an empty map if
|
||||
// no snapshot has been installed yet. Map is read-only by convention
|
||||
// — callers MUST NOT mutate it (the snapshot is shared across all
|
||||
// concurrent readers).
|
||||
func (s *PacketStore) GetBridgeScoreMap() map[string]float64 {
|
||||
snap := s.bridgeScoreMap.Load()
|
||||
if snap == nil {
|
||||
return map[string]float64{}
|
||||
}
|
||||
return *snap
|
||||
}
|
||||
|
||||
// resetBridgeRecomputerForTest is a test-only helper to allow the
|
||||
// integration test to re-Start the recomputer in a fresh process
|
||||
// (which would otherwise be blocked by the package-level
|
||||
// bridgeRecompStarted flag). Production code must not call this.
|
||||
func resetBridgeRecomputerForTest() {
|
||||
bridgeRecompStartedMu.Lock()
|
||||
bridgeRecompStarted = false
|
||||
bridgeRecompStartedMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package main: bridge axis of repeater usefulness score (issue #672,
|
||||
// axis 2 of 4). The "Bridge" signal is the betweenness centrality of a
|
||||
// node in the (undirected, weighted) neighbor graph: a high value means
|
||||
// the node lies on many shortest paths between other pairs and is hence
|
||||
// structurally important — removing it would force traffic around or
|
||||
// fragment the mesh.
|
||||
//
|
||||
// Algorithm: Brandes' algorithm (1) with Dijkstra for weighted
|
||||
// shortest paths. Complexity O(V · (E + V log V)). For the staging
|
||||
// graph (~600 nodes, ~2 000 edges) this is ~4.8M ops — trivial,
|
||||
// completes in milliseconds. We accumulate raw betweenness across all
|
||||
// sources, halve (an undirected pair is counted from each endpoint
|
||||
// once), then normalize by the max observed value so the per-node
|
||||
// score is in [0, 1].
|
||||
//
|
||||
// Edge weight follows the convention established by #1235: the
|
||||
// affinity score (count + recency decay) is multiplied by the
|
||||
// observer-diversity confidence — stronger, more corroborated
|
||||
// neighborships are preferred when there is a choice of paths.
|
||||
// Geo-rejected edges are already excluded from the input graph at
|
||||
// build time (#1230) so we don't have to re-filter here.
|
||||
//
|
||||
// For Dijkstra we need a DISTANCE (lower = better) not an affinity
|
||||
// (higher = better), so we convert: cost = 1 / max(epsilon, weight).
|
||||
// epsilon avoids divide-by-zero on a degenerate zero-weight edge.
|
||||
//
|
||||
// (1) Brandes, "A Faster Algorithm for Betweenness Centrality" (2001).
|
||||
package main
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BridgeEdge is the algorithm-facing edge tuple consumed by
|
||||
// ComputeBridgeScores. Endpoints A and B are pubkeys (case preserved
|
||||
// by caller; we lowercase internally for stable keying). Weight is
|
||||
// the affinity (higher = stronger connection). Edges with zero or
|
||||
// negative weight are skipped — they would break Dijkstra's
|
||||
// relaxation invariant.
|
||||
type BridgeEdge struct {
|
||||
A, B string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// bridgeMinWeightEpsilon is the floor applied to weights before we
|
||||
// invert them into Dijkstra distances. 1e-9 is small enough that any
|
||||
// real weight (Score in [0,1] times Confidence in [0,1]) dominates,
|
||||
// but large enough to avoid Inf when weight is exactly zero.
|
||||
const bridgeMinWeightEpsilon = 1e-9
|
||||
|
||||
// ComputeBridgeScores returns a map pubkey → bridge score in [0, 1]
|
||||
// computed via Brandes' weighted betweenness centrality on the
|
||||
// undirected graph defined by `edges`. Returned map is keyed by the
|
||||
// lowercase pubkey form (matching the byPathHop / persisted-edge
|
||||
// convention). Nodes appearing in the graph but with zero betweenness
|
||||
// are still present in the map with value 0.0.
|
||||
//
|
||||
// Self-loops (A == B) and edges with weight < epsilon are silently
|
||||
// skipped. Duplicate edges between the same pair keep the cheapest
|
||||
// (= the highest-weight) version — consistent with shortest-path
|
||||
// semantics.
|
||||
//
|
||||
// Pure (no global state, no locks); safe to call concurrently.
|
||||
// Cost: O(V · (E + V log V)).
|
||||
func ComputeBridgeScores(edges []BridgeEdge) map[string]float64 {
|
||||
// 1. Build adjacency list with distance = 1/weight.
|
||||
adj := make(map[string]map[string]float64)
|
||||
addOrMerge := func(a, b string, dist float64) {
|
||||
m, ok := adj[a]
|
||||
if !ok {
|
||||
m = make(map[string]float64)
|
||||
adj[a] = m
|
||||
}
|
||||
if existing, has := m[b]; !has || dist < existing {
|
||||
m[b] = dist
|
||||
}
|
||||
}
|
||||
for _, e := range edges {
|
||||
a := strings.ToLower(strings.TrimSpace(e.A))
|
||||
b := strings.ToLower(strings.TrimSpace(e.B))
|
||||
if a == "" || b == "" || a == b {
|
||||
continue
|
||||
}
|
||||
w := e.Weight
|
||||
if w < bridgeMinWeightEpsilon {
|
||||
continue
|
||||
}
|
||||
dist := 1.0 / w
|
||||
addOrMerge(a, b, dist)
|
||||
addOrMerge(b, a, dist)
|
||||
}
|
||||
if len(adj) == 0 {
|
||||
return map[string]float64{}
|
||||
}
|
||||
|
||||
nodes := make([]string, 0, len(adj))
|
||||
for n := range adj {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
bc := make(map[string]float64, len(nodes))
|
||||
for _, n := range nodes {
|
||||
bc[n] = 0
|
||||
}
|
||||
|
||||
// 2. Brandes outer loop: one Dijkstra-based single-source shortest
|
||||
// path computation per source vertex.
|
||||
for _, s := range nodes {
|
||||
stack := make([]string, 0, len(nodes))
|
||||
pred := make(map[string][]string, len(nodes))
|
||||
sigma := make(map[string]float64, len(nodes))
|
||||
dist := make(map[string]float64, len(nodes))
|
||||
for _, n := range nodes {
|
||||
sigma[n] = 0
|
||||
dist[n] = math.Inf(1)
|
||||
}
|
||||
sigma[s] = 1
|
||||
dist[s] = 0
|
||||
|
||||
pq := &bridgePQ{}
|
||||
heap.Init(pq)
|
||||
heap.Push(pq, bridgePQItem{node: s, dist: 0})
|
||||
|
||||
visited := make(map[string]bool, len(nodes))
|
||||
for pq.Len() > 0 {
|
||||
top := heap.Pop(pq).(bridgePQItem)
|
||||
v := top.node
|
||||
if visited[v] {
|
||||
continue
|
||||
}
|
||||
visited[v] = true
|
||||
stack = append(stack, v)
|
||||
|
||||
for w, edgeDist := range adj[v] {
|
||||
alt := dist[v] + edgeDist
|
||||
if alt < dist[w]-1e-12 {
|
||||
dist[w] = alt
|
||||
sigma[w] = sigma[v]
|
||||
pred[w] = append(pred[w][:0], v)
|
||||
heap.Push(pq, bridgePQItem{node: w, dist: alt})
|
||||
} else if math.Abs(alt-dist[w]) <= 1e-12 {
|
||||
sigma[w] += sigma[v]
|
||||
pred[w] = append(pred[w], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Back-propagation: walk the stack in reverse order.
|
||||
delta := make(map[string]float64, len(nodes))
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
w := stack[i]
|
||||
for _, v := range pred[w] {
|
||||
if sigma[w] == 0 {
|
||||
continue
|
||||
}
|
||||
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w])
|
||||
}
|
||||
if w != s {
|
||||
bc[w] += delta[w]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Undirected graphs double-count each (s,t) pair, so halve.
|
||||
for k := range bc {
|
||||
bc[k] /= 2.0
|
||||
}
|
||||
|
||||
// 5. Normalize by max so scores live in [0, 1]. If max is 0
|
||||
// (clique or single edge) we leave everything at zero.
|
||||
maxBC := 0.0
|
||||
for _, v := range bc {
|
||||
if v > maxBC {
|
||||
maxBC = v
|
||||
}
|
||||
}
|
||||
if maxBC > 0 {
|
||||
for k, v := range bc {
|
||||
bc[k] = v / maxBC
|
||||
}
|
||||
}
|
||||
return bc
|
||||
}
|
||||
|
||||
// ─── min-heap for Dijkstra ─────────────────────────────────────────────────────
|
||||
|
||||
type bridgePQItem struct {
|
||||
node string
|
||||
dist float64
|
||||
}
|
||||
|
||||
type bridgePQ []bridgePQItem
|
||||
|
||||
func (h bridgePQ) Len() int { return len(h) }
|
||||
func (h bridgePQ) Less(i, j int) bool { return h[i].dist < h[j].dist }
|
||||
func (h bridgePQ) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *bridgePQ) Push(x interface{}) { *h = append(*h, x.(bridgePQItem)) }
|
||||
func (h *bridgePQ) Pop() interface{} {
|
||||
old := *h
|
||||
n := len(old)
|
||||
it := old[n-1]
|
||||
*h = old[:n-1]
|
||||
return it
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestComputeBridgeScores_LineGraph asserts the canonical property of
|
||||
// betweenness centrality on a 4-node line A-B-C-D: the two middle
|
||||
// nodes B and C have non-zero centrality (every path between an end
|
||||
// and a far end traverses them) while the two leaves A and D bridge
|
||||
// no pairs and score zero. This is the RED test for issue #672 bridge
|
||||
// axis — it fails on master where ComputeBridgeScores is a stub.
|
||||
func TestComputeBridgeScores_LineGraph(t *testing.T) {
|
||||
edges := []BridgeEdge{
|
||||
{A: "a", B: "b", Weight: 1.0},
|
||||
{A: "b", B: "c", Weight: 1.0},
|
||||
{A: "c", B: "d", Weight: 1.0},
|
||||
}
|
||||
scores := ComputeBridgeScores(edges)
|
||||
|
||||
for _, leaf := range []string{"a", "d"} {
|
||||
if v, ok := scores[leaf]; !ok || v != 0 {
|
||||
t.Errorf("leaf %q: want score 0 (present), got %v ok=%v", leaf, v, ok)
|
||||
}
|
||||
}
|
||||
for _, mid := range []string{"b", "c"} {
|
||||
v, ok := scores[mid]
|
||||
if !ok {
|
||||
t.Errorf("middle %q: missing from result map", mid)
|
||||
continue
|
||||
}
|
||||
if v <= 0 {
|
||||
t.Errorf("middle %q: want non-zero centrality, got %v", mid, v)
|
||||
}
|
||||
}
|
||||
// Normalization: max must equal 1.0 exactly when any node has
|
||||
// non-zero centrality.
|
||||
maxScore := 0.0
|
||||
for _, v := range scores {
|
||||
if v > maxScore {
|
||||
maxScore = v
|
||||
}
|
||||
}
|
||||
if math.Abs(maxScore-1.0) > 1e-9 {
|
||||
t.Errorf("max normalized score: want 1.0, got %v", maxScore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeBridgeScores_TriangleNoBridge: in a fully connected
|
||||
// triangle every node has at least one alternate path so betweenness
|
||||
// is zero everywhere. The map should still contain all three nodes
|
||||
// (so callers can distinguish "in graph but unimportant" from
|
||||
// "not in graph") with explicit zero values.
|
||||
func TestComputeBridgeScores_TriangleNoBridge(t *testing.T) {
|
||||
edges := []BridgeEdge{
|
||||
{A: "x", B: "y", Weight: 1.0},
|
||||
{A: "y", B: "z", Weight: 1.0},
|
||||
{A: "z", B: "x", Weight: 1.0},
|
||||
}
|
||||
scores := ComputeBridgeScores(edges)
|
||||
for _, n := range []string{"x", "y", "z"} {
|
||||
if v, ok := scores[n]; !ok || v != 0 {
|
||||
t.Errorf("triangle node %q: want 0 present, got %v ok=%v", n, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeBridgeScores_Empty: an empty edge list yields an empty
|
||||
// (non-nil) map. Defensive check so the recomputer can swap in an
|
||||
// empty result without crashing the lookup path.
|
||||
func TestComputeBridgeScores_Empty(t *testing.T) {
|
||||
scores := ComputeBridgeScores(nil)
|
||||
if scores == nil {
|
||||
t.Fatal("want non-nil empty map, got nil")
|
||||
}
|
||||
if len(scores) != 0 {
|
||||
t.Errorf("want empty map, got %d entries", len(scores))
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeBridgeScores_WeightSensitive verifies the algorithm uses
|
||||
// edge weights as affinity (higher = preferred). In a graph A-B-D and
|
||||
// A-C-D where the B-route has weight 1.0 and the C-route has weight
|
||||
// 0.1, shortest path (max-affinity = min 1/w) goes through B, so B
|
||||
// has positive centrality and C does not. This is the "mutation
|
||||
// test" — flip the cost formula (e.g., remove the 1/w inversion) and
|
||||
// this test inverts.
|
||||
func TestComputeBridgeScores_WeightSensitive(t *testing.T) {
|
||||
edges := []BridgeEdge{
|
||||
{A: "a", B: "b", Weight: 1.0},
|
||||
{A: "b", B: "d", Weight: 1.0},
|
||||
{A: "a", B: "c", Weight: 0.1},
|
||||
{A: "c", B: "d", Weight: 0.1},
|
||||
}
|
||||
scores := ComputeBridgeScores(edges)
|
||||
if scores["b"] <= scores["c"] {
|
||||
t.Errorf("stronger-weight intermediary b should outrank c: b=%v c=%v",
|
||||
scores["b"], scores["c"])
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,20 @@ const (
|
||||
// drift rarely exceeds 1 hour, while epoch-0 RTCs produce ~1.7B sec.
|
||||
bimodalSkewThresholdSec = 3600.0
|
||||
|
||||
// rtcResetOutlierThresholdSec is the absolute skew above which a
|
||||
// sample is treated as obvious sensor garbage — an RTC-reset advert
|
||||
// where the firmware emitted its factory timestamp (typically off by
|
||||
// months/years). These samples are excluded from the recent-window
|
||||
// "good/bad" split (bug #1285 — single RTC-reset advert among 30
|
||||
// healthy adverts must not flip a node to bimodal_clock) and from the
|
||||
// per-hash evidence median (a 700-day median is not actionable for
|
||||
// operators). They remain in the raw sample stream and the RTC-reset
|
||||
// badge logic which surfaces them separately. 24h is a generous floor:
|
||||
// real drift is fractions of a sec/advert, real clock-skew tops out
|
||||
// in the hours range; anything above a day is structurally not a
|
||||
// drift signal.
|
||||
rtcResetOutlierThresholdSec = 24 * 3600.0
|
||||
|
||||
// maxPlausibleSkewJumpSec is the largest skew change between
|
||||
// consecutive samples that we treat as physical drift. Anything larger
|
||||
// (e.g. a GPS sync that jumps the clock by minutes/days) is rejected
|
||||
@@ -560,13 +574,25 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
// no_clock — goodFraction < 0.10 (essentially no real clock)
|
||||
// bimodal_clock — 0.10 <= goodFraction < 0.80 AND badCount > 0
|
||||
// ok/warn/etc. — goodFraction >= 0.80 (normal, outliers filtered)
|
||||
//
|
||||
// RTC-reset outliers (|skew| > 24h — single advert where the firmware
|
||||
// emitted its factory timestamp) are EXCLUDED from this split (bug
|
||||
// #1285): they're not "bimodal-bad real-but-large skew" but obvious
|
||||
// sensor garbage, surfaced separately via the RTC-reset badge. Counting
|
||||
// them as bimodal-bad produces a false-alarm warning ("3 of last 5
|
||||
// adverts had nonsense timestamps") on otherwise-healthy nodes.
|
||||
var goodSamples []float64
|
||||
var rtcResetCount int
|
||||
for _, v := range recentVals {
|
||||
if math.Abs(v) <= bimodalSkewThresholdSec {
|
||||
absV := math.Abs(v)
|
||||
switch {
|
||||
case absV > rtcResetOutlierThresholdSec:
|
||||
rtcResetCount++ // ignored for good/bad classification
|
||||
case absV <= bimodalSkewThresholdSec:
|
||||
goodSamples = append(goodSamples, v)
|
||||
}
|
||||
}
|
||||
recentSampleCount := len(recentVals)
|
||||
recentSampleCount := len(recentVals) - rtcResetCount
|
||||
recentBadCount := recentSampleCount - len(goodSamples)
|
||||
var goodFraction float64
|
||||
if recentSampleCount > 0 {
|
||||
@@ -586,8 +612,9 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
}
|
||||
} else {
|
||||
// Normal path: if there are good samples, use their median
|
||||
// (filters out rare outliers in ≥80% good case).
|
||||
if len(goodSamples) > 0 && recentBadCount > 0 {
|
||||
// (filters out rare outliers in ≥80% good case, and rejects
|
||||
// RTC-reset outliers regardless of bimodal/bad counts — #1285).
|
||||
if len(goodSamples) > 0 {
|
||||
recentSkew = median(goodSamples)
|
||||
}
|
||||
severity = classifySkew(math.Abs(recentSkew))
|
||||
@@ -668,7 +695,7 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
recentEvidence = append(recentEvidence, HashEvidence{
|
||||
Hash: eh.hash,
|
||||
Observers: observers,
|
||||
MedianCorrectedSkewSec: round(median(corrSkews), 1),
|
||||
MedianCorrectedSkewSec: round(hashEvidenceMedian(corrSkews), 1),
|
||||
Timestamp: eh.ts,
|
||||
})
|
||||
}
|
||||
@@ -694,9 +721,27 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
}
|
||||
}
|
||||
|
||||
// GetFleetClockSkew returns clock skew data for all nodes that have skew data.
|
||||
// Must NOT be called with s.mu held.
|
||||
// GetFleetClockSkew returns clock skew data for all nodes, preferring
|
||||
// the steady-state recomputer snapshot (issue #1265). Falls back to an
|
||||
// on-request compute if the recomputer is not yet running.
|
||||
func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
s.analyticsRecomputerMu.RLock()
|
||||
rc := s.recompNodesClockSkew
|
||||
s.analyticsRecomputerMu.RUnlock()
|
||||
if rc != nil {
|
||||
if v := rc.Load(); v != nil {
|
||||
if r, ok := v.([]*NodeClockSkew); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.computeFleetClockSkew()
|
||||
}
|
||||
|
||||
// computeFleetClockSkew is the underlying compute used by the
|
||||
// recomputer and the on-request fallback. Must NOT be called with
|
||||
// s.mu held.
|
||||
func (s *PacketStore) computeFleetClockSkew() []*NodeClockSkew {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -707,7 +752,7 @@ func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
nameMap[ni.PublicKey] = ni
|
||||
}
|
||||
|
||||
var results []*NodeClockSkew
|
||||
var results = []*NodeClockSkew{}
|
||||
for pubkey := range s.byNode {
|
||||
cs := s.getNodeClockSkewLocked(pubkey)
|
||||
if cs == nil {
|
||||
@@ -727,8 +772,26 @@ func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
return results
|
||||
}
|
||||
|
||||
// GetObserverCalibrations returns the current observer clock offsets.
|
||||
// GetObserverCalibrations returns the current observer clock offsets,
|
||||
// preferring the steady-state recomputer snapshot (issue #1265). Falls
|
||||
// back to an on-request compute when the recomputer is not running.
|
||||
func (s *PacketStore) GetObserverCalibrations() []ObserverCalibration {
|
||||
s.analyticsRecomputerMu.RLock()
|
||||
rc := s.recompObserversClockSkew
|
||||
s.analyticsRecomputerMu.RUnlock()
|
||||
if rc != nil {
|
||||
if v := rc.Load(); v != nil {
|
||||
if r, ok := v.([]ObserverCalibration); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.computeObserverCalibrations()
|
||||
}
|
||||
|
||||
// computeObserverCalibrations is the underlying compute used by the
|
||||
// recomputer and on-request fallback. Must NOT be called with s.mu held.
|
||||
func (s *PacketStore) computeObserverCalibrations() []ObserverCalibration {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -768,6 +831,23 @@ func median(vals []float64) float64 {
|
||||
return sorted[n/2]
|
||||
}
|
||||
|
||||
// hashEvidenceMedian returns the median corrected skew for a single
|
||||
// transmission hash, filtering out RTC-reset outliers (|skew| > 24h —
|
||||
// firmware emitting factory timestamp). Issue #1285: a single outlier
|
||||
// observer was dragging the displayed median to ~-704d on an otherwise
|
||||
// healthy node. If filtering leaves zero usable samples (every observer
|
||||
// of this hash saw a reset-shaped advert), return 0 so the UI can render
|
||||
// "insufficient data" rather than the garbage outlier value.
|
||||
func hashEvidenceMedian(vals []float64) float64 {
|
||||
clean := vals[:0:0]
|
||||
for _, v := range vals {
|
||||
if math.Abs(v) <= rtcResetOutlierThresholdSec {
|
||||
clean = append(clean, v)
|
||||
}
|
||||
}
|
||||
return median(clean)
|
||||
}
|
||||
|
||||
func mean(vals []float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// Regression tests for #1285:
|
||||
//
|
||||
// Bug A: per-hash evidence's MedianCorrectedSkewSec includes 700-day RTC-reset
|
||||
// outliers, dragging the displayed median into "−704d 18h" garbage even
|
||||
// though every recent sample is small (< 30s).
|
||||
//
|
||||
// Bug B: RecentBadSampleCount counts samples outside the *displayed* recent
|
||||
// window (or counts against raw skew, not corrected) → "3 of last 5
|
||||
// adverts had nonsense timestamps" warning fires on healthy nodes.
|
||||
//
|
||||
// Both must pass without disturbing the existing bimodal / no-clock logic.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Synthesizes the repro from issue #1285:
|
||||
// 30 healthy adverts (skew ~−20s) + 1 historical advert with an RTC reset
|
||||
// (advertTS = 2024-06-13, observed today → −705d skew).
|
||||
// Returns the populated store.
|
||||
func seedIssue1285Repro(t *testing.T) *PacketStore {
|
||||
t.Helper()
|
||||
ps := NewPacketStore(nil, nil)
|
||||
pt := 4 // ADVERT
|
||||
|
||||
const pubkey = "RTCRESET"
|
||||
const skewSec = int64(-20) // node clock is 20s BEHIND wall-clock
|
||||
|
||||
baseObs := int64(1779000000) // ~mid-2026
|
||||
rtcResetAdv := int64(1718281640) // 2024-06-13 (from the issue repro)
|
||||
|
||||
var txs []*StoreTx
|
||||
|
||||
// 30 healthy adverts spanning the older end of the recent window.
|
||||
for i := 0; i < 30; i++ {
|
||||
obsTS := baseObs + int64(i)*60
|
||||
advTS := obsTS + skewSec
|
||||
tx := &StoreTx{
|
||||
Hash: "healthy-" + formatInt64(int64(i)),
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
|
||||
Observations: []*StoreObs{
|
||||
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
||||
},
|
||||
}
|
||||
txs = append(txs, tx)
|
||||
}
|
||||
|
||||
// One RTC-reset packet observed MOST RECENTLY (so it sits in the
|
||||
// per-hash evidence list AND is included in the recent-window count
|
||||
// on master). Its advertTS is from 2024 → corrected skew ≈ -60M sec.
|
||||
rtcResetObs := baseObs + int64(30*60) + 60
|
||||
rtcTx := &StoreTx{
|
||||
Hash: "rtc-reset-0001",
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(rtcResetAdv) + `}}`,
|
||||
Observations: []*StoreObs{
|
||||
{ObserverID: "obs1", Timestamp: time.Unix(rtcResetObs, 0).UTC().Format(time.RFC3339)},
|
||||
},
|
||||
}
|
||||
txs = append(txs, rtcTx)
|
||||
|
||||
ps.mu.Lock()
|
||||
ps.byNode[pubkey] = txs
|
||||
for _, tx := range txs {
|
||||
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
||||
}
|
||||
ps.clockSkew.computeInterval = 0
|
||||
ps.mu.Unlock()
|
||||
return ps
|
||||
}
|
||||
|
||||
// Bug A — per-hash evidence median must EXCLUDE the 705-day RTC-reset outlier.
|
||||
// On master this asserts on the RTC-reset hash's MedianCorrectedSkewSec being
|
||||
// ≈ −60M sec ("−704d 18h"); after the fix the field is suppressed (0) or
|
||||
// otherwise marked insufficient, never displayed as the garbage value.
|
||||
func TestIssue1285_HashEvidence_OutlierExcludedFromMedian(t *testing.T) {
|
||||
ps := seedIssue1285Repro(t)
|
||||
r := ps.GetNodeClockSkew("RTCRESET")
|
||||
if r == nil {
|
||||
t.Fatal("expected clock skew result")
|
||||
}
|
||||
|
||||
// The recent-hash evidence list is the source of the "median corrected:
|
||||
// −704d 18h" string in the UI. After the fix, NO entry in this list
|
||||
// should report a |median| above the 24h sanity threshold — the fix is
|
||||
// to drop outlier samples (or flag the hash as insufficient-data) before
|
||||
// publishing the median.
|
||||
const maxSaneAbsSec = float64(24 * 3600)
|
||||
for _, ev := range r.RecentHashEvidence {
|
||||
if abs(ev.MedianCorrectedSkewSec) > maxSaneAbsSec {
|
||||
t.Errorf("hash %s exposes outlier-dominated median %.0fs (~%.1fd); "+
|
||||
"expected entry to be filtered out or marked insufficient (|median| <= %.0fs)",
|
||||
ev.Hash, ev.MedianCorrectedSkewSec,
|
||||
ev.MedianCorrectedSkewSec/86400, maxSaneAbsSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bug B — RecentBadSampleCount must be 0 when every sample in the recent
|
||||
// window is healthy (<30s |corrected skew|). On master this fires because
|
||||
// "recent" is computed over the wrong set (or against raw skew).
|
||||
func TestIssue1285_RecentBadCount_NotPollutedByOldOutlier(t *testing.T) {
|
||||
ps := seedIssue1285Repro(t)
|
||||
r := ps.GetNodeClockSkew("RTCRESET")
|
||||
if r == nil {
|
||||
t.Fatal("expected clock skew result")
|
||||
}
|
||||
if r.RecentBadSampleCount != 0 {
|
||||
t.Errorf("RecentBadSampleCount = %d, want 0 — recent samples are all "+
|
||||
"~−20s (healthy); the historical RTC-reset outlier is outside the "+
|
||||
"recent window and must not be counted", r.RecentBadSampleCount)
|
||||
}
|
||||
if r.Severity == SkewBimodalClock || r.Severity == SkewNoClock {
|
||||
t.Errorf("severity = %v, want ok/warning — recent samples are all "+
|
||||
"healthy (~−20s skew), one historical outlier must not flip the node "+
|
||||
"to bimodal/no-clock", r.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue #1265: /api/observers/clock-skew (3.3s) and /api/nodes/clock-skew (8.9s)
|
||||
// must be wired into the steady-state analytics recomputer so reads serve
|
||||
// from an atomic-pointer snapshot in <100ms p99 under concurrent load.
|
||||
|
||||
func TestClockSkewRecomputersRegistered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
|
||||
defer stop()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
store.analyticsRecomputerMu.RLock()
|
||||
rcObs := store.recompObserversClockSkew
|
||||
rcNodes := store.recompNodesClockSkew
|
||||
store.analyticsRecomputerMu.RUnlock()
|
||||
|
||||
if rcObs == nil {
|
||||
t.Fatalf("recompObserversClockSkew not registered after StartAnalyticsRecomputers (issue #1265 not fixed)")
|
||||
}
|
||||
if rcNodes == nil {
|
||||
t.Fatalf("recompNodesClockSkew not registered after StartAnalyticsRecomputers (issue #1265 not fixed)")
|
||||
}
|
||||
if rcObs.Load() == nil {
|
||||
t.Fatalf("recompObserversClockSkew snapshot is nil after initial compute")
|
||||
}
|
||||
if rcNodes.Load() == nil {
|
||||
t.Fatalf("recompNodesClockSkew snapshot is nil after initial compute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockSkewHandlersSteadyStateLatency(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
|
||||
defer stop()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
s := &Server{store: store}
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{"observers", "/api/observers/clock-skew", s.handleObserverClockSkew},
|
||||
{"nodes", "/api/nodes/clock-skew", s.handleFleetClockSkew},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
ep := ep
|
||||
t.Run(ep.name, func(t *testing.T) {
|
||||
const readers = 8
|
||||
const perReader = 25
|
||||
var (
|
||||
mu sync.Mutex
|
||||
samples []time.Duration
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(readers)
|
||||
for r := 0; r < readers; r++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perReader; i++ {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ep.path, nil)
|
||||
t0 := time.Now()
|
||||
ep.handler(rr, req)
|
||||
dt := time.Since(t0)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("%s status = %d, want 200", ep.path, rr.Code)
|
||||
}
|
||||
mu.Lock()
|
||||
samples = append(samples, dt)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
p99 := samples[int(float64(len(samples))*0.99)]
|
||||
if p99 > 100*time.Millisecond {
|
||||
t.Fatalf("%s p99 latency = %v over %d reqs, want <100ms (recomputer snapshot)", ep.path, p99, len(samples))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -708,9 +708,9 @@ func TestBimodalClock_845(t *testing.T) {
|
||||
|
||||
baseObs := int64(1700000000)
|
||||
var txs []*StoreTx
|
||||
// 6 good samples (-5s each), 4 bad samples (-50000000s each) = 60% good
|
||||
// 6 good samples (-5s each), 4 bad samples (-7200s each) = 60% good
|
||||
// Interleave so the recent window (last 5) captures both good and bad.
|
||||
skews := []int64{-5, -5, -50000000, -5, -50000000, -5, -50000000, -5, -50000000, -5}
|
||||
skews := []int64{-5, -5, -7200, -5, -7200, -5, -7200, -5, -7200, -5}
|
||||
for i := 0; i < 10; i++ {
|
||||
obsTS := baseObs + int64(i)*60
|
||||
advTS := obsTS + skews[i]
|
||||
@@ -794,14 +794,14 @@ func TestMostlyGood_OK_845(t *testing.T) {
|
||||
|
||||
baseObs := int64(1700000000)
|
||||
var txs []*StoreTx
|
||||
// 9 good at -5s, 1 bad at -50000000s
|
||||
// 9 good at -5s, 1 bad at -7200s
|
||||
for i := 0; i < 10; i++ {
|
||||
obsTS := baseObs + int64(i)*60
|
||||
var skew int64
|
||||
if i < 9 {
|
||||
skew = -5
|
||||
} else {
|
||||
skew = -50000000
|
||||
skew = -7200
|
||||
}
|
||||
advTS := obsTS + skew
|
||||
tx := &StoreTx{
|
||||
@@ -882,7 +882,7 @@ func TestFiftyFifty_Bimodal_845(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
skew = -10
|
||||
} else {
|
||||
skew = -50000000
|
||||
skew = -7200
|
||||
}
|
||||
tx := &StoreTx{
|
||||
Hash: fmt.Sprintf("fifty-%04d", i),
|
||||
|
||||
@@ -546,7 +546,7 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
|
||||
// data slowly." Lower values give fresher data at higher CPU cost.
|
||||
//
|
||||
// RecomputeIntervalSeconds keys (all optional):
|
||||
// topology, rf, distance, channels, hashCollisions, hashSizes, roles
|
||||
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
|
||||
type AnalyticsConfig struct {
|
||||
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
|
||||
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
|
||||
@@ -583,5 +583,7 @@ func (c *Config) AnalyticsRecomputeIntervals() AnalyticsRecomputeIntervals {
|
||||
out.HashCollisions = get("hashCollisions")
|
||||
out.HashSizes = get("hashSizes")
|
||||
out.Roles = get("roles")
|
||||
out.ObserversClockSkew = get("observersClockSkew")
|
||||
out.NodesClockSkew = get("nodesClockSkew")
|
||||
return out
|
||||
}
|
||||
|
||||
+6
-78
@@ -2032,38 +2032,10 @@ func nullInt(ni sql.NullInt64) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneOldPackets deletes transmissions and their observations older than the
|
||||
// given number of days. Nodes and observers are never touched.
|
||||
// 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) {
|
||||
rw, err := cachedRW(db.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete observations linked to old transmissions first (no CASCADE in SQLite)
|
||||
_, err = tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
|
||||
SELECT id FROM transmissions WHERE first_seen < ?
|
||||
)`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, tx.Commit()
|
||||
}
|
||||
// PruneOldPackets, PruneOldMetrics, and RemoveStaleObservers were
|
||||
// removed in #1283 — they are write operations and now live on the
|
||||
// ingestor's *Store (cmd/ingestor/maintenance.go and cmd/ingestor/db.go).
|
||||
// The server is the read path; it must not hold the SQLite write lock.
|
||||
|
||||
// MetricsSample represents a single row from observer_metrics with computed deltas.
|
||||
type MetricsSample struct {
|
||||
@@ -2381,52 +2353,8 @@ func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PruneOldMetrics deletes observer_metrics rows older than retentionDays.
|
||||
func (db *DB) PruneOldMetrics(retentionDays int) (int64, error) {
|
||||
rw, err := cachedRW(db.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
|
||||
res, err := rw.Exec(`DELETE FROM observer_metrics WHERE timestamp < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("[metrics] Pruned %d observer_metrics rows older than %d days", n, retentionDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RemoveStaleObservers marks observers that have not actively sent data in observerDays
|
||||
// as inactive (soft-delete). This preserves JOIN integrity for observations.observer_idx
|
||||
// and observer_metrics.observer_id — historical data still references the correct observer.
|
||||
// An observer must actively send data to stay listed — being seen by another node does not count.
|
||||
// observerDays <= -1 means never remove (keep forever).
|
||||
func (db *DB) RemoveStaleObservers(observerDays int) (int64, error) {
|
||||
if observerDays <= -1 {
|
||||
return 0, nil // keep forever
|
||||
}
|
||||
rw, err := cachedRW(db.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -observerDays).Format(time.RFC3339)
|
||||
res, err := rw.Exec(`UPDATE observers SET inactive = 1 WHERE last_seen < ? AND (inactive IS NULL OR inactive = 0)`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
// Clean up orphaned metrics for now-inactive observers
|
||||
rw.Exec(`DELETE FROM observer_metrics WHERE observer_id IN (SELECT id FROM observers WHERE inactive = 1)`)
|
||||
log.Printf("[observers] Marked %d observer(s) as inactive (not seen in %d days)", n, observerDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
// (PruneOldMetrics / RemoveStaleObservers removed in #1283 — see note
|
||||
// above the MetricsSample type. Ingestor owns these writes now.)
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createFreshIngestorDB creates a SQLite DB using the ingestor's applySchema logic
|
||||
// (simulated here) with auto_vacuum=INCREMENTAL set before tables.
|
||||
func createFreshDBWithAutoVacuum(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
// auto_vacuum must be set via DSN before journal_mode creates the DB file
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=auto_vacuum(INCREMENTAL)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// Create minimal schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
channel_hash TEXT
|
||||
);
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_idx INTEGER,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewDBHasIncrementalAutoVacuum(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db := createFreshDBWithAutoVacuum(t, path)
|
||||
defer db.Close()
|
||||
|
||||
var autoVacuum int
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if autoVacuum != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 (INCREMENTAL), got %d", autoVacuum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingDBHasAutoVacuumNone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB WITHOUT setting auto_vacuum (simulates old DB)
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var autoVacuum int
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
if autoVacuum != 0 {
|
||||
t.Fatalf("expected auto_vacuum=0 (NONE) for old DB, got %d", autoVacuum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumOnStartupMigratesDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB without auto_vacuum (old DB)
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before int
|
||||
db.QueryRow("PRAGMA auto_vacuum").Scan(&before)
|
||||
if before != 0 {
|
||||
t.Fatalf("precondition: expected auto_vacuum=0, got %d", before)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Simulate vacuumOnStartup migration using openRW
|
||||
rw, err := openRW(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec("VACUUM"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
// Verify migration
|
||||
db2, err := sql.Open("sqlite", path+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var after int
|
||||
if err := db2.QueryRow("PRAGMA auto_vacuum").Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 after VACUUM migration, got %d", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementalVacuumReducesFreelist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db := createFreshDBWithAutoVacuum(t, path)
|
||||
|
||||
// Insert a bunch of data
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for i := 0; i < 500; i++ {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO transmissions (raw_hex, hash, first_seen) VALUES (?, ?, ?)",
|
||||
strings.Repeat("AA", 200), // ~400 bytes each
|
||||
"hash_"+string(rune('A'+i%26))+string(rune('0'+i/26)),
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file size before delete
|
||||
db.Close()
|
||||
infoBefore, _ := os.Stat(path)
|
||||
sizeBefore := infoBefore.Size()
|
||||
|
||||
// Reopen and delete all
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("DELETE FROM transmissions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check freelist before vacuum
|
||||
var freelistBefore int64
|
||||
db.QueryRow("PRAGMA freelist_count").Scan(&freelistBefore)
|
||||
if freelistBefore == 0 {
|
||||
t.Fatal("expected non-zero freelist after DELETE")
|
||||
}
|
||||
|
||||
// Run incremental vacuum
|
||||
_, err = db.Exec("PRAGMA incremental_vacuum(10000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check freelist after vacuum
|
||||
var freelistAfter int64
|
||||
db.QueryRow("PRAGMA freelist_count").Scan(&freelistAfter)
|
||||
if freelistAfter >= freelistBefore {
|
||||
t.Fatalf("expected freelist to shrink: before=%d after=%d", freelistBefore, freelistAfter)
|
||||
}
|
||||
|
||||
// Checkpoint WAL and check file size shrunk
|
||||
db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
db.Close()
|
||||
infoAfter, _ := os.Stat(path)
|
||||
sizeAfter := infoAfter.Size()
|
||||
if sizeAfter >= sizeBefore {
|
||||
t.Logf("warning: file did not shrink (before=%d after=%d) — may depend on page reuse", sizeBefore, sizeAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAutoVacuumLogs(t *testing.T) {
|
||||
// This test verifies checkAutoVacuum doesn't panic on various configs
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create a fresh DB with auto_vacuum=INCREMENTAL
|
||||
dbConn := createFreshDBWithAutoVacuum(t, path)
|
||||
db := &DB{conn: dbConn, path: path}
|
||||
cfg := &Config{}
|
||||
|
||||
// Should not panic
|
||||
checkAutoVacuum(db, cfg, path)
|
||||
dbConn.Close()
|
||||
|
||||
// Create a DB without auto_vacuum
|
||||
path2 := filepath.Join(dir, "test2.db")
|
||||
dbConn2, _ := sql.Open("sqlite", path2+"?_pragma=journal_mode(WAL)")
|
||||
dbConn2.SetMaxOpenConns(1)
|
||||
dbConn2.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
db2 := &DB{conn: dbConn2, path: path2}
|
||||
|
||||
// Should log warning but not panic
|
||||
checkAutoVacuum(db2, cfg, path2)
|
||||
dbConn2.Close()
|
||||
}
|
||||
|
||||
func TestConfigIncrementalVacuumPages(t *testing.T) {
|
||||
// Default
|
||||
cfg := &Config{}
|
||||
if cfg.IncrementalVacuumPages() != 1024 {
|
||||
t.Fatalf("expected default 1024, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
|
||||
// Custom
|
||||
cfg.DB = &DBConfig{IncrementalVacuumPages: 512}
|
||||
if cfg.IncrementalVacuumPages() != 512 {
|
||||
t.Fatalf("expected 512, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
|
||||
// Zero should return default
|
||||
cfg.DB.IncrementalVacuumPages = 0
|
||||
if cfg.IncrementalVacuumPages() != 1024 {
|
||||
t.Fatalf("expected default 1024 for zero, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
}
|
||||
+138
-8
@@ -109,6 +109,32 @@ type Payload struct {
|
||||
SNRValues []float64 `json:"snrValues,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// GRP_TXT/GRP_DATA channel envelope helpers — see
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:376-391.
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
// GRP_DATA (PAYLOAD_TYPE_GRP_DATA=0x06) inner fields, per
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:382-385.
|
||||
DataType *int `json:"dataType,omitempty"`
|
||||
DataLen *int `json:"dataLen,omitempty"`
|
||||
DecryptedBlob string `json:"decryptedBlob,omitempty"`
|
||||
// MULTIPART (PAYLOAD_TYPE_MULTIPART=0x0A) inner fields, per
|
||||
// firmware/src/Mesh.cpp:289 — byte0 = (remaining<<4) | inner_type.
|
||||
Remaining *int `json:"remaining,omitempty"`
|
||||
InnerType *int `json:"innerType,omitempty"`
|
||||
InnerTypeName string `json:"innerTypeName,omitempty"`
|
||||
InnerAckCrc string `json:"innerAckCrc,omitempty"`
|
||||
InnerPayload string `json:"innerPayload,omitempty"`
|
||||
// CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per
|
||||
// firmware/src/Mesh.cpp:69 — high-bit = zero-hop direct subset.
|
||||
CtrlFlags string `json:"ctrlFlags,omitempty"`
|
||||
CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"`
|
||||
CtrlLength *int `json:"ctrlLength,omitempty"`
|
||||
// RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per
|
||||
// firmware/src/Mesh.cpp:577 (createRawData). We expose the bare envelope
|
||||
// shape so consumers can triage by length + leading tag byte.
|
||||
RawLength *int `json:"rawLength,omitempty"`
|
||||
FirstByteTag string `json:"firstByteTag,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
@@ -313,6 +339,85 @@ func decodeGrpTxt(buf []byte) Payload {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeGrpData decodes PAYLOAD_TYPE_GRP_DATA (0x06). Outer envelope is the
|
||||
// same shape as GRP_TXT (channel_hash(1)+MAC(2)+ciphertext) — see
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:476,500. This server-side decoder has
|
||||
// no channel keys, so it surfaces the envelope only.
|
||||
func decodeGrpData(buf []byte) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_DATA", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
ChannelHash: int(buf[0]),
|
||||
ChannelHashHex: fmt.Sprintf("%02X", buf[0]),
|
||||
MAC: hex.EncodeToString(buf[1:3]),
|
||||
EncryptedData: hex.EncodeToString(buf[3:]),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMultipart decodes PAYLOAD_TYPE_MULTIPART (0x0A) per
|
||||
// firmware/src/Mesh.cpp:287-310. byte0 = (remaining<<4) | inner_type;
|
||||
// when inner_type == PAYLOAD_TYPE_ACK the next 4 bytes are an ack_crc.
|
||||
func decodeMultipart(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "MULTIPART", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
remaining := int(buf[0] >> 4)
|
||||
innerType := int(buf[0] & 0x0F)
|
||||
innerName := payloadTypeNames[innerType]
|
||||
if innerName == "" {
|
||||
innerName = "UNKNOWN"
|
||||
}
|
||||
p := Payload{
|
||||
Type: "MULTIPART",
|
||||
Remaining: &remaining,
|
||||
InnerType: &innerType,
|
||||
InnerTypeName: innerName,
|
||||
}
|
||||
if innerType == PayloadACK && len(buf) >= 5 {
|
||||
crc := binary.LittleEndian.Uint32(buf[1:5])
|
||||
p.InnerAckCrc = fmt.Sprintf("%08x", crc)
|
||||
} else if len(buf) > 1 {
|
||||
p.InnerPayload = hex.EncodeToString(buf[1:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per
|
||||
// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset).
|
||||
func decodeControl(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
zeroHop := buf[0]&0x80 != 0
|
||||
length := len(buf)
|
||||
return Payload{
|
||||
Type: "CONTROL",
|
||||
CtrlFlags: fmt.Sprintf("%02x", buf[0]),
|
||||
CtrlZeroHop: &zeroHop,
|
||||
CtrlLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). The payload bytes
|
||||
// are application-defined per firmware/src/Mesh.cpp:577 (createRawData), so
|
||||
// we only surface the bare envelope shape: total length plus the leading
|
||||
// byte, which apps commonly use as a tag/type discriminator.
|
||||
func decodeRawCustom(buf []byte) Payload {
|
||||
length := len(buf)
|
||||
p := Payload{
|
||||
Type: "RAW_CUSTOM",
|
||||
RawLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
if length > 0 {
|
||||
p.FirstByteTag = fmt.Sprintf("%02X", buf[0])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -372,12 +477,20 @@ func decodePayload(payloadType int, buf []byte, validateSignatures bool) Payload
|
||||
return decodeAdvert(buf, validateSignatures)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf)
|
||||
case PayloadGRP_DATA:
|
||||
return decodeGrpData(buf)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
case PayloadMULTIPART:
|
||||
return decodeMultipart(buf)
|
||||
case PayloadCONTROL:
|
||||
return decodeControl(buf)
|
||||
case PayloadRAW_CUSTOM:
|
||||
return decodeRawCustom(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
@@ -617,8 +730,13 @@ func ValidateAdvert(p *Payload) (bool, string) {
|
||||
|
||||
if p.Flags != nil {
|
||||
role := advertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
// Accept canonical labels plus "none" (ADV_TYPE_NONE=0) and "type-N"
|
||||
// placeholders for ADV_TYPE 5-15 (FUTURE) — see
|
||||
// firmware/src/helpers/AdvertDataHelpers.h:7-12.
|
||||
validRoles := map[string]bool{
|
||||
"repeater": true, "companion": true, "room": true, "sensor": true, "none": true,
|
||||
}
|
||||
if !validRoles[role] && !strings.HasPrefix(role, "type-") {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
@@ -638,17 +756,29 @@ func sanitizeName(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// advertRole returns a stable role label for an advert. Follows firmware
|
||||
// ADV_TYPE_* constants in firmware/src/helpers/AdvertDataHelpers.h:7-12:
|
||||
// 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR, 5-15 FUTURE.
|
||||
// Previously this coerced both 0 (NONE) and 5-15 (FUTURE) to "companion",
|
||||
// silently relabelling unknown/reserved types — see issue #1279 P1 #3.
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
if f == nil {
|
||||
return "companion"
|
||||
}
|
||||
switch f.Type {
|
||||
case 0:
|
||||
return "none"
|
||||
case 1:
|
||||
return "companion"
|
||||
case 2:
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
case 3:
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
case 4:
|
||||
return "sensor"
|
||||
default:
|
||||
return fmt.Sprintf("type-%d", f.Type)
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P2 items:
|
||||
// - Item 1: payloadTypeNames map must include ALL 13 firmware payload types.
|
||||
// - Item 5: RAW_CUSTOM (0x0F) decoder must expose rawLength + firstByteTag.
|
||||
//
|
||||
// Firmware refs:
|
||||
// - firmware/src/Packet.h:19-32 (PAYLOAD_TYPE_*) — 0..0xB plus 0xF (RAW_CUSTOM)
|
||||
// - firmware/src/Mesh.cpp:577 (createRawData) — application-defined payload
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPayloadTypeNamesAll13(t *testing.T) {
|
||||
// Firmware-defined: 0..9, 0x0A, 0x0B, 0x0F — 13 total.
|
||||
want := map[int]string{
|
||||
0x00: "REQ", 0x01: "RESPONSE", 0x02: "TXT_MSG", 0x03: "ACK",
|
||||
0x04: "ADVERT", 0x05: "GRP_TXT", 0x06: "GRP_DATA", 0x07: "ANON_REQ",
|
||||
0x08: "PATH", 0x09: "TRACE", 0x0A: "MULTIPART", 0x0B: "CONTROL",
|
||||
0x0F: "RAW_CUSTOM",
|
||||
}
|
||||
if len(payloadTypeNames) != len(want) {
|
||||
t.Errorf("payloadTypeNames has %d entries, want %d", len(payloadTypeNames), len(want))
|
||||
}
|
||||
for code, name := range want {
|
||||
got, ok := payloadTypeNames[code]
|
||||
if !ok {
|
||||
t.Errorf("payloadTypeNames missing 0x%02X (%s)", code, name)
|
||||
continue
|
||||
}
|
||||
if got != name {
|
||||
t.Errorf("payloadTypeNames[0x%02X] = %q, want %q", code, got, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRawCustomExposesLengthAndTag(t *testing.T) {
|
||||
// Build a RAW_CUSTOM packet: header byte = (route<<6 | type<<2 | ver),
|
||||
// type=0x0F. Route FLOOD (1), version 1: (1<<6)|(0x0F<<2)|1 = 0x7D.
|
||||
// Path byte: 0 hops, hash_size=1 → upper bits 0, lower 0 → 0x00.
|
||||
// Payload: first byte tag 0xA5, then arbitrary data.
|
||||
hexStr := "7D00A5DEADBEEF"
|
||||
pkt, err := DecodePacket(hexStr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "RAW_CUSTOM" {
|
||||
t.Fatalf("payload type = %q, want RAW_CUSTOM", pkt.Payload.Type)
|
||||
}
|
||||
if pkt.Payload.RawLength == nil {
|
||||
t.Fatal("RawLength should be set for RAW_CUSTOM")
|
||||
}
|
||||
// payload = 4 bytes (A5 DE AD BE EF) — wait, A5 DE AD BE EF = 5 bytes.
|
||||
if *pkt.Payload.RawLength != 5 {
|
||||
t.Errorf("RawLength=%d, want 5", *pkt.Payload.RawLength)
|
||||
}
|
||||
if !strings.EqualFold(pkt.Payload.FirstByteTag, "A5") {
|
||||
t.Errorf("FirstByteTag=%q, want A5", pkt.Payload.FirstByteTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P0+P1 decoder additions (server-side).
|
||||
//
|
||||
// Wire-vector citations identical to the ingestor counterpart:
|
||||
// - GRP_DATA outer: firmware/src/helpers/BaseChatMesh.cpp:500
|
||||
// - MULTIPART byte0: firmware/src/Mesh.cpp:289
|
||||
// - MULTIPART ACK inner: firmware/src/Mesh.cpp:292-307
|
||||
// - CONTROL byte0 flags: firmware/src/Mesh.cpp:69 + Mesh.cpp:609
|
||||
// - advertRole label rules: firmware/src/helpers/AdvertDataHelpers.h:7-12
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeGrpDataEnvelopeServer(t *testing.T) {
|
||||
// Server-side decoder has no channel keys: envelope only.
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11}
|
||||
p := decodeGrpData(buf)
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Fatalf("type=%q want GRP_DATA", p.Type)
|
||||
}
|
||||
if p.ChannelHash != 0xAA {
|
||||
t.Errorf("channelHash=%d want 170", p.ChannelHash)
|
||||
}
|
||||
if p.ChannelHashHex != "AA" {
|
||||
t.Errorf("channelHashHex=%q want AA", p.ChannelHashHex)
|
||||
}
|
||||
if p.MAC != "bbcc" {
|
||||
t.Errorf("mac=%q want bbcc", p.MAC)
|
||||
}
|
||||
if p.EncryptedData != "ddeeff11" {
|
||||
t.Errorf("encryptedData=%q want ddeeff11", p.EncryptedData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMultipartAckServer(t *testing.T) {
|
||||
buf := []byte{0x33, 0xEF, 0xBE, 0xAD, 0xDE}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Type != "MULTIPART" {
|
||||
t.Fatalf("type=%q want MULTIPART", p.Type)
|
||||
}
|
||||
if p.Remaining == nil || *p.Remaining != 3 {
|
||||
t.Errorf("remaining=%v want 3", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x03 {
|
||||
t.Errorf("innerType=%v want 3", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "ACK" {
|
||||
t.Errorf("innerTypeName=%q want ACK", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerAckCrc != "deadbeef" {
|
||||
t.Errorf("innerAckCrc=%q want deadbeef", p.InnerAckCrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMultipartNonAckServer(t *testing.T) {
|
||||
buf := []byte{0x22, 0x01, 0x02, 0x03}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Remaining == nil || *p.Remaining != 2 {
|
||||
t.Errorf("remaining=%v want 2", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x02 {
|
||||
t.Errorf("innerType=%v want 2", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "TXT_MSG" {
|
||||
t.Errorf("innerTypeName=%q want TXT_MSG", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerPayload != "010203" {
|
||||
t.Errorf("innerPayload=%q want 010203", p.InnerPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvertRoleLabelsRawTypeServer(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ int
|
||||
want string
|
||||
}{
|
||||
{0, "none"},
|
||||
{1, "companion"},
|
||||
{2, "repeater"},
|
||||
{3, "room"},
|
||||
{4, "sensor"},
|
||||
{5, "type-5"},
|
||||
{15, "type-15"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := advertRole(&AdvertFlags{Type: tc.typ, Repeater: tc.typ == 2, Room: tc.typ == 3, Sensor: tc.typ == 4})
|
||||
if got != tc.want {
|
||||
t.Errorf("advertRole(type=%d) = %q, want %q", tc.typ, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlZeroHopServer(t *testing.T) {
|
||||
buf := []byte{0x81, 0xAA, 0xBB, 0xCC}
|
||||
p := decodeControl(buf)
|
||||
if p.Type != "CONTROL" {
|
||||
t.Fatalf("type=%q want CONTROL", p.Type)
|
||||
}
|
||||
if p.CtrlFlags != "81" {
|
||||
t.Errorf("ctrlFlags=%q want 81", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || !*p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want true", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 4 {
|
||||
t.Errorf("ctrlLength=%v want 4", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlMultiHopServer(t *testing.T) {
|
||||
buf := []byte{0x01, 0x42}
|
||||
p := decodeControl(buf)
|
||||
if p.CtrlFlags != "01" {
|
||||
t.Errorf("ctrlFlags=%q want 01", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || *p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want false", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 2 {
|
||||
t.Errorf("ctrlLength=%v want 2", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
+18
-127
@@ -167,8 +167,8 @@ func main() {
|
||||
stats.TotalTransmissions, stats.TotalObservations, stats.TotalNodes, stats.TotalObservers)
|
||||
}
|
||||
|
||||
// Check auto_vacuum mode and optionally migrate (#919)
|
||||
checkAutoVacuum(database, cfg, resolvedDB)
|
||||
// auto_vacuum is checked + migrated by the ingestor (#1283). The
|
||||
// server is read-only and must not race the writer for the lock.
|
||||
|
||||
// Ensure indexes the server's SQL fallback path depends on
|
||||
// (mirrors ingestor schema for DBs created by old server-only builds).
|
||||
@@ -377,120 +377,21 @@ func main() {
|
||||
log.Printf("[repeater-enrich-recompute] background recompute enabled (window=%.1fh, interval=%s)",
|
||||
relayWindowHours, cfg.AnalyticsDefaultRecomputeInterval())
|
||||
|
||||
// Auto-prune old packets if retention.packetDays is configured
|
||||
vacuumPages := cfg.IncrementalVacuumPages()
|
||||
var stopPrune func()
|
||||
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
|
||||
days := cfg.Retention.PacketDays
|
||||
pruneTicker := time.NewTicker(24 * time.Hour)
|
||||
pruneDone := make(chan struct{})
|
||||
stopPrune = func() {
|
||||
pruneTicker.Stop()
|
||||
close(pruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(1 * time.Minute)
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
if n > 0 {
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-pruneTicker.C:
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
if n > 0 {
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
}
|
||||
}
|
||||
case <-pruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
|
||||
}
|
||||
// Steady-state bridge-centrality recomputer (issue #672 axis 2).
|
||||
// Computes betweenness centrality over the in-memory neighbor
|
||||
// graph and stores the per-pubkey score map atomically. Read by
|
||||
// handleNodes via a single atomic load.
|
||||
stopBridgeRecomp := store.StartBridgeScoreRecomputer(
|
||||
cfg.AnalyticsDefaultRecomputeInterval(),
|
||||
)
|
||||
defer stopBridgeRecomp()
|
||||
log.Printf("[bridge-recompute] background recompute enabled (interval=%s)",
|
||||
cfg.AnalyticsDefaultRecomputeInterval())
|
||||
|
||||
// Auto-prune old metrics
|
||||
var stopMetricsPrune func()
|
||||
{
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
metricsPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
metricsPruneDone := make(chan struct{})
|
||||
stopMetricsPrune = func() {
|
||||
metricsPruneTicker.Stop()
|
||||
close(metricsPruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[metrics-prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(2 * time.Minute) // stagger after packet prune
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-metricsPruneTicker.C:
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-metricsPruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[metrics-prune] auto-prune enabled: metrics older than %d days", metricsDays)
|
||||
}
|
||||
|
||||
// Auto-prune stale observers
|
||||
var stopObserverPrune func()
|
||||
{
|
||||
observerDays := cfg.ObserverDaysOrDefault()
|
||||
if observerDays <= -1 {
|
||||
// -1 means keep forever, skip
|
||||
} else {
|
||||
observerPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
observerPruneDone := make(chan struct{})
|
||||
stopObserverPrune = func() {
|
||||
observerPruneTicker.Stop()
|
||||
close(observerPruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[observer-prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(3 * time.Minute) // stagger after metrics prune
|
||||
database.RemoveStaleObservers(observerDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-observerPruneTicker.C:
|
||||
database.RemoveStaleObservers(observerDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-observerPruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[observer-prune] auto-prune enabled: observers not seen in %d days will be removed", observerDays)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-prune old neighbor edges
|
||||
// Packet / metrics / observer retention moved to the ingestor in
|
||||
// #1283 (writes only belong on the writer process). The server no
|
||||
// longer schedules any of these; the ingestor's tickers handle them.
|
||||
_ = cfg.IncrementalVacuumPages() // kept reachable for config validation; not used here
|
||||
var stopEdgePrune func()
|
||||
{
|
||||
maxAgeDays := cfg.NeighborMaxAgeDays()
|
||||
@@ -509,13 +410,11 @@ func main() {
|
||||
time.Sleep(4 * time.Minute) // stagger after metrics prune
|
||||
g := store.graph.Load()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-edgePruneTicker.C:
|
||||
g := store.graph.Load()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-edgePruneDone:
|
||||
return
|
||||
}
|
||||
@@ -542,16 +441,8 @@ func main() {
|
||||
// 1. Stop accepting new WebSocket/poll data
|
||||
poller.Stop()
|
||||
|
||||
// 1b. Stop auto-prune ticker
|
||||
if stopPrune != nil {
|
||||
stopPrune()
|
||||
}
|
||||
if stopMetricsPrune != nil {
|
||||
stopMetricsPrune()
|
||||
}
|
||||
if stopObserverPrune != nil {
|
||||
stopObserverPrune()
|
||||
}
|
||||
// 1b. Stop auto-prune ticker (server-side packet/metrics/observer
|
||||
// prunes were removed in #1283; only neighbor-edge prune remains.)
|
||||
if stopEdgePrune != nil {
|
||||
stopEdgePrune()
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func routeDescriptions() map[string]routeMeta {
|
||||
"GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"},
|
||||
"GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"},
|
||||
"POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true},
|
||||
"POST /api/admin/prune": {Summary: "Prune old data", Description: "Deletes packets and nodes older than the configured retention period.", Tag: "admin", Auth: true},
|
||||
// "POST /api/admin/prune" removed in #1283 (ingestor owns prune).
|
||||
"GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true},
|
||||
"GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-<unix>.db.", Tag: "admin", Auth: true},
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestHandleNodePaths_AnchorBiasInconsistency_Issue1278 reproduces #1278:
|
||||
// /api/nodes/{pk}/paths returns a tx whose CANONICAL persisted resolved_path
|
||||
// (the one the packets page reads via fetchResolvedPathForTxBest) does NOT
|
||||
// contain the queried pubkey.
|
||||
//
|
||||
// Two nodes share the 1-byte prefix "c0":
|
||||
// - nodeNoGPS ("c0dedad…") — no GPS (staging: Kpa Roof Solar)
|
||||
// - nodeGPS ("c0ffeec…") — has GPS (staging: West SoMa Repeater)
|
||||
//
|
||||
// A transmission has TWO observations of the same raw path ["c0"]:
|
||||
// - obs1 (short path_json): persisted resolved_path = [nodeNoGPSPK]
|
||||
// (e.g. a region where context picked nodeNoGPS at ingest time)
|
||||
// - obs2 (longer path_json): persisted resolved_path = [nodeGPSPK]
|
||||
// (best-obs picks this one — it's the canonical answer the packets page shows)
|
||||
//
|
||||
// The membership index has BOTH pubkeys → /api/nodes/{nodeNoGPS}/paths
|
||||
// passes the candidacy gate (obs1's resolved_path mentions nodeNoGPS), then
|
||||
// re-resolves with the anchor-biased context and reports the tx — even
|
||||
// though the CANONICAL ("best") resolved_path picked nodeGPS.
|
||||
//
|
||||
// Acceptance: /api/nodes/{nodeNoGPS}/paths MUST exclude this tx because
|
||||
// the best-obs canonical resolved_path doesn't contain it. Conversely
|
||||
// /api/nodes/{nodeGPS}/paths MUST include it.
|
||||
func TestHandleNodePaths_AnchorBiasInconsistency_Issue1278(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
nodeNoGPSPK := "c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4"
|
||||
nodeGPSPK := "c0ffeec700000000000000000000000000000000000000000000000000000001"
|
||||
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'NodeNoGPS', 'repeater', 0, 0, ?, '2026-01-01', 1)`, nodeNoGPSPK, recent); err != nil {
|
||||
t.Fatalf("insert nodeNoGPS: %v", err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'NodeGPS', 'repeater', 37.5, -122.0, ?, '2026-01-01', 1)`, nodeGPSPK, recent); err != nil {
|
||||
t.Fatalf("insert nodeGPS: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen)
|
||||
VALUES (100, 'AA', 'hash_collision', ?)`, recent); err != nil {
|
||||
t.Fatalf("insert tx: %v", err)
|
||||
}
|
||||
// obs1: SHORTER path_json (single hop), resolved → nodeNoGPS.
|
||||
// (Without this row, the membership index wouldn't list nodeNoGPS at all
|
||||
// and the tx would be cleanly excluded — the bug needs the index hit.)
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (100, NULL, '["c0"]', ?, ?)`, recentEpoch, `["`+nodeNoGPSPK+`"]`); err != nil {
|
||||
t.Fatalf("insert obs1: %v", err)
|
||||
}
|
||||
// obs2: LONGER path_json (two hops, first hop is what packets page shows
|
||||
// as resolved_path[0]). fetchResolvedPathForTxBest picks this obs as
|
||||
// canonical because it has the longer path_json. Its resolved_path
|
||||
// picks nodeGPS for "c0", NOT nodeNoGPS.
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (100, NULL, '["c0","ee"]', ?, ?)`, recentEpoch, `["`+nodeGPSPK+`","ee00000000000000000000000000000000000000000000000000000000000000"]`); err != nil {
|
||||
t.Fatalf("insert obs2: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
doGet := func(pk string) NodePathsResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths for %s: code=%d body=%s", pk, w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Acceptance #1: nodeGPS (the canonical / best-obs pick) MUST include tx.
|
||||
respGPS := doGet(nodeGPSPK)
|
||||
if respGPS.TotalTransmissions != 1 {
|
||||
t.Errorf("nodeGPS /paths: expected 1 transmission (canonical owner), got %d", respGPS.TotalTransmissions)
|
||||
}
|
||||
|
||||
// Acceptance #2: nodeNoGPS MUST NOT include the tx — its CANONICAL
|
||||
// (best-obs) resolved_path picked nodeGPS, so the packets page would
|
||||
// show nodeGPS. Consistency requires the same here.
|
||||
respNoGPS := doGet(nodeNoGPSPK)
|
||||
if respNoGPS.TotalTransmissions != 0 {
|
||||
var hashes []string
|
||||
for _, p := range respNoGPS.Paths {
|
||||
hashes = append(hashes, p.SampleHash)
|
||||
}
|
||||
t.Errorf("nodeNoGPS /paths: expected 0 transmissions (canonical/best-obs resolved_path picked NodeGPS, not NodeNoGPS) — anchor-bias inconsistency, got %d; sample hashes: %s",
|
||||
respNoGPS.TotalTransmissions, strings.Join(hashes, ","))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestServerDBHasNoWriteMethods enforces the architectural invariant from
|
||||
// issue #1283: cmd/server is the read path. All write/maintenance methods
|
||||
// (PruneOldPackets, PruneOldMetrics, RemoveStaleObservers) MUST live on
|
||||
// the ingestor's *Store, not on the server's *DB.
|
||||
//
|
||||
// Before the fix, these methods existed on cmd/server/*DB and used
|
||||
// cachedRW(db.path) to acquire a write lock, racing with the ingestor's
|
||||
// concurrent INSERTs and producing SQLITE_BUSY (the bug in #1283).
|
||||
// After the fix, this test passes because the methods are gone.
|
||||
func TestServerDBHasNoWriteMethods(t *testing.T) {
|
||||
forbidden := []string{
|
||||
"PruneOldPackets",
|
||||
"PruneOldMetrics",
|
||||
"RemoveStaleObservers",
|
||||
}
|
||||
typ := reflect.TypeOf((*DB)(nil))
|
||||
for _, name := range forbidden {
|
||||
if _, ok := typ.MethodByName(name); ok {
|
||||
t.Errorf("server *DB exposes forbidden write method %q — must be relocated to ingestor (#1283)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerDBConnIsReadOnly asserts that the *sql.DB the server opens
|
||||
// cannot acquire a write lock. The server has always opened mode=ro, but
|
||||
// before #1283 it routed around that by calling cachedRW(path) to get a
|
||||
// second RW handle. After the fix, server-side writes are impossible
|
||||
// because there is no helper to open a writable connection.
|
||||
func TestServerDBConnIsReadOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/ro_invariant.db"
|
||||
|
||||
// Bootstrap a minimal DB with the ingestor-style WAL opener so the
|
||||
// server can attach in read-only mode.
|
||||
if err := bootstrapMinimalDB(path); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
d, err := OpenDB(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer d.conn.Close()
|
||||
|
||||
_, err = d.conn.Exec(`INSERT INTO nodes (public_key, name) VALUES ('x','y')`)
|
||||
if err == nil {
|
||||
t.Fatalf("expected INSERT via server *DB to fail (read-only invariant)")
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrapMinimalDB creates a tiny DB with the columns these tests
|
||||
// need, opened with WAL so the read-only opener in OpenDB can attach.
|
||||
// Kept in *_test.go so it does NOT add any write capability to the
|
||||
// production server binary.
|
||||
func bootstrapMinimalDB(path string) error {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000", path)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rw.Close()
|
||||
if _, err := rw.Exec(`CREATE TABLE IF NOT EXISTS nodes (public_key TEXT PRIMARY KEY, name TEXT)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+119
-64
@@ -154,7 +154,9 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET")
|
||||
r.HandleFunc("/api/perf/write-sources", s.handlePerfWriteSources).Methods("GET")
|
||||
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
|
||||
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
|
||||
// /api/admin/prune removed in #1283 — pruning is owned by the
|
||||
// ingestor process (scheduled tickers + startup pass). Operators
|
||||
// who want an ad-hoc prune can restart the ingestor.
|
||||
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
|
||||
r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET")
|
||||
r.Handle("/api/backup", s.requireAPIKey(http.HandlerFunc(s.handleBackup))).Methods("GET")
|
||||
@@ -1206,6 +1208,10 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
relayMap = s.store.GetRepeaterRelayInfoMap(relayWindow)
|
||||
usefulMap = s.store.GetRepeaterUsefulnessScoreMap()
|
||||
}
|
||||
// Bridge axis (#672 axis 2 of 4). Snapshot is an atomic load
|
||||
// — safe to call regardless of needsRelay, and we want the
|
||||
// score on repeater rows specifically.
|
||||
bridgeMap := s.store.GetBridgeScoreMap()
|
||||
for _, node := range nodes {
|
||||
if pk, ok := node["public_key"].(string); ok {
|
||||
EnrichNodeWithHashSize(node, hashInfo[pk])
|
||||
@@ -1220,6 +1226,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
node["usefulness_score"] = lookupUsefulnessScore(usefulMap, pk)
|
||||
node["bridge_score"] = lookupUsefulnessScore(bridgeMap, pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1335,6 +1342,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pubkey)
|
||||
node["bridge_score"] = s.store.GetBridgeScore(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1511,6 +1519,27 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
candidates = filtered
|
||||
|
||||
// #1278: Read the CANONICAL persisted resolved_path for each surviving
|
||||
// candidate OUTSIDE s.mu (fetchResolvedPathForTxBest takes lruMu; the
|
||||
// lock-ordering contract forbids acquiring lruMu under s.mu).
|
||||
//
|
||||
// Option A from the issue: the packets page renders each tx via
|
||||
// fetchResolvedPathForTxBest. For /api/nodes/{pk}/paths to stay
|
||||
// CONSISTENT with the packets page, BOTH the containsTarget membership
|
||||
// decision AND the displayed hop names must come from that same
|
||||
// canonical resolved_path — not a re-resolution biased by passing the
|
||||
// queried node as hopContext anchor.
|
||||
//
|
||||
// Falls back to biased re-resolve only when a tx has no persisted
|
||||
// resolved_path (older data / async backfill incomplete); in that case
|
||||
// there's no canonical answer to be consistent with.
|
||||
canonicalRP := make(map[int][]*string, len(candidates))
|
||||
for _, tx := range candidates {
|
||||
if rp := s.store.fetchResolvedPathForTxBest(tx); rp != nil {
|
||||
canonicalRP[tx.ID] = rp
|
||||
}
|
||||
}
|
||||
|
||||
// Re-acquire read lock for the aggregation phase that reads store data.
|
||||
s.store.mu.RLock()
|
||||
|
||||
@@ -1528,6 +1557,11 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
// (handleNodePaths aggregates paths terminating at lowerPK). Passing nil
|
||||
// here re-introduced regression #1197 in production. See
|
||||
// resolve_context_callsites_test.go.
|
||||
//
|
||||
// NOTE (#1278): this biased resolver is only consulted for the FALLBACK
|
||||
// path — txs with no persisted resolved_path. Txs with a canonical
|
||||
// resolved_path use the persisted pubkeys directly (see canonicalRP),
|
||||
// which keeps results consistent with the packets page.
|
||||
hopContext := []string{lowerPK}
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
@@ -1537,38 +1571,96 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
// nodeByPK caches pubkey → *nodeInfo lookups when rendering canonical
|
||||
// resolved_path entries. Cheap O(1) hit against pm.m (the prefix map
|
||||
// stores the full pubkey as a key for pubkeys >= maxPrefixLen).
|
||||
nodeByPK := make(map[string]*nodeInfo)
|
||||
lookupNode := func(pk string) *nodeInfo {
|
||||
key := strings.ToLower(pk)
|
||||
if cached, ok := nodeByPK[key]; ok {
|
||||
return cached
|
||||
}
|
||||
// Use plain resolve(); we have the full pubkey, no ambiguity.
|
||||
n := pm.resolve(key)
|
||||
if n == nil || !strings.EqualFold(n.PublicKey, key) {
|
||||
// Full pubkey may not be present in pm (role filter, eviction).
|
||||
// Fall through with nil; caller renders prefix-only entry.
|
||||
nodeByPK[key] = nil
|
||||
return nil
|
||||
}
|
||||
nodeByPK[key] = n
|
||||
return n
|
||||
}
|
||||
for _, tx := range candidates {
|
||||
hops := txGetParsedPath(tx)
|
||||
resolvedHops := make([]PathHopResp, len(hops))
|
||||
sigParts := make([]string, len(hops))
|
||||
// For candidates not confirmed via full-pubkey index or SQL, verify that at
|
||||
// least one hop actually resolves to the target. This catches prefix collisions
|
||||
// (e.g. two nodes sharing a "7a" 1-byte prefix) that slipped through the
|
||||
// conservative resolved_path fallback.
|
||||
containsTarget := confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]
|
||||
for i, hop := range hops {
|
||||
resolved := resolveHop(hop)
|
||||
entry := PathHopResp{Prefix: hop, Name: hop}
|
||||
if resolved != nil {
|
||||
entry.Name = resolved.Name
|
||||
entry.Pubkey = resolved.PublicKey
|
||||
if resolved.HasGPS {
|
||||
entry.Lat = resolved.Lat
|
||||
entry.Lon = resolved.Lon
|
||||
containsTarget := false
|
||||
|
||||
if rp, ok := canonicalRP[tx.ID]; ok {
|
||||
// Option A: render hops + decide membership from the CANONICAL
|
||||
// persisted resolved_path. resolved_path is parallel to the
|
||||
// best-obs path_json which may be longer than tx.PathJSON used by
|
||||
// txGetParsedPath; align by the shorter length.
|
||||
rpLen := len(rp)
|
||||
for i, hop := range hops {
|
||||
entry := PathHopResp{Prefix: hop, Name: hop}
|
||||
var resolvedPK string
|
||||
if i < rpLen && rp[i] != nil {
|
||||
resolvedPK = strings.ToLower(*rp[i])
|
||||
}
|
||||
sigParts[i] = resolved.PublicKey
|
||||
if strings.ToLower(resolved.PublicKey) == lowerPK {
|
||||
containsTarget = true
|
||||
}
|
||||
} else {
|
||||
sigParts[i] = hop
|
||||
// Unresolvable hop: keep conservative if prefix could be the target.
|
||||
if strings.HasPrefix(lowerPK, strings.ToLower(hop)) {
|
||||
containsTarget = true
|
||||
if resolvedPK != "" {
|
||||
if n := lookupNode(resolvedPK); n != nil {
|
||||
entry.Name = n.Name
|
||||
entry.Pubkey = n.PublicKey
|
||||
if n.HasGPS {
|
||||
entry.Lat = n.Lat
|
||||
entry.Lon = n.Lon
|
||||
}
|
||||
sigParts[i] = n.PublicKey
|
||||
} else {
|
||||
entry.Pubkey = resolvedPK
|
||||
sigParts[i] = resolvedPK
|
||||
}
|
||||
if resolvedPK == lowerPK {
|
||||
containsTarget = true
|
||||
}
|
||||
} else {
|
||||
sigParts[i] = hop
|
||||
}
|
||||
resolvedHops[i] = entry
|
||||
}
|
||||
} else {
|
||||
// Fallback: no canonical resolved_path persisted (older data /
|
||||
// async backfill incomplete). Use biased re-resolve and the
|
||||
// legacy containsTarget heuristics (preserves #1197 behavior
|
||||
// and the #929 prefix-collision exclusion test).
|
||||
containsTarget = confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]
|
||||
for i, hop := range hops {
|
||||
resolved := resolveHop(hop)
|
||||
entry := PathHopResp{Prefix: hop, Name: hop}
|
||||
if resolved != nil {
|
||||
entry.Name = resolved.Name
|
||||
entry.Pubkey = resolved.PublicKey
|
||||
if resolved.HasGPS {
|
||||
entry.Lat = resolved.Lat
|
||||
entry.Lon = resolved.Lon
|
||||
}
|
||||
sigParts[i] = resolved.PublicKey
|
||||
if strings.ToLower(resolved.PublicKey) == lowerPK {
|
||||
containsTarget = true
|
||||
}
|
||||
} else {
|
||||
sigParts[i] = hop
|
||||
// Unresolvable hop: keep conservative if prefix could be the target.
|
||||
if strings.HasPrefix(lowerPK, strings.ToLower(hop)) {
|
||||
containsTarget = true
|
||||
}
|
||||
}
|
||||
resolvedHops[i] = entry
|
||||
}
|
||||
resolvedHops[i] = entry
|
||||
}
|
||||
|
||||
if !containsTarget {
|
||||
continue
|
||||
}
|
||||
@@ -2761,45 +2853,8 @@ func parseWindowDuration(window string) (time.Duration, error) {
|
||||
return time.ParseDuration(window)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
|
||||
days := 0
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
fmt.Sscanf(d, "%d", &days)
|
||||
}
|
||||
if days <= 0 && s.cfg.Retention != nil {
|
||||
days = s.cfg.Retention.PacketDays
|
||||
}
|
||||
if days <= 0 {
|
||||
writeError(w, 400, "days parameter required (or set retention.packetDays in config)")
|
||||
return
|
||||
}
|
||||
|
||||
results := map[string]interface{}{}
|
||||
|
||||
// Prune old packets
|
||||
n, err := s.db.PruneOldPackets(days)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
results["packets_deleted"] = n
|
||||
results["deleted"] = n // legacy alias
|
||||
|
||||
// Also mark stale observers as inactive if observerDays is configured
|
||||
observerDays := s.cfg.ObserverDaysOrDefault()
|
||||
if observerDays > 0 {
|
||||
obsN, obsErr := s.db.RemoveStaleObservers(observerDays)
|
||||
if obsErr != nil {
|
||||
log.Printf("[prune] observer prune error: %v", obsErr)
|
||||
} else {
|
||||
results["observers_inactive"] = obsN
|
||||
}
|
||||
}
|
||||
|
||||
results["days"] = days
|
||||
writeJSON(w, results)
|
||||
}
|
||||
// handleAdminPrune was removed in #1283. Prune now runs in the ingestor
|
||||
// process (server is read-only). The function and route are gone.
|
||||
|
||||
// constantTimeEqual compares two strings in constant time to prevent timing attacks.
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
|
||||
+14
-1
@@ -18,9 +18,12 @@ import (
|
||||
)
|
||||
|
||||
// payloadTypeNames maps payload_type int → human-readable name (firmware-standard).
|
||||
// Must stay in sync with the canonical map in cmd/ingestor/decoder.go and
|
||||
// cmd/server/decoder.go. Source of truth: firmware/src/Packet.h:19-32.
|
||||
var payloadTypeNames = map[int]string{
|
||||
0: "REQ", 1: "RESPONSE", 2: "TXT_MSG", 3: "ACK", 4: "ADVERT",
|
||||
5: "GRP_TXT", 7: "ANON_REQ", 8: "PATH", 9: "TRACE", 11: "CONTROL",
|
||||
5: "GRP_TXT", 6: "GRP_DATA", 7: "ANON_REQ", 8: "PATH", 9: "TRACE",
|
||||
10: "MULTIPART", 11: "CONTROL", 15: "RAW_CUSTOM",
|
||||
}
|
||||
|
||||
// StoreTx is an in-memory transmission with embedded observations.
|
||||
@@ -165,6 +168,8 @@ type PacketStore struct {
|
||||
recompHashCollisions *analyticsRecomputer
|
||||
recompHashSizes *analyticsRecomputer
|
||||
recompRoles *analyticsRecomputer
|
||||
recompObserversClockSkew *analyticsRecomputer
|
||||
recompNodesClockSkew *analyticsRecomputer
|
||||
cacheHits int64
|
||||
cacheMisses int64
|
||||
// Rate-limited invalidation (fixes #533: caches cleared faster than hit)
|
||||
@@ -234,6 +239,14 @@ type PacketStore struct {
|
||||
repeaterEnrichRecompStop chan struct{}
|
||||
repeaterEnrichRecompDone chan struct{}
|
||||
|
||||
// Bridge axis (issue #672 axis 2 of 4): atomic snapshot of pubkey
|
||||
// → 0..1 betweenness-centrality score over the current neighbor
|
||||
// graph. Populated by the bridge recomputer (bridge_recomputer.go);
|
||||
// nil until the first compute lands. Read path is a single atomic
|
||||
// pointer load — no lock contention with the per-request enrichment
|
||||
// path in handleNodes (same discipline as #1248).
|
||||
bridgeScoreMap atomic.Pointer[map[string]float64]
|
||||
|
||||
// Precomputed distinct advert pubkey count (refcounted for eviction correctness).
|
||||
// Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats.
|
||||
advertPubkeys map[string]int // pubkey → number of advert packets referencing it
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkAutoVacuum inspects the current auto_vacuum mode and logs a warning
|
||||
// if it's not INCREMENTAL. Optionally performs a one-time full VACUUM if
|
||||
// the operator has set db.vacuumOnStartup: true in config (#919).
|
||||
func checkAutoVacuum(db *DB, cfg *Config, dbPath string) {
|
||||
var autoVacuum int
|
||||
if err := db.conn.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
log.Printf("[db] warning: could not read auto_vacuum: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if autoVacuum == 2 {
|
||||
log.Printf("[db] auto_vacuum=INCREMENTAL")
|
||||
return
|
||||
}
|
||||
|
||||
modes := map[int]string{0: "NONE", 1: "FULL", 2: "INCREMENTAL"}
|
||||
mode := modes[autoVacuum]
|
||||
if mode == "" {
|
||||
mode = fmt.Sprintf("UNKNOWN(%d)", autoVacuum)
|
||||
}
|
||||
|
||||
log.Printf("[db] auto_vacuum=%s — DB needs one-time VACUUM to enable incremental auto-vacuum. "+
|
||||
"Set db.vacuumOnStartup: true in config to migrate (will block startup for several minutes on large DBs). "+
|
||||
"See https://github.com/Kpa-clawbot/CoreScope/issues/919", mode)
|
||||
|
||||
if cfg.DB != nil && cfg.DB.VacuumOnStartup {
|
||||
// WARNING: Full VACUUM creates a temporary copy of the entire DB file.
|
||||
// Requires ~2× the DB file size in free disk space or it will fail.
|
||||
log.Printf("[db] vacuumOnStartup=true — starting one-time full VACUUM (ensure 2x DB size free disk space)...")
|
||||
start := time.Now()
|
||||
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[db] VACUUM failed: could not open RW connection: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := rw.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
|
||||
log.Printf("[db] VACUUM failed: could not set auto_vacuum: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := rw.Exec("VACUUM"); err != nil {
|
||||
log.Printf("[db] VACUUM failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[db] VACUUM complete in %v — auto_vacuum is now INCREMENTAL", elapsed.Round(time.Millisecond))
|
||||
|
||||
// Re-check
|
||||
var newMode int
|
||||
if err := db.conn.QueryRow("PRAGMA auto_vacuum").Scan(&newMode); err == nil {
|
||||
if newMode == 2 {
|
||||
log.Printf("[db] auto_vacuum=INCREMENTAL (confirmed after VACUUM)")
|
||||
} else {
|
||||
log.Printf("[db] warning: auto_vacuum=%d after VACUUM — expected 2", newMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runIncrementalVacuum runs PRAGMA incremental_vacuum(N) on a read-write
|
||||
// connection. Safe to call on auto_vacuum=NONE databases (noop).
|
||||
func runIncrementalVacuum(dbPath string, pages int) {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[vacuum] could not open RW connection: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := rw.Exec(fmt.Sprintf("PRAGMA incremental_vacuum(%d)", pages)); err != nil {
|
||||
log.Printf("[vacuum] incremental_vacuum error: %v", err)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -9,12 +9,12 @@
|
||||
"nodeDays": 7,
|
||||
"observerDays": 14,
|
||||
"packetDays": 30,
|
||||
"_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). packetDays: transmissions older than N days are deleted (0 = disabled)."
|
||||
"_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). packetDays: transmissions older than N days are deleted (0 = disabled). NOTE (#1283): all four retention fields are consumed by the INGESTOR process. The server is read-only and never prunes."
|
||||
},
|
||||
"db": {
|
||||
"vacuumOnStartup": false,
|
||||
"incrementalVacuumPages": 1024,
|
||||
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs (blocks startup for minutes on large DBs; requires 2x DB file size in free disk space). incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919."
|
||||
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs. Executed by the INGESTOR at startup, BEFORE the MQTT subscriber starts (#1283), so there is no contention with concurrent writes. Blocks ingestor startup for minutes on large DBs; requires 2x DB file size in free disk space. incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919."
|
||||
},
|
||||
"_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.",
|
||||
"https": {
|
||||
@@ -259,8 +259,10 @@
|
||||
"channels": 300,
|
||||
"hashCollisions": 300,
|
||||
"hashSizes": 300,
|
||||
"roles": 300
|
||||
"roles": 300,
|
||||
"observersClockSkew": 300,
|
||||
"nodesClockSkew": 300
|
||||
}
|
||||
},
|
||||
"_comment_analytics": "Issue #1240 + #1256. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache."
|
||||
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache."
|
||||
}
|
||||
|
||||
+65
-15
@@ -2720,9 +2720,15 @@ function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = nu
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const regionLabel = rq ? (new URLSearchParams(rq.slice(1)).get('region') || '') : '';
|
||||
|
||||
let nodesResp;
|
||||
let nodesResp, hashSizesResp;
|
||||
try {
|
||||
nodesResp = await api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList });
|
||||
[nodesResp, hashSizesResp] = await Promise.all([
|
||||
api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
|
||||
// #1270: fetch CONFIGURED-hash-size counts so the Network Overview
|
||||
// tells the operational story (matching Hash Stats "By Repeaters"),
|
||||
// not just a math-only count of unique pubkey slices.
|
||||
api('/analytics/hash-sizes' + rq, { ttl: CLIENT_TTL.analyticsRF }).catch(() => null),
|
||||
]);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="text-muted" role="alert" style="padding:40px">Failed to load: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
@@ -2766,6 +2772,34 @@ function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = nu
|
||||
};
|
||||
});
|
||||
|
||||
// #1270: CONFIGURED-hash-size counts (operational truth) from
|
||||
// /api/analytics/hash-sizes — same source the Hash Stats tab uses.
|
||||
// distributionByRepeaters keys are stringified ints ("1","2","3").
|
||||
// "0" = no adverts observed yet, so the configured size is unknown
|
||||
// and that node doesn't count for any tier.
|
||||
const distByRepeaters = (hashSizesResp && hashSizesResp.distributionByRepeaters) || {};
|
||||
const configuredCount = {
|
||||
1: Number(distByRepeaters['1'] || 0),
|
||||
2: Number(distByRepeaters['2'] || 0),
|
||||
3: Number(distByRepeaters['3'] || 0),
|
||||
};
|
||||
const totalConfigured = configuredCount[1] + configuredCount[2] + configuredCount[3];
|
||||
|
||||
// Operational collisions per tier: only consider repeaters CONFIGURED
|
||||
// for this hash size — same definition the Hash Issues tab uses.
|
||||
// n.hash_size is enriched on the /nodes payload from GetNodeHashSizeInfo.
|
||||
const opCollisions = { 1: 0, 2: 0, 3: 0 };
|
||||
[1, 2, 3].forEach(b => {
|
||||
const sub = new Map();
|
||||
nodes.forEach(n => {
|
||||
if (n.hash_size !== b) return;
|
||||
const p = n.public_key.toUpperCase().slice(0, b * 2);
|
||||
if (!sub.has(p)) sub.set(p, 0);
|
||||
sub.set(p, sub.get(p) + 1);
|
||||
});
|
||||
opCollisions[b] = [...sub.values()].filter(c => c > 1).length;
|
||||
});
|
||||
|
||||
// Recommendation by network size
|
||||
const totalNodes = nodes.length;
|
||||
let rec, recDetail;
|
||||
@@ -2798,29 +2832,45 @@ function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = nu
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Total repeaters</div>
|
||||
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
|
||||
<div class="text-muted" style="font-size:0.78em;margin-top:4px">
|
||||
${totalConfigured.toLocaleString()} with known hash size
|
||||
</div>
|
||||
</div>
|
||||
${[1, 2, 3].map(b => `
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:150px;border-color:${stats[b].collidingPrefixes > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
${[1, 2, 3].map(b => {
|
||||
// #1270: PRIMARY = configured-for-this-size repeater count
|
||||
// (operational truth, matches Hash Stats "By Repeaters").
|
||||
// SECONDARY = math fact (unique pubkey slices). OPERATIONAL
|
||||
// COLLISIONS = colliding slices among configured-for-this-size repeaters only.
|
||||
const cfg = configuredCount[b];
|
||||
const opC = opCollisions[b];
|
||||
const borderVar = opC > 0 ? 'var(--status-red)' : 'var(--border)';
|
||||
const opLine = opC === 0
|
||||
? `<span style="color:var(--status-green)">✅ No operational collisions</span>`
|
||||
: `<span style="color:var(--status-red)">⚠️ ${opC} operational collision${opC !== 1 ? 's' : ''}</span>`;
|
||||
return `
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:170px;border-color:${borderVar}">
|
||||
<div class="analytics-stat-label">${b}-byte prefixes</div>
|
||||
<div class="analytics-stat-value" style="font-size:1em">
|
||||
${stats[b].usedPrefixes.toLocaleString()}
|
||||
<span class="text-muted" style="font-size:0.7em"> / ${spaceSizes[b].toLocaleString()}</span>
|
||||
<div class="analytics-stat-value" style="font-size:1em"
|
||||
data-pt-configured="${b}" data-value="${cfg}">
|
||||
${cfg.toLocaleString()}
|
||||
<span class="text-muted" style="font-size:0.7em"> of ${totalNodes.toLocaleString()} repeaters configured</span>
|
||||
</div>
|
||||
<div style="font-size:0.82em;margin-top:4px;color:${stats[b].collidingPrefixes > 0 ? 'var(--status-red)' : 'var(--status-green)'}">
|
||||
${stats[b].collidingPrefixes === 0
|
||||
? '✅ No collisions'
|
||||
: `⚠️ ${stats[b].collidingPrefixes} prefix${stats[b].collidingPrefixes !== 1 ? 'es' : ''} collide`}
|
||||
<div style="font-size:0.82em;margin-top:4px">${opLine}</div>
|
||||
<div class="text-muted" style="font-size:0.72em;margin-top:6px;border-top:1px dashed var(--border);padding-top:4px">
|
||||
<em>Theoretical:</em> ${stats[b].usedPrefixes.toLocaleString()} unique ${b}-byte slice${stats[b].usedPrefixes !== 1 ? 's' : ''}
|
||||
across all repeater pubkeys (of ${spaceSizes[b].toLocaleString()} possible)
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px;margin-bottom:12px">
|
||||
<strong>Recommendation: ${rec} prefixes</strong> — ${recDetail}
|
||||
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:4px">Hash size is configured per-node in firmware. Changing requires reflashing.</span>
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px;font-size:0.85em">
|
||||
<strong>ℹ️ About these numbers:</strong> This tool checks <em>repeater</em> public key prefixes regardless of their configured hash size. Only repeaters are included because they are the nodes that relay packets using hash-based addressing.
|
||||
The <a href="#/analytics?tab=collisions" style="color:var(--accent)">Hash Issues</a> tab shows only <em>operational</em> collisions — nodes that actually use the same hash size and are repeaters.
|
||||
A collision shown here may not appear in Hash Issues if the nodes use a different hash size.
|
||||
<strong>ℹ️ About these numbers:</strong> The primary count is how many repeaters are <em>configured</em> for each hash size (their advertised path hash byte length), matching the
|
||||
<a href="#/analytics?tab=hashsizes" style="color:var(--accent)">Hash Stats</a> tab. Operational collisions count only repeaters configured for the same hash size whose actual prefix slices collide — same definition the
|
||||
<a href="#/analytics?tab=collisions" style="color:var(--accent)">Hash Issues</a> tab uses. The <em>theoretical</em> line shows the math fact: how many distinct slices appear when every repeater pubkey is truncated to N bytes, regardless of configured hash size.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,6 +351,28 @@
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
/* #1274: marker-style swatches — mirror the live map circleMarker ring
|
||||
* convention (bright white ring = repeater, faded ring = other roles).
|
||||
* Background uses --role-repeater / --text-muted via CSS variables so
|
||||
* theming remains consistent. */
|
||||
.live-ring {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.live-ring--repeater {
|
||||
border: 1.5px solid #fff;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.live-ring--other {
|
||||
border: 1px solid #fff;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ---- Tooltip ---- */
|
||||
.live-tooltip {
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent) !important;
|
||||
|
||||
+42
-2
@@ -121,7 +121,9 @@
|
||||
|
||||
const TYPE_COLORS = window.TYPE_COLORS || {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6'
|
||||
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
|
||||
ANON_REQ: '#f43f5e', GRP_DATA: '#8b5cf6', MULTIPART: '#0d9488',
|
||||
CONTROL: '#b45309', RAW_CUSTOM: '#c026d3'
|
||||
};
|
||||
|
||||
const PAYLOAD_ICONS = {
|
||||
@@ -236,7 +238,32 @@
|
||||
// Set live-page height from JS — most reliable across all mobile browsers
|
||||
const page = document.querySelector('.live-page');
|
||||
const appEl = document.getElementById('app');
|
||||
const h = window.innerHeight;
|
||||
// #1267: the CSS rule for .live-page subtracts --bottom-nav-reserve
|
||||
// (0px desktop, 56px+safe-area at ≤768px) so the fixed .bottom-nav
|
||||
// (z-index 1200) does not occlude the VCR bar (position:absolute;
|
||||
// bottom:0; z-index 1000). Mirror that subtraction here — otherwise
|
||||
// this JS override clobbers the CSS height with raw window.innerHeight
|
||||
// and the VCR bar slides under the bottom-nav (issue #1267).
|
||||
// Prefer the bottom-nav's measured rendered height so we also cover
|
||||
// the 1px top border and any visual chrome the --bottom-nav-reserve
|
||||
// token doesn't account for; fall back to the token-resolved value.
|
||||
const reserve = (() => {
|
||||
const bn = document.querySelector('.bottom-nav');
|
||||
if (bn) {
|
||||
const cs = getComputedStyle(bn);
|
||||
if (cs.display !== 'none') {
|
||||
const r = bn.getBoundingClientRect().height;
|
||||
if (r > 0) return r;
|
||||
}
|
||||
}
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:absolute;visibility:hidden;height:var(--bottom-nav-reserve,0px);pointer-events:none;';
|
||||
(document.body || document.documentElement).appendChild(probe);
|
||||
const px = probe.getBoundingClientRect().height || 0;
|
||||
probe.remove();
|
||||
return px;
|
||||
})();
|
||||
const h = Math.max(0, window.innerHeight - reserve);
|
||||
if (page) page.style.height = h + 'px';
|
||||
if (appEl) appEl.style.height = h + 'px';
|
||||
if (map) {
|
||||
@@ -1010,10 +1037,23 @@
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.GRP_TXT}" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.TXT_MSG}" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.REQUEST}" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.RESPONSE}" aria-hidden="true"></span> Response — Data response</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.TRACE}" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.PATH}" aria-hidden="true"></span> Path — Path discovery</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.ANON_REQ}" aria-hidden="true"></span> Anon Req — Anonymous request</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.GRP_DATA}" aria-hidden="true"></span> Grp Data — Group datagram</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.MULTIPART}" aria-hidden="true"></span> Multipart — Multi-fragment payload</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.CONTROL}" aria-hidden="true"></span> Control — Control plane</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.RAW_CUSTOM}" aria-hidden="true"></span> Raw Custom — Application-defined payload</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.ACK}" aria-hidden="true"></span> Ack / Other — Acknowledgment or unknown type</li>
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list" id="roleLegendList"></ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">MARKER STYLES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-ring live-ring--repeater" aria-hidden="true"></span> Bright white ring — repeater</li>
|
||||
<li><span class="live-ring live-ring--other" aria-hidden="true"></span> Faded ring — companion / sensor / room</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+44
-1
@@ -176,6 +176,13 @@
|
||||
if (n.hash_size) {
|
||||
html += ` <span class="badge" style="background:var(--nav-bg);color:var(--nav-text);font-family:var(--mono)">${n.public_key.slice(0, n.hash_size * 2).toUpperCase()}</span>`;
|
||||
}
|
||||
// #1279 P2 #4: multibyte capability badge — surfaced from the observable
|
||||
// multibyte hash_size (firmware Feat1/Feat2 carry the wire capability bits
|
||||
// per AdvertDataHelpers.h:14-16, but Feat1/Feat2 aren't persisted per-node
|
||||
// in CoreScope today; hash_size is the observed effective capability).
|
||||
if (n.hash_size && Number(n.hash_size) >= 2) {
|
||||
html += ` <span class="badge multibyte-badge" title="Node advertises multibyte hash path (firmware Feat1/Feat2)" style="background:var(--accent-bg, rgba(20,184,166,0.2));color:var(--accent, #14b8a6);font-size:10px">Multibyte: ${Number(n.hash_size)}-byte</span>`;
|
||||
}
|
||||
if (n.hash_size_inconsistent) {
|
||||
html += ` <a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" class="badge" style="background:var(--status-yellow);color:#000;font-size:10px;cursor:pointer;text-decoration:none">⚠️ variable hash size</a>`;
|
||||
}
|
||||
@@ -547,6 +554,24 @@
|
||||
const barWidth = Math.max(2, Math.round(s * 100));
|
||||
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}"><td title="Fraction of non-advert traffic in the network observed by CoreScope that this repeater carries as a relay hop (Traffic axis of issue #672). Range 0–1; higher = forwards more of the mesh's actual traffic.">Usefulness</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
|
||||
})() : ''}
|
||||
${(n.role === 'repeater' || n.role === 'room') && n.bridge_score != null ? (() => {
|
||||
// Bridge axis (issue #672 axis 2 of 4): normalized betweenness
|
||||
// centrality from the neighbor-edges graph. Distinct from the
|
||||
// Traffic-based Usefulness score above — bridge measures
|
||||
// STRUCTURAL importance (how many shortest paths between
|
||||
// other node pairs go through this one) regardless of
|
||||
// current traffic.
|
||||
const b = Number(n.bridge_score) || 0;
|
||||
const bpct = (b * 100).toFixed(1);
|
||||
let blabel, bcolor;
|
||||
if (b >= 0.5) { blabel = 'Critical bridge'; bcolor = 'var(--status-green, #2ecc71)'; }
|
||||
else if (b >= 0.2) { blabel = 'Important'; bcolor = 'var(--status-green, #2ecc71)'; }
|
||||
else if (b >= 0.05) { blabel = 'Some role'; bcolor = 'var(--status-yellow, #f1c40f)'; }
|
||||
else if (b > 0) { blabel = 'Marginal'; bcolor = 'var(--status-orange, #e67e22)'; }
|
||||
else { blabel = 'No bridge role'; bcolor = 'var(--text-muted)'; }
|
||||
const bbarWidth = Math.max(2, Math.round(b * 100));
|
||||
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="Structural importance of this repeater as a path between other nodes — normalized betweenness centrality on the neighbor-edges graph (Bridge axis of issue #672, axis 2 of 4). Higher = more pairs of nodes route shortest paths through this one. Independent of current traffic.">Bridge</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
|
||||
})() : ''}
|
||||
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
|
||||
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
|
||||
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
|
||||
@@ -918,7 +943,25 @@
|
||||
var hashBlocks = evidence.map(function(ev) {
|
||||
var shortHash = (ev.hash || '').substring(0, 8) + '…';
|
||||
var obsCount = ev.observers ? ev.observers.length : 0;
|
||||
var header = '<div style="font-weight:600;font-size:12px;margin-top:6px">Hash ' + shortHash + ' · ' + obsCount + ' observer' + (obsCount !== 1 ? 's' : '') + ' · median corrected: ' + formatSkew(ev.medianCorrectedSkewSec) + '</div>';
|
||||
// #1285: per-hash median is server-side filtered to exclude RTC-reset
|
||||
// outliers (|corrected skew| > 24h). Compute the same on the client so
|
||||
// we can label hashes whose observers ALL saw a reset-shaped advert as
|
||||
// "insufficient data — N outliers excluded" instead of rendering 0 or
|
||||
// a misleading post-filter value.
|
||||
var OUTLIER_SEC = 86400;
|
||||
var outlierObs = 0;
|
||||
(ev.observers || []).forEach(function(o) {
|
||||
if (Math.abs(o.correctedSkewSec || 0) > OUTLIER_SEC) outlierObs++;
|
||||
});
|
||||
var medianLabel;
|
||||
if (outlierObs > 0 && outlierObs === obsCount) {
|
||||
medianLabel = 'insufficient data (' + outlierObs + ' RTC-reset outlier' + (outlierObs !== 1 ? 's' : '') + ' excluded)';
|
||||
} else if (outlierObs > 0) {
|
||||
medianLabel = formatSkew(ev.medianCorrectedSkewSec) + ' (' + outlierObs + ' RTC-reset outlier' + (outlierObs !== 1 ? 's' : '') + ' excluded)';
|
||||
} else {
|
||||
medianLabel = formatSkew(ev.medianCorrectedSkewSec);
|
||||
}
|
||||
var header = '<div style="font-weight:600;font-size:12px;margin-top:6px">Hash ' + shortHash + ' · ' + obsCount + ' observer' + (obsCount !== 1 ? 's' : '') + ' · median corrected: ' + medianLabel + '</div>';
|
||||
var lines = (ev.observers || []).map(function(o) {
|
||||
var name = o.observerName || o.observerID;
|
||||
return '<div style="font-size:11px;padding-left:16px;font-family:var(--mono)">' +
|
||||
|
||||
@@ -283,6 +283,16 @@
|
||||
if (field === 'payload_hex') {
|
||||
return packet.raw_hex ? packet.raw_hex.slice(4) : '';
|
||||
}
|
||||
// TransportCodes fields surfaced from decoded_json.transportCodes
|
||||
// (firmware/src/Packet.h:46, parsed at cmd/server/decoder.go:492-498).
|
||||
if (field === 'code1' || field === 'code2') {
|
||||
try {
|
||||
var dc = typeof packet.decoded_json === 'string' ? JSON.parse(packet.decoded_json) : packet.decoded_json;
|
||||
if (!dc || !dc.transportCodes) return null;
|
||||
var v = field === 'code1' ? dc.transportCodes.code1 : dc.transportCodes.code2;
|
||||
return v ? String(v).toUpperCase() : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
// Decoded payload fields (dot notation)
|
||||
if (field.startsWith('payload.')) {
|
||||
try {
|
||||
@@ -449,6 +459,8 @@
|
||||
{ name: 'payload.flags.repeater', desc: 'Decoded payload: advert flag (repeater role)' },
|
||||
{ name: 'payload.flags.room', desc: 'Decoded payload: advert flag (room server)' },
|
||||
{ name: 'payload.flags.hasLocation', desc: 'Decoded payload: advert has location' },
|
||||
{ name: 'code1', desc: 'Transport route Code1 (hex, e.g. AABB) — present on TRANSPORT_FLOOD/DIRECT' },
|
||||
{ name: 'code2', desc: 'Transport route Code2 (hex, e.g. CCDD) — present on TRANSPORT_FLOOD/DIRECT' },
|
||||
];
|
||||
|
||||
var OPERATORS = [
|
||||
|
||||
+32
-6
@@ -2841,15 +2841,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Location: from ADVERT lat/lon, or from known node via pubkey/sender name
|
||||
let locationHtml = '—';
|
||||
// Location: from ADVERT lat/lon, or from known node via pubkey/sender name.
|
||||
// Issue #1281: only render the row when we actually have transmitter GPS.
|
||||
// Non-ADVERT packets don't carry GPS in the unencrypted payload, so the row
|
||||
// would otherwise render as "—" and waste a slot on ~90% of packet types.
|
||||
let locationHtml = '';
|
||||
let locationNodeKey = null;
|
||||
if (decoded.lat != null && decoded.lon != null && !(decoded.lat === 0 && decoded.lon === 0)) {
|
||||
locationNodeKey = decoded.pubKey || decoded.srcPubKey || '';
|
||||
const nodeName = decoded.name || '';
|
||||
locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`;
|
||||
if (nodeName) locationHtml = `${escapeHtml(nodeName)} — ${locationHtml}`;
|
||||
if (locationNodeKey) locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
|
||||
if (locationNodeKey) locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link">📍map</a>`;
|
||||
} else {
|
||||
// Try to resolve sender node location from nodes list
|
||||
const senderKey = decoded.pubKey || decoded.srcPubKey;
|
||||
@@ -2861,7 +2864,7 @@
|
||||
locationNodeKey = nodeData.node.public_key;
|
||||
locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`;
|
||||
if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)} — ${locationHtml}`;
|
||||
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
|
||||
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link">📍map</a>`;
|
||||
} else if (senderName && !senderKey) {
|
||||
// Search by name
|
||||
const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
|
||||
@@ -2870,7 +2873,7 @@
|
||||
locationNodeKey = match.public_key;
|
||||
locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`;
|
||||
locationHtml = `${escapeHtml(match.name)} — ${locationHtml}`;
|
||||
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
|
||||
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" class="loc-map-link">📍map</a>`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -2889,6 +2892,27 @@
|
||||
? `<span style="font-size:0.8em;color:var(--text-muted);margin-left:6px">(observation ${observations.indexOf(currentObs) + 1} of ${observations.length})</span>`
|
||||
: '';
|
||||
|
||||
// #1279 P2 #3 — Transport codes detail row (firmware/src/Packet.h:46,
|
||||
// parsed at cmd/server/decoder.go:492-498). Present on TRANSPORT_FLOOD/
|
||||
// TRANSPORT_DIRECT routes only.
|
||||
var tcCode1 = '—', tcCode2 = '—', tcShow = false;
|
||||
if (decoded.transportCodes) {
|
||||
tcShow = true;
|
||||
if (decoded.transportCodes.code1) tcCode1 = String(decoded.transportCodes.code1).toUpperCase();
|
||||
if (decoded.transportCodes.code2) tcCode2 = String(decoded.transportCodes.code2).toUpperCase();
|
||||
}
|
||||
var transportCodesRow = tcShow
|
||||
? `<dt>Transport Codes</dt><dd class="transport-codes">Code1: <code>${escapeHtml(tcCode1)}</code> · Code2: <code>${escapeHtml(tcCode2)}</code></dd>`
|
||||
: '';
|
||||
|
||||
// #1279 P2 #5 — RAW_CUSTOM detail row (firmware/src/Mesh.cpp:577).
|
||||
var rawCustomRow = '';
|
||||
if (pkt.payload_type === 15 && decoded.type === 'RAW_CUSTOM') {
|
||||
var rl = decoded.rawLength != null ? decoded.rawLength + ' byte' + (decoded.rawLength === 1 ? '' : 's') : '—';
|
||||
var ft = decoded.firstByteTag ? String(decoded.firstByteTag).toUpperCase() : '—';
|
||||
rawCustomRow = `<dt>Raw Custom</dt><dd class="raw-custom-detail">Length: <code>${escapeHtml(rl)}</code> · First byte tag: <code>${escapeHtml(ft)}</code></dd>`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${anomalyBanner}
|
||||
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
|
||||
@@ -2896,7 +2920,7 @@
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Observer</dt><dd>${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}</dd>
|
||||
<dt>Location</dt><dd>${locationHtml}</dd>
|
||||
${locationHtml ? `<dt>Location</dt><dd>${locationHtml}</dd>` : ''}
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
@@ -2904,6 +2928,8 @@
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(effectivePkt.timestamp)}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<dt>Path</dt><dd>${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span> ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}</dd>
|
||||
${transportCodesRow}
|
||||
${rawCustomRow}
|
||||
${effectivePkt.direction ? `<dt>Direction</dt><dd>${escapeHtml(effectivePkt.direction)}</dd>` : ''}
|
||||
</dl>
|
||||
<div class="detail-actions">
|
||||
|
||||
+9
-4
@@ -917,6 +917,10 @@ body.scroll-locked { overflow: hidden; }
|
||||
}
|
||||
.detail-meta dt { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.detail-meta dd { font-weight: 500; margin-bottom: 4px; }
|
||||
/* #1281: 📍map link inside detail-meta — UA-default blue is unreadable on
|
||||
* dark backgrounds. Force theme-aware color via --accent. */
|
||||
.loc-map-link { color: var(--accent); font-size: 0.85em; text-decoration: none; }
|
||||
.loc-map-link:hover { text-decoration: underline; }
|
||||
.observation-current { background: var(--accent-bg, rgba(0,122,255,0.1)); font-weight: 600; }
|
||||
.detail-obs-row:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
|
||||
.detail-obs-table th { font-size: 0.8em; text-transform: uppercase; color: var(--text-muted); }
|
||||
@@ -2170,12 +2174,12 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
}
|
||||
.observer-selector { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.node-qr { text-align: center; margin-top: 8px; }
|
||||
.node-qr svg { max-width: 100px; border-radius: 4px; }
|
||||
.node-qr svg { max-width: 100px; height: auto; border-radius: 4px; }
|
||||
[data-theme="dark"] .node-qr svg rect[fill="#ffffff"] { fill: var(--card-bg); }
|
||||
[data-theme="dark"] .node-qr svg rect[fill="#000000"] { fill: var(--text); }
|
||||
.node-map-qr-wrap { position: relative; }
|
||||
.node-map-qr-overlay { position: absolute; bottom: 8px; right: 8px; z-index: 400; background: rgba(255,255,255,0.5); border-radius: 4px; padding: 4px; line-height: 0; margin: 0; text-align: center; }
|
||||
.node-map-qr-overlay svg { max-width: 56px !important; display: block; margin: 0; }
|
||||
.node-map-qr-overlay svg { max-width: 56px !important; height: auto; display: block; margin: 0; }
|
||||
[data-theme="dark"] .node-map-qr-overlay { background: rgba(255,255,255,0.4); }
|
||||
|
||||
/* Replay on Live Map button in packet detail */
|
||||
@@ -2462,7 +2466,8 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.node-top-row { display: flex; gap: 16px; margin-bottom: 12px; }
|
||||
.node-top-row .node-map-wrap { flex: 3; min-height: 200px; border-radius: 8px; overflow: hidden; }
|
||||
.node-top-row .node-map-wrap .node-detail-map { height: 100%; }
|
||||
.node-top-row .node-qr-wrap { flex: 1; min-width: 120px; max-width: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
||||
.node-top-row .node-qr-wrap { flex: 1; align-self: flex-start; min-width: 120px; max-width: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
|
||||
.node-top-row .node-qr-wrap .node-qr { margin-top: 0; }
|
||||
.node-qr-wrap--full { max-width: 240px; margin: 0 auto; }
|
||||
.node-stats-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 12px; }
|
||||
.node-stats-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
||||
@@ -2500,7 +2505,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
line-height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.node-top-row .node-qr-wrap .node-qr svg { max-width: 72px; }
|
||||
.node-top-row .node-qr-wrap .node-qr svg { max-width: 72px; height: auto; }
|
||||
/* Hide the redundant pubkey caption inside the overlay QR — it's already
|
||||
shown in the card above and would push the overlay too large. */
|
||||
.node-top-row .node-qr-wrap .mono { display: none; }
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* #1267 — VCR bar invisible on mobile /live (iOS Safari ~375x812).
|
||||
*
|
||||
* RED first: at a 375x812 mobile viewport, the `.vcr-bar` must be visible
|
||||
* between the map and bottom-nav. Asserts measured height > 0, display !==
|
||||
* 'none', visibility !== 'hidden', and that its top edge is within the
|
||||
* viewport (not pushed below the visible area).
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-e2e-1267-mobile-vcr.js
|
||||
*/
|
||||
const { chromium, devices } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 375, height: 812 },
|
||||
deviceScaleFactor: 2,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
let failed = false;
|
||||
function fail(msg) { failed = true; console.log(' \u274c', msg); }
|
||||
function pass(msg) { console.log(' \u2705', msg); }
|
||||
|
||||
console.log(`\n#1267 mobile VCR-bar visibility against ${BASE} (375x812)\n`);
|
||||
|
||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
|
||||
// Allow live page mount + initial render + VCR bar ResizeObserver publish.
|
||||
await page.waitForSelector('.live-page', { timeout: 15000 });
|
||||
await page.waitForSelector('#vcrBar', { timeout: 15000 });
|
||||
// Wait until markers populate — #1267 only manifests after marker render.
|
||||
// We poll for >=1 leaflet-marker-icon to appear in the DOM, then settle.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#liveMap .leaflet-marker-icon, #liveMap .leaflet-marker-pane > *').length > 0,
|
||||
null,
|
||||
{ timeout: 15000 }
|
||||
).catch(() => {});
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const bar = document.getElementById('vcrBar');
|
||||
if (!bar) return { missing: true };
|
||||
const r = bar.getBoundingClientRect();
|
||||
const cs = getComputedStyle(bar);
|
||||
const page = document.querySelector('.live-page');
|
||||
const pageR = page ? page.getBoundingClientRect() : null;
|
||||
const bn = document.querySelector('.bottom-nav');
|
||||
const bnR = bn ? bn.getBoundingClientRect() : null;
|
||||
const bnCs = bn ? getComputedStyle(bn) : null;
|
||||
const rootCs = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
rect: { top: r.top, bottom: r.bottom, height: r.height, width: r.width, left: r.left, right: r.right },
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
position: cs.position,
|
||||
zIndex: cs.zIndex,
|
||||
viewportH: window.innerHeight,
|
||||
viewportW: window.innerWidth,
|
||||
pageRect: pageR ? { top: pageR.top, bottom: pageR.bottom, height: pageR.height } : null,
|
||||
bottomNav: bnR ? { top: bnR.top, bottom: bnR.bottom, height: bnR.height, display: bnCs.display } : null,
|
||||
bottomNavReserve: rootCs.getPropertyValue('--bottom-nav-reserve'),
|
||||
vcrBarHeightVar: getComputedStyle(page || document.body).getPropertyValue('--vcr-bar-height'),
|
||||
};
|
||||
});
|
||||
|
||||
console.log('VCR bar measurement:', JSON.stringify(info, null, 2));
|
||||
|
||||
if (info.missing) {
|
||||
fail('#vcrBar element not in DOM');
|
||||
} else {
|
||||
if (info.display === 'none') fail(`display:none on .vcr-bar`);
|
||||
else pass(`display is ${info.display}`);
|
||||
|
||||
if (info.visibility === 'hidden') fail(`visibility:hidden on .vcr-bar`);
|
||||
else pass(`visibility is ${info.visibility}`);
|
||||
|
||||
if (info.rect.height <= 0) fail(`getBoundingClientRect().height = ${info.rect.height} (expected > 0)`);
|
||||
else pass(`height ${info.rect.height}px > 0`);
|
||||
|
||||
if (info.rect.top >= info.viewportH) fail(`bar top ${info.rect.top} >= viewport height ${info.viewportH} (pushed off-screen)`);
|
||||
else pass(`bar top ${info.rect.top} < viewport height ${info.viewportH}`);
|
||||
|
||||
// #1267 root assertion: the VCR bar must not be occluded by the
|
||||
// fixed bottom-nav (z=1200 > vcr-bar z=1000). The bar's bottom edge
|
||||
// must sit AT OR ABOVE the bottom-nav's top edge.
|
||||
if (info.bottomNav && info.bottomNav.display !== 'none') {
|
||||
if (info.rect.bottom > info.bottomNav.top + 0.5) {
|
||||
fail(`VCR bar bottom ${info.rect.bottom} overlaps bottom-nav top ${info.bottomNav.top} (hidden behind bottom-nav — #1267)`);
|
||||
} else {
|
||||
pass(`VCR bar bottom ${info.rect.bottom} ≤ bottom-nav top ${info.bottomNav.top}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the bar should occupy width across most of the viewport.
|
||||
if (info.rect.width < info.viewportW * 0.5) fail(`bar width ${info.rect.width} < 50% of viewport ${info.viewportW}`);
|
||||
else pass(`bar width ${info.rect.width} spans >50% viewport`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch(err => { console.error(err); process.exit(2); });
|
||||
@@ -3102,6 +3102,55 @@ async function run() {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
});
|
||||
|
||||
// Issue #1270: The Prefix Tool's Network Overview must report
|
||||
// CONFIGURED-hash-size repeater counts (the operational truth) as the
|
||||
// primary number for each tier, agreeing with the Hash Stats tab's
|
||||
// "By Repeaters" panel. The math-only "unique slices of every pubkey"
|
||||
// number is allowed only as a secondary/educational stat. Before the
|
||||
// fix, Prefix Tool showed "168 / 65536" for 2-byte while Hash Stats
|
||||
// showed only 20 repeaters actually configured for 2-byte hashing.
|
||||
await test('#1270 Prefix Tool primary counts match Hash Stats By Repeaters', async () => {
|
||||
// 1) Read configured-by-hash-size counts straight from the API
|
||||
// (this is what the Hash Stats tab renders).
|
||||
const distByRepeaters = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/analytics/hash-sizes');
|
||||
const j = await r.json();
|
||||
return j.distributionByRepeaters || {};
|
||||
});
|
||||
const expected = {
|
||||
1: Number(distByRepeaters['1'] || 0),
|
||||
2: Number(distByRepeaters['2'] || 0),
|
||||
3: Number(distByRepeaters['3'] || 0),
|
||||
};
|
||||
|
||||
// 2) Visit the Prefix Tool tab, open Network Overview, scrape
|
||||
// the primary stat values for each tier.
|
||||
await page.goto(`${BASE}/#/analytics?tab=prefix-tool`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#ptOverview', { timeout: 15000 });
|
||||
// Open the overview accordion if collapsed.
|
||||
await page.evaluate(() => {
|
||||
const body = document.getElementById('ptOverviewBody');
|
||||
if (body && getComputedStyle(body).display === 'none') {
|
||||
document.getElementById('ptOverviewToggle').click();
|
||||
}
|
||||
});
|
||||
await page.waitForSelector('[data-pt-configured="1"]', { timeout: 5000 });
|
||||
const got = await page.evaluate(() => {
|
||||
const read = (b) => {
|
||||
const el = document.querySelector(`[data-pt-configured="${b}"]`);
|
||||
return el ? Number(el.getAttribute('data-value')) : null;
|
||||
};
|
||||
return { 1: read(1), 2: read(2), 3: read(3) };
|
||||
});
|
||||
|
||||
assert(got[1] === expected[1],
|
||||
`#1270 1-byte: prefix-tool shows ${got[1]}, hash-sizes API shows ${expected[1]}`);
|
||||
assert(got[2] === expected[2],
|
||||
`#1270 2-byte: prefix-tool shows ${got[2]}, hash-sizes API shows ${expected[2]}`);
|
||||
assert(got[3] === expected[3],
|
||||
`#1270 3-byte: prefix-tool shows ${got[3]}, hash-sizes API shows ${expected[3]}`);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Summary
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* #1273 — QR overlay container is 2-3× taller than the QR canvas on the
|
||||
* full node detail page (`#/nodes/<pubkey>`).
|
||||
*
|
||||
* On mobile (<=640px) and desktop, `.node-top-row .node-qr-wrap` (the QR
|
||||
* overlay box) must NOT have meaningful empty translucent space below the
|
||||
* QR canvas. The wrap's bounding-rect height must be ≤ the inner QR
|
||||
* canvas/svg height + 32px (covers padding + caption + minor rounding).
|
||||
*
|
||||
* Asserted on:
|
||||
* - 375x800 (mobile — overlay style applies)
|
||||
* - 1280x800 (desktop guard — existing flex layout must not regress)
|
||||
*
|
||||
* RED on master (mobile): the absolute-positioned overlay inherits the
|
||||
* column flex layout with `justify-content: center` and the caption hidden
|
||||
* but still allocates space because the wrap doesn't have a content-fit
|
||||
* height.
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
async function pickPubkey(page) {
|
||||
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
|
||||
return await page.evaluate(async () => {
|
||||
const r = await fetch('/api/nodes?limit=20');
|
||||
const d = await r.json();
|
||||
return (d.nodes || [])[0] && (d.nodes || [])[0].public_key;
|
||||
});
|
||||
}
|
||||
|
||||
async function measureOverlay(page, pubkey) {
|
||||
await page.goto(BASE + '/#/nodes/' + encodeURIComponent(pubkey),
|
||||
{ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.node-top-row .node-qr-wrap', { timeout: 10000 });
|
||||
// Wait until the QR svg is actually painted inside the wrap.
|
||||
await page.waitForFunction(() => {
|
||||
const wrap = document.querySelector('.node-top-row .node-qr-wrap');
|
||||
return wrap && wrap.querySelector('.node-qr svg');
|
||||
}, { timeout: 10000 });
|
||||
await page.waitForTimeout(150); // allow layout to settle
|
||||
return await page.evaluate(() => {
|
||||
const wrap = document.querySelector('.node-top-row .node-qr-wrap');
|
||||
const svg = wrap.querySelector('.node-qr svg');
|
||||
const wr = wrap.getBoundingClientRect();
|
||||
const sr = svg.getBoundingClientRect();
|
||||
const cap = wrap.querySelector('.mono');
|
||||
const capH = cap && getComputedStyle(cap).display !== 'none'
|
||||
? Math.round(cap.getBoundingClientRect().height) : 0;
|
||||
// QR is always square — the visible/intended QR height is the SMALLER
|
||||
// of svg width vs svg height. Any "extra" svg height beyond that is
|
||||
// wasted intrinsic-sizing space that bloats the wrap.
|
||||
const qrVisibleH = Math.min(Math.round(sr.width), Math.round(sr.height));
|
||||
return {
|
||||
wrapH: Math.round(wr.height),
|
||||
wrapW: Math.round(wr.width),
|
||||
svgH: Math.round(sr.height),
|
||||
svgW: Math.round(sr.width),
|
||||
qrVisibleH,
|
||||
capH,
|
||||
position: getComputedStyle(wrap).position,
|
||||
top: Math.round(wr.top),
|
||||
right: Math.round(window.innerWidth - wr.right),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const launchOpts = { headless: true, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
|
||||
console.log(`\n=== #1273 QR overlay height E2E against ${BASE} ===`);
|
||||
|
||||
// ── pick a real pubkey from the API ──
|
||||
const ctxBoot = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const probe = await ctxBoot.newPage();
|
||||
const pubkey = await pickPubkey(probe);
|
||||
await ctxBoot.close();
|
||||
assert(pubkey, 'No pubkey returned from /api/nodes');
|
||||
console.log(' → probe pubkey: ' + pubkey.slice(0, 12) + '…');
|
||||
|
||||
// ── Mobile 375x800 ──
|
||||
const m = await browser.newContext({ viewport: { width: 375, height: 800 } });
|
||||
const mp = await m.newPage();
|
||||
await step('mobile 375x800: .node-qr-wrap height \u2264 visible QR + 32px', async () => {
|
||||
const d = await measureOverlay(mp, pubkey);
|
||||
console.log(' mobile measurements: ' + JSON.stringify(d));
|
||||
assert(d.qrVisibleH > 0, 'QR svg has zero visible square (not rendered)');
|
||||
assert(d.position === 'absolute',
|
||||
'mobile overlay must remain position:absolute, got ' + d.position);
|
||||
assert(d.wrapH <= d.qrVisibleH + d.capH + 32,
|
||||
`wrap height ${d.wrapH}px must be \u2264 visible QR ${d.qrVisibleH}px + caption ${d.capH}px + 32px (delta ${d.wrapH - d.qrVisibleH - d.capH}px)`);
|
||||
});
|
||||
await m.close();
|
||||
|
||||
// ── Desktop 1280x800 (regression guard) ──
|
||||
const dctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const dp = await dctx.newPage();
|
||||
await step('desktop 1280x800: .node-qr-wrap height \u2264 visible QR + caption + 32px', async () => {
|
||||
const d = await measureOverlay(dp, pubkey);
|
||||
console.log(' desktop measurements: ' + JSON.stringify(d));
|
||||
assert(d.qrVisibleH > 0, 'QR svg has zero visible square (not rendered)');
|
||||
assert(d.wrapH <= d.qrVisibleH + d.capH + 32,
|
||||
`wrap height ${d.wrapH}px must be \u2264 visible QR ${d.qrVisibleH}px + caption ${d.capH}px + 32px (delta ${d.wrapH - d.qrVisibleH - d.capH}px)`);
|
||||
});
|
||||
await dctx.close();
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' +
|
||||
(failed ? ', ' + failed + ' failed' : ''));
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
)().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* E2E for #1274 — Live legend must document gray packets (ACK + unknown
|
||||
* payload types), the RESPONSE and PATH colors, AND the white-ring
|
||||
* repeater convention. See issue #1274 acceptance criteria.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1274-legend-coverage-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
async function gotoLive(page) {
|
||||
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#liveLegend', { timeout: 8000, state: 'attached' });
|
||||
await page.waitForTimeout(400);
|
||||
// Ensure the legend is expanded (it persists collapsed state via localStorage).
|
||||
const hidden = await page.evaluate(() => {
|
||||
const el = document.getElementById('liveLegend');
|
||||
return !!el && el.classList.contains('hidden');
|
||||
});
|
||||
if (hidden) {
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.removeItem('live-legend-hidden'); } catch (_) {}
|
||||
const el = document.getElementById('liveLegend');
|
||||
if (el) el.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function legendText(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.getElementById('liveLegend');
|
||||
return el ? (el.textContent || '').toLowerCase() : '';
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
console.log(`\n=== #1274 legend documents ACK/RESPONSE/PATH + white-ring — E2E against ${BASE} ===`);
|
||||
|
||||
for (const vp of [
|
||||
{ w: 1440, h: 900, tag: '[1440x900 desktop]' },
|
||||
{ w: 375, h: 800, tag: '[375x800 mobile]' },
|
||||
]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
await step(vp.tag + ' navigate to /live', async () => { await gotoLive(page); });
|
||||
|
||||
await step(vp.tag + ' legend lists ACK row', async () => {
|
||||
const t = await legendText(page);
|
||||
assert(/\back\b/.test(t), 'legend missing ACK row; text=' + t.slice(0, 400));
|
||||
});
|
||||
await step(vp.tag + ' legend lists RESPONSE row', async () => {
|
||||
const t = await legendText(page);
|
||||
assert(/response/.test(t), 'legend missing RESPONSE row');
|
||||
});
|
||||
await step(vp.tag + ' legend lists PATH row', async () => {
|
||||
const t = await legendText(page);
|
||||
assert(/path/.test(t), 'legend missing PATH row');
|
||||
});
|
||||
await step(vp.tag + ' legend documents white-ring / repeater convention', async () => {
|
||||
const t = await legendText(page);
|
||||
assert(/repeater/.test(t) && /ring/.test(t),
|
||||
'legend missing repeater white-ring documentation; text=' + t.slice(0, 600));
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* E2E for #1279 P2 #2 — Live legend covers all remaining named payload types.
|
||||
* After PR #1276 the legend already lists Advert/Message/Direct/Request/
|
||||
* Response/Trace/Path/Ack; this PR adds Anon Req, Grp Data, Multipart,
|
||||
* Control and Raw Custom.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
async function gotoLive(page) {
|
||||
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#liveLegend', { timeout: 8000, state: 'attached' });
|
||||
await page.waitForTimeout(400);
|
||||
const hidden = await page.evaluate(() => {
|
||||
const el = document.getElementById('liveLegend');
|
||||
return !!el && el.classList.contains('hidden');
|
||||
});
|
||||
if (hidden) {
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.removeItem('live-legend-hidden'); } catch (_) {}
|
||||
const el = document.getElementById('liveLegend');
|
||||
if (el) el.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function legendText(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.getElementById('liveLegend');
|
||||
return el ? (el.textContent || '').toLowerCase() : '';
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
console.log(`\n=== #1279 P2 legend covers all 13 payload types — E2E against ${BASE} ===`);
|
||||
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
await step('navigate to /live', async () => { await gotoLive(page); });
|
||||
|
||||
// Types already covered by #1274/#1276: Advert/Message/Direct/Request/
|
||||
// Response/Trace/Path/Ack. New ones added by #1279 P2:
|
||||
const newRows = ['anon req', 'grp data', 'multipart', 'control', 'raw custom'];
|
||||
for (const label of newRows) {
|
||||
await step(`legend lists "${label}"`, async () => {
|
||||
const t = await legendText(page);
|
||||
assert(t.indexOf(label) !== -1, 'legend missing row: ' + label);
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Unit tests for issue #1279 P2 #3 — code1/code2 filter grammar */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
|
||||
const code = fs.readFileSync('public/packet-filter.js', 'utf8');
|
||||
const ctx = { window: {}, console };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(code, ctx);
|
||||
const PF = ctx.window.PacketFilter;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); pass++; console.log(' ✓ ' + name); }
|
||||
catch (e) { console.log(' ✗ ' + name + ' — ' + e.message); fail++; }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
const withCodes = {
|
||||
route_type: 0, payload_type: 2, raw_hex: '00aabbccdd',
|
||||
decoded_json: JSON.stringify({ transportCodes: { code1: 'AABB', code2: 'CCDD' } })
|
||||
};
|
||||
const noCodes = {
|
||||
route_type: 1, payload_type: 2, raw_hex: '04',
|
||||
decoded_json: JSON.stringify({})
|
||||
};
|
||||
|
||||
test('code1 == AABB matches transport packet', () => {
|
||||
assert(PF.compile('code1 == AABB').filter(withCodes));
|
||||
});
|
||||
test('code1 == aabb (case-insensitive)', () => {
|
||||
assert(PF.compile('code1 == aabb').filter(withCodes));
|
||||
});
|
||||
test('code1 == AABB does NOT match packet without transportCodes', () => {
|
||||
assert(!PF.compile('code1 == AABB').filter(noCodes));
|
||||
});
|
||||
test('code2 == CCDD matches', () => {
|
||||
assert(PF.compile('code2 == CCDD').filter(withCodes));
|
||||
});
|
||||
test('code1 != AABB false when code1 is AABB', () => {
|
||||
assert(!PF.compile('code1 != AABB').filter(withCodes));
|
||||
});
|
||||
test('FIELDS metadata includes code1 and code2', () => {
|
||||
var names = PF.FIELDS.map(function(f){return f.name;});
|
||||
assert(names.indexOf('code1') !== -1, 'code1 missing from FIELDS');
|
||||
assert(names.indexOf('code2') !== -1, 'code2 missing from FIELDS');
|
||||
});
|
||||
|
||||
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* #1281 — Packet detail Location row + 📍map link contrast.
|
||||
*
|
||||
* Bug:
|
||||
* A) <dt>Location</dt><dd>—</dd> renders unconditionally on every packet,
|
||||
* wasting a row on ~90% of packet types (only ADVERT carries unencrypted
|
||||
* transmitter GPS).
|
||||
* B) The trailing `📍map` link has no class/color → inherits UA-default <a>
|
||||
* blue → unreadable in dark mode.
|
||||
*
|
||||
* Asserts:
|
||||
* 1. Some non-ADVERT packet detail does NOT contain <dt>Location</dt>.
|
||||
* 2. Some ADVERT packet detail DOES contain <dt>Location</dt> with coords.
|
||||
* 3. The 📍map link uses class="loc-map-link" with color = --accent
|
||||
* (NOT the default UA blue rgb(0,0,238)).
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
function normRgb(s) {
|
||||
const m = s && s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return null;
|
||||
return `rgb(${m[1]}, ${m[2]}, ${m[3]})`;
|
||||
}
|
||||
|
||||
async function gotoPackets(page) {
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('meshcore-groupbyhash');
|
||||
localStorage.setItem('meshcore-time-window', '525600');
|
||||
});
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 });
|
||||
}
|
||||
|
||||
// Click rows until detail pane's Payload Type matches `wantType` (e.g. "Advert"
|
||||
// or any non-"Advert"). Returns true on hit, false if exhausted.
|
||||
async function findPacketDetailByType(page, predicate, maxRows = 40) {
|
||||
await page.waitForTimeout(400);
|
||||
const rows = await page.$$('table tbody tr[data-hash][data-action]');
|
||||
for (let i = 0; i < Math.min(rows.length, maxRows); i++) {
|
||||
await rows[i].click({ timeout: 3000 }).catch(() => null);
|
||||
await page.waitForTimeout(350);
|
||||
const meta = await page.evaluate(() => {
|
||||
const dts = document.querySelectorAll('dl.detail-meta dt');
|
||||
let typeName = null;
|
||||
let hasLocation = false;
|
||||
let locationText = '';
|
||||
for (const dt of dts) {
|
||||
const label = dt.textContent.trim();
|
||||
const dd = dt.nextElementSibling;
|
||||
if (label === 'Payload Type') typeName = dd ? dd.textContent.trim() : null;
|
||||
if (label === 'Location') { hasLocation = true; locationText = dd ? dd.textContent.trim() : ''; }
|
||||
}
|
||||
return { typeName, hasLocation, locationText };
|
||||
});
|
||||
if (predicate(meta)) return meta;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log(`\n=== #1281 Location row + map link contrast E2E against ${BASE} ===`);
|
||||
|
||||
await step('Non-ADVERT packet detail does NOT render <dt>Location</dt>', async () => {
|
||||
await gotoPackets(page);
|
||||
// Filter to a non-ADVERT type to make the search efficient.
|
||||
const meta = await findPacketDetailByType(
|
||||
page,
|
||||
(m) => m.typeName && m.typeName !== 'Advert',
|
||||
40
|
||||
);
|
||||
assert(meta, 'No non-ADVERT packet found in first 40 rows');
|
||||
assert(!meta.hasLocation,
|
||||
`Expected NO <dt>Location</dt> for type "${meta.typeName}", but found one with text "${meta.locationText}"`);
|
||||
});
|
||||
|
||||
await step('ADVERT packet detail STILL renders <dt>Location</dt> with GPS coords', async () => {
|
||||
await gotoPackets(page);
|
||||
// Filter UI to ADVERTs to guarantee we find one.
|
||||
const fInput = await page.$('#packetFilterInput');
|
||||
if (fInput) {
|
||||
await fInput.fill('type == ADVERT');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
const meta = await findPacketDetailByType(
|
||||
page,
|
||||
(m) => m.typeName === 'Advert' && m.hasLocation,
|
||||
40
|
||||
);
|
||||
assert(meta, 'No ADVERT packet with Location row found in first 40 ADVERT rows');
|
||||
assert(/-?\d+\.\d+\s*,\s*-?\d+\.\d+/.test(meta.locationText),
|
||||
`ADVERT Location should contain GPS coords, got: "${meta.locationText}"`);
|
||||
});
|
||||
|
||||
await step('📍map link uses class="loc-map-link" with color = var(--accent)', async () => {
|
||||
// Reuse the ADVERT detail pane left open from the previous step.
|
||||
const result = await page.evaluate(() => {
|
||||
const link = document.querySelector('dl.detail-meta a.loc-map-link');
|
||||
if (!link) return { missing: true };
|
||||
const cs = getComputedStyle(link);
|
||||
const accentRaw = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
// Resolve --accent value to its computed rgb() via a probe element.
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(--accent)`;
|
||||
document.body.appendChild(probe);
|
||||
const accentRgb = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return {
|
||||
linkColor: cs.color,
|
||||
accentRgb,
|
||||
accentRaw,
|
||||
href: link.getAttribute('href'),
|
||||
text: link.textContent.trim(),
|
||||
};
|
||||
});
|
||||
assert(!result.missing,
|
||||
'<a class="loc-map-link"> not found in detail pane — implementation must apply the class');
|
||||
const link = normRgb(result.linkColor);
|
||||
const accent = normRgb(result.accentRgb);
|
||||
console.log(` link.color=${result.linkColor} --accent→${result.accentRgb} (raw "${result.accentRaw}")`);
|
||||
assert(link === accent,
|
||||
`📍map link color ${result.linkColor} must equal --accent (${result.accentRgb}); ` +
|
||||
`default UA blue (rgb(0, 0, 238)) is not acceptable`);
|
||||
assert(link !== 'rgb(0, 0, 238)',
|
||||
'Link color is UA-default blue — class is missing or CSS rule does not match');
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user