mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 06:31:39 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f265b312d | |||
| 5a959093fe | |||
| d259076285 | |||
| 6dc4a21a1f | |||
| 507ed19d0e | |||
| 0c93c2f548 |
@@ -236,7 +236,7 @@ jobs:
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
needs: [e2e-test]
|
||||
runs-on: [self-hosted, meshcore-vm]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: [self-hosted, meshcore-vm]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
+3
-12
@@ -117,8 +117,6 @@ type PacketStore struct {
|
||||
// computed during Load() and incrementally updated on ingest.
|
||||
distHops []distHopRecord
|
||||
distPaths []distPathRecord
|
||||
distDirty bool // set when paths change; cleared after rebuild
|
||||
distLast time.Time // last time distance index was rebuilt
|
||||
|
||||
// Cached GetNodeHashSizeInfo result — recomputed at most once every 15s
|
||||
hashSizeInfoMu sync.Mutex
|
||||
@@ -331,7 +329,6 @@ func (s *PacketStore) Load() error {
|
||||
|
||||
// Precompute distance analytics (hop distances, path totals)
|
||||
s.buildDistanceIndex()
|
||||
s.distLast = time.Now()
|
||||
|
||||
s.loaded = true
|
||||
elapsed := time.Since(t0)
|
||||
@@ -824,7 +821,7 @@ func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
SqliteOnly: false,
|
||||
MaxPackets: 2386092,
|
||||
EstimatedMB: estimatedMB,
|
||||
MaxMB: s.maxMemoryMB,
|
||||
MaxMB: 1024,
|
||||
Indexes: PacketStoreIndexes{
|
||||
ByHash: hashIdx,
|
||||
ByObserver: observerIdx,
|
||||
@@ -1473,19 +1470,13 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
}
|
||||
}
|
||||
|
||||
// Mark distance index dirty if any paths changed (rebuild is debounced)
|
||||
// Rebuild distance index if any paths changed (distances depend on path hops)
|
||||
for txID, tx := range updatedTxs {
|
||||
if tx.PathJSON != oldPaths[txID] {
|
||||
s.distDirty = true
|
||||
s.buildDistanceIndex()
|
||||
break
|
||||
}
|
||||
}
|
||||
// Rebuild at most every 30s to avoid hot-looping on busy meshes
|
||||
if s.distDirty && time.Since(s.distLast) > 30*time.Second {
|
||||
s.buildDistanceIndex()
|
||||
s.distDirty = false
|
||||
s.distLast = time.Now()
|
||||
}
|
||||
|
||||
if len(updatedTxs) > 0 {
|
||||
// Targeted cache invalidation: new observations always affect RF
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Spec: Server-side hop resolution at ingest — `resolved_path`
|
||||
|
||||
**Status:** Final
|
||||
**Issue:** [#555](https://github.com/Kpa-clawbot/CoreScope/issues/555)
|
||||
**Related:** [#482](https://github.com/Kpa-clawbot/CoreScope/issues/482), [#528](https://github.com/Kpa-clawbot/CoreScope/issues/528)
|
||||
|
||||
## Problem
|
||||
|
||||
Any place where 1, 2, or 3-byte prefixes must be resolved to actual full repeater public keys and friendly names should use affinity data first, geo data as fallback. Across frontend, backend, whatever. Efficiently — no 7-second waits, no recomputation, aggressive caching.
|
||||
|
||||
Currently, hop paths are stored as short uppercase hex prefixes in `path_json` (e.g. `["D6", "E3", "59"]`). Resolution to full pubkeys happens **client-side** via `HopResolver` (`public/hop-resolver.js`), which:
|
||||
|
||||
- Is slow — each page/component re-resolves independently
|
||||
- Is inconsistent — different components may resolve the same prefix differently
|
||||
- Cannot leverage the server's neighbor affinity graph, which has far richer context for disambiguation
|
||||
- Causes redundant `/api/resolve-hops` calls from every client
|
||||
|
||||
## Solution
|
||||
|
||||
Resolve hop prefixes to full pubkeys **once at ingest time** on the server, using `resolveWithContext()` with 4-tier priority (affinity → geo → GPS → first match) and a **persisted neighbor graph**. Store the result as a new `resolved_path` column on observations alongside `path_json`.
|
||||
|
||||
## Design decisions (locked)
|
||||
|
||||
1. **`path_json` stays unchanged** — raw firmware prefixes, uppercase hex. Ground truth.
|
||||
2. **`resolved_path` is a column on observations** — full 64-char lowercase hex pubkeys, `null` for unresolved.
|
||||
3. **Resolved at ingest** using `resolveWithContext(hop, context, graph)` — 4-tier priority: affinity → geo → GPS → first match.
|
||||
4. **`null` = unresolved** — ambiguous prefixes store `null`. Frontend falls back to prefix display.
|
||||
5. **Both fields coexist** — not interchangeable. Different consumers use different fields.
|
||||
|
||||
## Persisted neighbor graph
|
||||
|
||||
### SQLite table: `neighbor_edges`
|
||||
|
||||
Thin and normalized. Stores ONLY the relationship. SNR, observer names, GPS, roles — all join from existing tables when needed. No duplication.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
);
|
||||
```
|
||||
|
||||
### Edge extraction rules (ADVERT vs non-ADVERT)
|
||||
|
||||
At ingest, for each packet:
|
||||
|
||||
- **ADVERT packets** (payload_type 4): originator pubkey is known from `decoded_json.pubKey`. Extract edge: `originator ↔ path[0]` (the first hop is a direct neighbor of the originator).
|
||||
- **ALL packets**: observer pubkey is known. Extract edge: `observer ↔ path[last]` (the last hop is a direct neighbor of the observer).
|
||||
- **Non-ADVERT packets**: originator is unknown (encrypted). ONLY extract `observer ↔ path[last]`.
|
||||
- Each packet produces **1 or 2 edge upserts** depending on type.
|
||||
|
||||
Edge upsert uses canonical ordering (`node_a < node_b` lexicographically) to avoid duplicate edges:
|
||||
|
||||
```sql
|
||||
INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (min(?, ?), max(?, ?), 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1,
|
||||
last_seen = excluded.last_seen;
|
||||
```
|
||||
|
||||
### In-memory structure
|
||||
|
||||
```go
|
||||
type NeighborGraph struct {
|
||||
edges map[string][]NeighborEdge // pubkey → list of neighbor edges
|
||||
mu sync.RWMutex
|
||||
}
|
||||
```
|
||||
|
||||
### Cold startup and backfill
|
||||
|
||||
On startup:
|
||||
|
||||
```go
|
||||
// 1. Query all edges from SQLite
|
||||
rows := db.Query("SELECT node_a, node_b, count, last_seen FROM neighbor_edges")
|
||||
|
||||
// 2. Build in-memory graph
|
||||
graph := NewNeighborGraph()
|
||||
for rows.Next() {
|
||||
var a, b string
|
||||
var count int
|
||||
var lastSeen string
|
||||
rows.Scan(&a, &b, &count, &lastSeen)
|
||||
graph.UpsertEdge(a, b, count, lastSeen)
|
||||
}
|
||||
|
||||
// 3. Attach to PacketStore
|
||||
store.graph = graph
|
||||
```
|
||||
|
||||
1. **Load `neighbor_edges` from SQLite** → build in-memory graph (code above).
|
||||
2. **If table empty (first run):** `BuildFromStore(packets)` — scan all existing packets, extract edges per the rules above, INSERT into `neighbor_edges`.
|
||||
3. **Load observations from SQLite.**
|
||||
4. **For observations without `resolved_path`:** resolve using the graph, UPDATE `resolved_path` in SQLite.
|
||||
5. **Ready to serve.**
|
||||
|
||||
On subsequent runs, step 2 is skipped (table already populated). Step 4 only processes observations with NULL `resolved_path` (new or previously unresolved).
|
||||
|
||||
### Incremental update at ingest
|
||||
|
||||
Every edge upsert writes to **both** the in-memory graph and SQLite — they stay in sync. SQLite is the persistence layer, in-memory is the fast lookup layer.
|
||||
|
||||
```go
|
||||
// Extract edge from packet
|
||||
graph.UpsertEdge(nodeA, nodeB, 1, now)
|
||||
|
||||
// Also persist to SQLite
|
||||
db.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1, last_seen = ?`, a, b, now, now)
|
||||
```
|
||||
|
||||
## Data model
|
||||
|
||||
### Where does `resolved_path` live?
|
||||
|
||||
**On observations**, as a column:
|
||||
|
||||
```sql
|
||||
ALTER TABLE observations ADD COLUMN resolved_path TEXT;
|
||||
```
|
||||
|
||||
Rationale: Each observer sees the packet from a different vantage point. The same 2-char prefix may resolve to different full pubkeys depending on which observer's neighborhood is considered. The observer's own pubkey provides critical context for `resolveWithContext` (tier 2: neighbor affinity). Storing on observations preserves this per-observer resolution.
|
||||
|
||||
`resolved_path` is written in the same INSERT that creates the observation — one write, no double-write problem.
|
||||
|
||||
### Field shape
|
||||
|
||||
```
|
||||
resolved_path TEXT -- JSON array: ["aabb...64chars", null, "ccdd...64chars"]
|
||||
```
|
||||
|
||||
- Same length as the `path_json` array
|
||||
- Each element is either a 64-char lowercase hex pubkey string, or `null`
|
||||
- Stored as a JSON text column (same approach as `path_json`)
|
||||
- Uses `omitempty` — absent from JSON when not set
|
||||
|
||||
## Every path resolution uses the graph — no exceptions
|
||||
|
||||
All existing `pm.resolve()` call sites MUST be migrated to `resolveWithContext` with the persisted graph. No "we'll get to it later."
|
||||
|
||||
### Call sites to migrate (exhaustive)
|
||||
|
||||
Found via `grep -n "pm.resolve" cmd/server/store.go`:
|
||||
|
||||
| Line | Function | Current | After |
|
||||
|------|----------|---------|-------|
|
||||
| 1192 | `IngestNewFromDB()` | `pm.resolve(hop)` | `resolveWithContext(hop, ctx, graph)` — resolve at ingest, store as `resolved_path` |
|
||||
| 1876 | `buildDistanceIndex()` | `pm.resolve(hop)` | Read `resolved_path` from observation — already resolved at ingest |
|
||||
| 3537 | `computeAnalyticsTopology()` | `pm.resolve(hop)` | Read `resolved_path` from observation |
|
||||
| 5528 | `computeAnalyticsSubpaths()` | `pm.resolve(hop)` | Read `resolved_path` from observation |
|
||||
| 5665 | `GetSubpathDetail()` | `pm.resolve(hop)` | `resolveWithContext(hop, ctx, graph)` — ad-hoc resolution for user-provided hops |
|
||||
| 5744 | `GetSubpathDetail()` | `pm.resolve(h)` | `resolveWithContext(h, ctx, graph)` — same function, second usage |
|
||||
|
||||
**After migration:** `pm.resolve()` (naive prefix-only lookup) is dead code. Remove it. All resolution goes through `resolveWithContext` which uses the persisted neighbor graph for affinity-based disambiguation.
|
||||
|
||||
## Ingest pipeline changes
|
||||
|
||||
### Where resolution happens
|
||||
|
||||
In `PacketStore.IngestNewFromDB()` in `cmd/server/store.go`. For new observations, resolution happens during the observation INSERT — same write. For backfill (cold startup), it's a separate UPDATE pass.
|
||||
|
||||
Note on ordering: edge upserts (step 5) happen **after** resolution (step 3-4). This means the very first packet for a new neighbor pair resolves without that edge in the graph yet. This is acceptable — the affinity tier will miss, but geo/GPS/first-match tiers still work. On the next packet, the edge exists and affinity kicks in.
|
||||
|
||||
Resolution flow per observation:
|
||||
1. Parse `path_json` into hop prefixes
|
||||
2. Build context pubkeys from the observation (observer pubkey, source/dest from decoded packet)
|
||||
3. Call `resolveWithContext(hop, contextPubkeys, neighborGraph)` for each hop
|
||||
4. Store result as `resolved_path` column on the observation (same INSERT)
|
||||
5. Upsert neighbor edges into `neighbor_edges` table (incremental update)
|
||||
|
||||
### Performance
|
||||
|
||||
`resolveWithContext` does:
|
||||
- Prefix map lookup (map access, O(1))
|
||||
- Optional neighbor graph check (small map lookups)
|
||||
- No DB queries, no network calls
|
||||
|
||||
Per-hop cost: ~1–5μs. A typical packet has 0–5 hops. At 100 packets/second ingest rate, this adds <0.5ms total overhead per second. **Negligible.**
|
||||
|
||||
## All consumers use `resolved_path`
|
||||
|
||||
| Consumer | Before | After |
|
||||
|---|---|---|
|
||||
| Packets detail path names | Client HopResolver (naive) | Read `resolved_path` |
|
||||
| Map Show Route | Client HopResolver (naive) | Read `resolved_path` |
|
||||
| Live map animated paths | Client HopResolver (naive) | Read `resolved_path` |
|
||||
| Node detail paths | Client HopResolver (naive) | Read `resolved_path` |
|
||||
| Analytics topology | Server `pm.resolve()` (naive) | Read `resolved_path` from observations |
|
||||
| Analytics subpaths | Server `pm.resolve()` (naive) | Read `resolved_path` from observations |
|
||||
| Analytics hop distances | Server `pm.resolve()` (naive) | Read `resolved_path` from observations |
|
||||
| Subpath detail | Server `pm.resolve()` (naive) | `resolveWithContext` with graph |
|
||||
| Show Neighbors | Server neighbors API | Already correct |
|
||||
| `/api/resolve-hops` | Server `resolveWithContext` | Already correct |
|
||||
| Hex breakdown display | `path_json` raw | Unchanged — shows raw bytes |
|
||||
|
||||
## WebSocket broadcast
|
||||
|
||||
Include `resolved_path` in broadcast messages. Resolution happens before broadcast assembly — negligible latency impact. The WS broadcast already includes `path_json`; `resolved_path` is added alongside it.
|
||||
|
||||
## API changes
|
||||
|
||||
### Endpoints that return `resolved_path`
|
||||
|
||||
All endpoints that currently return `path_json` also return `resolved_path`:
|
||||
|
||||
- `GET /api/packets` — transmission-level (use best observation's `resolved_path`)
|
||||
- `GET /api/packets/:hash` — per-observation detail
|
||||
- `GET /api/packets/:hash/observations` — each observation includes its own `resolved_path`
|
||||
- WebSocket broadcast messages — per-observation
|
||||
|
||||
### `/api/resolve-hops`
|
||||
|
||||
**Kept.** Useful for ad-hoc resolution of arbitrary prefixes (debug tools, clients resolving prefixes not associated with a packet). Not deprecated.
|
||||
|
||||
## Pubkey case convention
|
||||
|
||||
- **DB/API:** lowercase
|
||||
- **`path_json` display prefixes:** uppercase (raw firmware)
|
||||
- **`resolved_path`:** lowercase full pubkeys
|
||||
- **Comparison code:** normalizes to lowercase
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- Old observations without `resolved_path`: resolved during cold startup backfill (step 4). If still `null` after backfill, frontend falls back to client-side HopResolver.
|
||||
- `resolved_path` field uses `omitempty` — absent from JSON when not set.
|
||||
|
||||
### Fallback pattern (frontend)
|
||||
|
||||
```javascript
|
||||
function getResolvedHops(packet) {
|
||||
if (packet.resolved_path) return packet.resolved_path;
|
||||
// Fall back to client-side resolution for old packets
|
||||
return resolveHopsClientSide(packet.path_json);
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation milestones
|
||||
|
||||
### M1: Persist graph to SQLite + load on startup + incremental updates at ingest
|
||||
- Create `neighbor_edges` table in SQLite (schema above)
|
||||
- On first run: `BuildFromStore(packets)` — scan all packets, extract edges per ADVERT/non-ADVERT rules, INSERT into table
|
||||
- On subsequent runs: load from SQLite → build in-memory graph (instant startup)
|
||||
- Upsert edges incrementally during packet ingest
|
||||
- Graph lives on `PacketStore`, not `Server`
|
||||
- Tests: graph persistence, load, incremental update, ADVERT vs non-ADVERT edge extraction
|
||||
|
||||
### M2: Add `resolved_path` column to observations + resolve at ingest
|
||||
- `ALTER TABLE observations ADD COLUMN resolved_path TEXT`
|
||||
- Add `ResolvedPath []*string` to `Observation` struct
|
||||
- Resolve during `IngestNewFromDB` — same INSERT, one write
|
||||
- Cold startup backfill: resolve observations with NULL `resolved_path`, UPDATE in SQLite
|
||||
- Migrate ALL 6 `pm.resolve()` call sites to `resolveWithContext` or read from `resolved_path`
|
||||
- Remove dead `pm.resolve()` code
|
||||
- Tests: unit test resolution at ingest, verify stored values, verify all call sites use graph
|
||||
|
||||
### M3: Update all API responses to include `resolved_path`
|
||||
- Include `resolved_path` in all packet/observation API responses
|
||||
- Include in WebSocket broadcast messages
|
||||
- Tests: verify API response shape, WS broadcast shape
|
||||
|
||||
### M4: Update frontend consumers to prefer `resolved_path`
|
||||
- Update `packets.js`, `map.js`, `live.js`, `analytics.js`, `nodes.js`
|
||||
- Add fallback to `path_json` + `HopResolver` for old packets
|
||||
- `hop-resolver.js` becomes fallback only
|
||||
- Tests: Playwright tests for path display
|
||||
@@ -463,9 +463,6 @@ function navigate() {
|
||||
currentPage = basePage;
|
||||
|
||||
const app = document.getElementById('app');
|
||||
// Pages with fixed-height containers (maps, virtual-scroll, split-panels)
|
||||
const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 };
|
||||
app.classList.toggle('app-fixed', basePage in fixedPages);
|
||||
if (pages[basePage]?.init) {
|
||||
const t0 = performance.now();
|
||||
pages[basePage].init(app, routeParam);
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
|
||||
packetsB = [];
|
||||
currentView = 'summary';
|
||||
|
||||
app.innerHTML = '<div class="compare-page" style="padding:16px">' +
|
||||
app.innerHTML = '<div class="compare-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">' +
|
||||
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">' +
|
||||
'<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">\u2190</a>' +
|
||||
'<h2 style="margin:0">\uD83D\uDD0D Observer Comparison</h2>' +
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
/* === CoreScope — home.css === */
|
||||
|
||||
/* Home page now uses body scroll (no #app override needed — see style.css) */
|
||||
/* Override #app overflow:hidden for home page scrolling */
|
||||
#app:has(.home-hero), #app:has(.home-chooser) { overflow-y: auto; }
|
||||
|
||||
/* Chooser */
|
||||
.home-chooser {
|
||||
|
||||
+4
-10
@@ -1343,7 +1343,7 @@
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Recent Packets</h4>
|
||||
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
|
||||
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${transportBadge(p.route_type)}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
@@ -1548,7 +1548,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
`;
|
||||
@@ -1650,7 +1650,6 @@
|
||||
}
|
||||
delete nodeMarkers[key];
|
||||
delete nodeData[key];
|
||||
delete nodeActivity[key];
|
||||
pruned = true;
|
||||
}
|
||||
} else if (marker && marker._staleDimmed) {
|
||||
@@ -1666,17 +1665,12 @@
|
||||
if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
if (window.HopResolver) HopResolver.init(Object.values(nodeData));
|
||||
}
|
||||
// Prune orphaned nodeActivity entries (nodes removed above or never tracked)
|
||||
for (var aKey in nodeActivity) {
|
||||
if (!(aKey in nodeData)) delete nodeActivity[aKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for testing
|
||||
window._livePruneStaleNodes = pruneStaleNodes;
|
||||
window._liveNodeMarkers = function() { return nodeMarkers; };
|
||||
window._liveNodeData = function() { return nodeData; };
|
||||
window._liveNodeActivity = function() { return nodeActivity; };
|
||||
window._vcrFormatTime = vcrFormatTime;
|
||||
window._liveDbPacketToLive = dbPacketToLive;
|
||||
window._liveExpandToBufferEntries = expandToBufferEntries;
|
||||
@@ -2490,7 +2484,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
@@ -2558,7 +2552,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12));
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="max-width:1000px;margin:0 auto;padding:12px 16px">
|
||||
<div style="max-width:1000px;margin:0 auto;padding:12px 16px;height:100%;overflow-y:auto">
|
||||
<div style="margin-bottom:12px">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px">← Back to ${nodeName}</a>
|
||||
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} — Analytics</h2>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="observer-detail-page" style="padding:16px">
|
||||
<div class="observer-detail-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">←</a>
|
||||
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
|
||||
|
||||
+13
-16
@@ -547,22 +547,19 @@
|
||||
// Ambiguous hops are already resolved by HopResolver client-side
|
||||
// No need for per-observer server API calls
|
||||
|
||||
// Restore expanded group children (parallel fetch, Map lookup)
|
||||
// Restore expanded group children
|
||||
if (groupByHash && expandedHashes.size > 0) {
|
||||
const expandedArr = [...expandedHashes];
|
||||
const results = await Promise.all(expandedArr.map(hash => {
|
||||
const group = hashIndex.get(hash);
|
||||
if (!group) return { hash, group: null, data: null };
|
||||
return api(`/packets?hash=${hash}&limit=20`)
|
||||
.then(data => ({ hash, group, data }))
|
||||
.catch(() => ({ hash, group, data: null }));
|
||||
}));
|
||||
for (const { hash, group, data } of results) {
|
||||
if (!group) {
|
||||
for (const hash of expandedHashes) {
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group) {
|
||||
try {
|
||||
const childData = await api(`/packets?hash=${hash}&limit=20`);
|
||||
group._children = childData.packets || [];
|
||||
sortGroupChildren(group);
|
||||
} catch {}
|
||||
} else {
|
||||
// Group no longer in results — remove from expanded
|
||||
expandedHashes.delete(hash);
|
||||
} else if (data) {
|
||||
group._children = data.packets || [];
|
||||
sortGroupChildren(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1015,7 +1012,7 @@
|
||||
}
|
||||
else if (action === 'select-observation') {
|
||||
const parentHash = row.dataset.parentHash;
|
||||
const group = hashIndex.get(parentHash);
|
||||
const group = packets.find(p => p.hash === parentHash);
|
||||
const child = group?._children?.find(c => String(c.id) === String(value));
|
||||
if (child) {
|
||||
const parentData = group._fetchedData;
|
||||
@@ -2012,7 +2009,7 @@
|
||||
const data = await api(`/packets/${hash}`);
|
||||
const pkt = data.packet;
|
||||
if (!pkt) return;
|
||||
const group = hashIndex.get(hash);
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group && data.observations) {
|
||||
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
|
||||
group._fetchedData = data;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
let interval = null;
|
||||
|
||||
async function render(app) {
|
||||
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||||
app.innerHTML = '<div id="perfWrapper" style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||||
await refresh();
|
||||
}
|
||||
|
||||
|
||||
+4
-9
@@ -181,12 +181,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
|
||||
/* === Layout === */
|
||||
/* Default: body-scroll mode — content pushes beyond viewport, iOS status-bar
|
||||
tap-to-scroll works because <body> is the scroll container. Pages that need
|
||||
a fixed-height container (maps, virtual-scroll, split-panels) add
|
||||
.app-fixed via the router so their children can use height:100%. */
|
||||
#app { min-height: calc(100vh - 52px); min-height: calc(100dvh - 52px); }
|
||||
#app.app-fixed { height: calc(100vh - 52px); height: calc(100dvh - 52px); min-height: 0; overflow: hidden; }
|
||||
#app { height: calc(100vh - 52px); height: calc(100dvh - 52px); overflow: hidden; }
|
||||
|
||||
.split-layout {
|
||||
display: flex; height: 100%; overflow: hidden;
|
||||
@@ -679,7 +674,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.advert-info { font-size: 12px; line-height: 1.5; }
|
||||
|
||||
/* === Traces Page === */
|
||||
.traces-page { padding: 16px; max-width: var(--trace-max-width, 95vw); margin: 0 auto; }
|
||||
.traces-page { padding: 16px; max-width: var(--trace-max-width, 95vw); margin: 0 auto; overflow-y: auto; height: 100%; }
|
||||
.trace-search {
|
||||
display: flex; gap: 8px; margin-bottom: 20px;
|
||||
}
|
||||
@@ -751,7 +746,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* === Observers Page === */
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
@@ -1143,7 +1138,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.node-activity-time { color: var(--text-muted); white-space: nowrap; min-width: 70px; font-size: 12px; }
|
||||
|
||||
/* Analytics page */
|
||||
.analytics-page { padding: 16px 24px; max-width: 1600px; margin: 0 auto; }
|
||||
.analytics-page { padding: 16px 24px; max-width: 1600px; margin: 0 auto; overflow-y: auto; height: 100%; }
|
||||
.analytics-header { margin-bottom: 20px; }
|
||||
.analytics-header h2 { margin: 0 0 4px; }
|
||||
.analytics-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
|
||||
@@ -998,56 +998,6 @@ console.log('\n=== live.js: pruneStaleNodes ===');
|
||||
assert.ok(markers['apiNode'], 'API stale node should NOT be removed');
|
||||
assert.ok(data['apiNode'], 'API stale node data should NOT be removed');
|
||||
});
|
||||
|
||||
test('pruneStaleNodes cleans up nodeActivity for removed nodes', () => {
|
||||
const { ctx } = makeLiveSandbox();
|
||||
const prune = ctx.window._livePruneStaleNodes;
|
||||
const markers = ctx.window._liveNodeMarkers();
|
||||
const data = ctx.window._liveNodeData();
|
||||
const activity = ctx.window._liveNodeActivity();
|
||||
|
||||
// WS-only stale node
|
||||
markers['staleNode'] = { _glowMarker: null };
|
||||
data['staleNode'] = { public_key: 'staleNode', role: 'companion', _liveSeen: Date.now() - 48 * 3600000 };
|
||||
activity['staleNode'] = 5;
|
||||
|
||||
// Active node
|
||||
markers['activeNode'] = { setStyle: function() {}, _glowMarker: null };
|
||||
data['activeNode'] = { public_key: 'activeNode', role: 'companion', _liveSeen: Date.now() };
|
||||
activity['activeNode'] = 3;
|
||||
|
||||
prune();
|
||||
|
||||
assert.ok(!markers['staleNode'], 'stale node marker removed');
|
||||
assert.ok(!data['staleNode'], 'stale node data removed');
|
||||
assert.ok(!activity['staleNode'], 'stale node activity removed');
|
||||
assert.ok(markers['activeNode'], 'active node marker preserved');
|
||||
assert.ok(data['activeNode'], 'active node data preserved');
|
||||
assert.strictEqual(activity['activeNode'], 3, 'active node activity preserved');
|
||||
});
|
||||
|
||||
test('pruneStaleNodes removes orphaned nodeActivity entries', () => {
|
||||
const { ctx } = makeLiveSandbox();
|
||||
const prune = ctx.window._livePruneStaleNodes;
|
||||
const markers = ctx.window._liveNodeMarkers();
|
||||
const data = ctx.window._liveNodeData();
|
||||
const activity = ctx.window._liveNodeActivity();
|
||||
|
||||
// Add an active node
|
||||
markers['existingNode'] = { setStyle: function() {}, _glowMarker: null };
|
||||
data['existingNode'] = { public_key: 'existingNode', role: 'companion', _liveSeen: Date.now() };
|
||||
activity['existingNode'] = 2;
|
||||
|
||||
// Add orphaned activity (no corresponding nodeData)
|
||||
activity['ghostNode'] = 10;
|
||||
|
||||
prune();
|
||||
|
||||
assert.ok(markers['existingNode'], 'existing node preserved');
|
||||
assert.ok(data['existingNode'], 'existing node data preserved');
|
||||
assert.strictEqual(activity['existingNode'], 2, 'existing node activity preserved');
|
||||
assert.ok(!activity['ghostNode'], 'orphaned activity entry removed');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== live.js: vcrFormatTime respects UTC/local setting =====
|
||||
|
||||
@@ -881,17 +881,6 @@ console.log('\n=== live.js: source-level safety checks ===');
|
||||
assert.ok(src.includes('const existingIds = new Set(VCR.buffer.map(b => b.pkt.id)'),
|
||||
'vcrRewind should dedup by packet ID');
|
||||
});
|
||||
|
||||
test('feed items include transport badge', () => {
|
||||
const count = (src.match(/transportBadge\(pkt\.route_type\)/g) || []).length;
|
||||
assert.ok(count >= 3,
|
||||
`feed rendering should call transportBadge(pkt.route_type) in at least 3 places (found ${count})`);
|
||||
});
|
||||
|
||||
test('node detail recent packets include transport badge', () => {
|
||||
assert.ok(src.includes('transportBadge(p.route_type)'),
|
||||
'node detail recent packets should call transportBadge(p.route_type)');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
|
||||
Reference in New Issue
Block a user