## Summary
- `/api/nodes/{pk}/paths` returned paths in non-deterministic map
iteration order; with many paths the UI showed a random ordering on each
page load
- Now sorted by `LastSeen` descending (newest-first), with `Count` as a
tiebreaker (higher first)
- Nil `LastSeen` sorts last (treated as oldest)
- `LastSeen` is an RFC 3339 string so lexicographic comparison is
correct
Closes#1145.
## Test plan
- [ ] `TestHandleNodePaths_SortByRecency_1145` — 3 distinct paths (via
relay1, relay2, direct), verifies newest appears first
- [ ] `TestHandleNodePaths_SortCountTiebreaker_1145` — two paths with
identical `LastSeen`, verifies higher-count path wins the tiebreak
- [ ] All existing `TestHandleNodePaths_*` tests still pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
`observer.last_seen` (and `last_packet_at`) answer "when did the
analyzer last hear from this observer" — fundamentally an ingest-time
question. Previously both the status-message handler and the
packet-message handler passed the MQTT envelope timestamp into
`UpsertObserverAt` / `stmtUpdateObserverLastSeen`, which let buggy
observer clocks drag `last_seen` hours into the past even when the
timestamp parsed cleanly as RFC3339 (so #1464's naive-clamp didn't catch
it).
California observers on `analyzer.00id.net` consistently appeared 3-7h
stale for this reason.
## Fix
- `cmd/ingestor/main.go` status handler: pass `""` to `UpsertObserverAt`
so it falls back to `time.Now()`.
- `cmd/ingestor/main.go` packet-path observer upsert: same.
- `cmd/ingestor/db.go` `InsertTransmission`'s
`stmtUpdateObserverLastSeen.Exec` call: use `ingestNow` for both
`last_seen` and `last_packet_at` (was `rxTime`).
Per-packet rxTime semantics (`transmissions.first_seen`,
`observations.timestamp`) are unchanged — those continue to use envelope
time with the naive-clamp / 14h-future / 30d-past guards from #1463 /
#1464. Per-hop SNR-vs-time analysis still works.
## TDD
- Red: `test(#1465): observer.last_seen uses ingest time even with
well-formed envelope (red)`
- 3 new tests in `observer_lastseen_1465_test.go`: status-past,
status-future, packet-path-past.
- Status-past and packet-path-past assertions failed on master (envelope
time stored verbatim).
- Green: `fix(#1465): observer.last_seen always uses ingest time, not
envelope`
- All 3 new tests pass.
- Pre-existing `TestInsertTransmissionUpdatesObserverLastSeen` and
`TestLastPacketAtUpdatedOnPacketOnly` were encoding the buggy behavior;
updated to assert ingest-time semantics.
- Full `go test ./cmd/ingestor/...` green.
## Refs
- Refs #1463 (root-cause investigation)
- Refs #1464 (naive-clamp fix that handled malformed timestamps)
- Closes#1465
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Red commit: fc6ed65f (CI fails on
`TestResolveRxTimeNaiveTimestampClamp`)
Green commit: 80bf1285
## Problem
California observers (UTC−7) had `last_seen` perpetually pinned ~7h
behind wall-clock and rendered "Stale" in the UI despite active MQTT
status traffic. Root cause: `parseEnvelopeTime` parses zone-less ISO
timestamps (python `datetime.now().isoformat()`) as UTC, leaving a
residual offset equal to the observer's UTC offset. The existing
soft-clamp at `resolveRxTime` only caught the future-skew (UTC+N) mirror
case.
## Fix — Option B (symmetric clamp)
- `parseEnvelopeTime` now returns a `(time.Time, naive bool, error)`
tuple so callers can tell zone-aware from zone-less parses.
- `resolveRxTime` applies a 15-minute symmetric tolerance window for
`naive==true` values: anything further off than 15 min collapses to
ingest time and emits a warning log.
- Well-behaved observers (Z-suffixed or explicit `±HH:MM` offset) are
completely untouched regardless of skew — legitimate buffered uploads
remain accurate to the second.
Chose option B over option A (reject naive outright) because some
observers may be sending naive *UTC* strings — those would suddenly lose
their own time. Symmetric clamp preserves the well-synced naive case (<
15 min off) and rescues every other zone.
## Tests
- New `TestResolveRxTimeNaiveTimestampClamp` covers naive past, naive
future, naive w/ microseconds, Z-suffixed past (verbatim),
offset-suffixed (canonicalized to UTC), naive within tolerance
(verbatim).
- `TestParseEnvelopeTime` updated for new signature, asserts `naive`
flag.
- All existing rxtime tests preserved (factory date, 30-day floor, 14h
future, plausible past).
- Red commit ran first, failed on assertions, then green commit makes
everything pass.
## Operator visibility
`naive timestamp "..." off by 7h, using ingest time` now appears in the
ingestor log so operators can identify upstream observer scripts that
should switch to `datetime.now(timezone.utc).isoformat()`.
Fixes#1463
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Master CI has been failing on `test-channel-color-picker-e2e.js` — the
"outside click closes popover" step — most recently on run
[26574358472](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26574358472)
(master push `d24246395`). The previous deflake attempt (#1317, commit
62a81776) only papered over part of the race.
## Root cause
`showPopover` in `public/channel-color-picker.js:148-152` installs the
document-level outside-click listener inside a `setTimeout(0)`:
```js
setTimeout(function() {
document.addEventListener('click', onOutsideClick, true);
document.addEventListener('keydown', onEscape, true);
}, 0);
```
The previous fix tried to wait for that listener with a `rect.width > 0`
"popover visible" proxy — but visibility ≠ listener install. Under CI
load, the macrotask can be deferred past Playwright's polling
resolution, so `page.mouse.click(700, 500)` fires before the listener
exists, the click is dropped, and the second `waitForFunction` runs out
the 8s default timeout.
## Fix (test-only)
1. **Drain pending macrotasks node-side** with `requestAnimationFrame` ×
2 + `setTimeout(0)` before clicking, so the same scheduler tier the
listener uses has definitely run.
2. **Retry the outside click in a small loop** (up to 10×, 1s each).
Even if the very first synthetic click still races install, subsequent
clicks land cleanly. Each retry is cheap (~ms), and `assert(closed,
...)` gives a clear failure message if the popover never hides.
## Verification
| Scenario | Old test | New test |
|---|---|---|
| Baseline (no artificial delay) | passes | 45/45 clean runs locally |
| Artificially delay listener install to **250ms** | **5/5 FAIL** | 5/5
PASS (popover closes on retry #2) |
Production code untouched. Comment block in-test captures the history so
the next person doesn't re-introduce the race.
## Linked
- Supersedes the partial fix in #1317
- CI run that exposed it:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26574358472
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
## Closes#1415 — packets cross-viewport jank
## Closes#1458 — Tufte mobile-packets P0 findings (folded into same
branch)
Single PR covers both issues — they touch the same files
(`public/packets.js`,
`public/style.css`) and a split would invite merge thrash.
### #1415 — column priority + chrome compaction
Locked column-priority tiers (operator spec):
| Tier | Viewport | Columns |
|---|---|---|
| 1 | always (mobile through desktop) | expand · time · type · details |
| 2 | tablet+ (>768px) | path |
| 3 | desktop only (>1024px) | hash · observer · rpt |
Enforced via existing `data-priority` system in `TableResponsive.apply`
(priorities 3 → hide ≤1024, 5 → hide ≤768).
CSS:
- `.col-expand` pinned to `width/min-width/max-width: 32px` at every
viewport
— kills the 50–180px dead column that pushed every data column right.
- `.col-details` capped at `max-width: 480px` so wide viewports stop
wasting
hundreds of px on the last column.
- `@media (max-width: 480px)` hides page-header BYOP, shrinks the h2,
and
tightens row padding → pre-table chrome drops from ~280px to ~140px.
### #1458 — Tufte mobile P0 findings
**P0-A: semantic-first detail panel.** Was: `"Packet Byte Breakdown (134
bytes)"`
title + giant neon hex grid above the meaningful fields. Now: type badge
+
decoded summary + hop count + `src → dst` lead the panel, followed by
the
existing `.detail-meta` dl (reordered: Payload Type → Path → Timestamp →
Observer).
**P0-B: raw-bytes disclosure.** Hex legend / hex dump / field table
wrapped in
`<details class="detail-technical">`. Disclosure copy reads "Show raw
bytes".
Collapsed by default on phones (`window.innerWidth ≤ 480`), expanded on
tablet+.
**P0-C: mobile filter-zone collapse.** The always-on filter-expression
input
above `.filter-bar` is now wrapped with `.pkt-filter-expr` and hidden
under
the `@media (max-width: 480px)` block. Reveals when the existing
"Filters ▾"
toggle adds `.filters-expanded` to the sibling `.filter-bar` (CSS
`:has()`
selector — one tap reveals both chrome rows together).
### TDD
`test-issue-1415-packets-layout.js` — pure source-grep, no browser:
- col-expand class on first `<th>` + `<td>` + CSS 32px pin
- locked column-priority tier values per column
- `.col-details` max-width ≤ 480px
- mobile @media block: hides BYOP, hides `.pkt-filter-expr` (revealed by
`.filters-expanded`)
- detail-meta order: Payload Type before Observer
- `<details class="detail-technical">` wrapper exists with "Show raw
bytes"
summary
- detail-title leads with a type badge; `.detail-srcdst` emitted
- old "Packet Byte Breakdown (N bytes)" title literal removed
Red commit `d4372d82` (8 assertion failures, no compile errors), green
commit `4fab9dbd` (#1415 work), follow-up commit `a5218035` (#1458 work)
keeps everything green. 26 assertions, 0 failed.
---------
Co-authored-by: openclaw-bot <bot@openclaw>
## Summary
Rename the "Usefulness" UI label to "Traffic share", add hover tooltips
for both Traffic share and Bridge score, and introduce a new
`traffic_share_score` field on `/api/nodes` (alongside the legacy
`usefulness_score`, kept for API back-compat).
Closes#1456.
## Why
The "Usefulness" label implied a composite score that doesn't exist yet
— only the Traffic-share axis (axis 1 of 4 from #672) and the Bridge
axis (axis 2 of 4 from #1275) are wired today. A node with low traffic
but critical structural position read as "not useful" — exactly wrong.
Neither score had a tooltip explaining what it measured.
## Changes
### Frontend (`public/nodes.js`)
- Visible label `Usefulness` → `Traffic share` (with ⓘ glyph)
- Tooltip explains traffic-share semantics, cross-references Bridge for
structural importance, points at #672 for the 4-axis roadmap
- Bridge row gets a parallel ⓘ glyph and a tooltip naming "betweenness
centrality" + the "quiet but irreplaceable chokepoint" interpretation
- Prefers new `traffic_share_score` with graceful fallback to legacy
`usefulness_score`
### Backend (`cmd/server/routes.go`)
- `/api/nodes` and `/api/nodes/{pubkey}` now emit BOTH
`usefulness_score` (kept for API compat) AND `traffic_share_score` (new
canonical name), populated with the same value
- Inline comment documents the deprecation path: when the #672 composite
ships, `usefulness_score` becomes the composite and
`traffic_share_score` keeps the per-axis value
## Tests
- `test-issue-1456-score-labels.js` — file-grep pins on `nodes.js`
(label, tooltip fragments, percent formatting, dual-field read with
fallback)
- `cmd/server/traffic_share_score_test.go` — `/api/nodes` +
`/api/nodes/{pk}` responses contain both fields with equal values
TDD: red commit (`8bd235a0`) added failing tests; green commit
(`c4d3aee5`) implemented. `go test ./cmd/server/...` passes (47s).
## Out of scope
- Renaming the backend field (would break consumers)
- Wiring axes 3 (Coverage) and 4 (Redundancy) — tracked in #672
- Changing the score calculation
---------
Co-authored-by: clawbot <bot@openclaw.local>
## Summary
Adds a customizer checkbox that toggles
`localStorage["channels-show-encrypted"]` — the read-gate that controls
whether `/api/channels` is fetched with `?includeEncrypted=true`. Today
operators can only flip that gate from DevTools; this PR gives them the
obvious affordance.
Default behavior is unchanged: key remains unset → server filters
encrypted entries → ~19 channels rendered. Toggle ON sets the key to
`"true"` → fetch grows to ~265 with `Encrypted (0xAB)` entries.
## Behavior
- **Display tab → new "Channels" subsection → "Show encrypted channels"
checkbox.**
- ON writes `localStorage["channels-show-encrypted"] = "true"`.
- OFF *removes* the key (never writes `"false"`) so the read-gate
cleanly returns false and the customizer match-default detection still
works.
- Toggling dispatches `mc-channels-show-encrypted-changed`;
`channels.js` listens and re-fetches via `loadChannels()` — no page
reload.
- Tooltip / hint copy: "Encrypted channels appear as 'Encrypted (0xAB)'
with no name. Operators usually leave this off."
## TDD
`test-issue-1454-channels-toggle.js` — source-grep invariants:
- Red commit `feb9dcee`: assertions on customizer + listener — failed
(production code not yet present).
- Green commit `d8742f2c`: production patch — passes.
Read-gate at `public/channels.js:1564` is left untouched; the test
asserts it.
## Out of scope
- Migration of legacy localStorage values into customizer overrides (no
override store needed — we keep using the raw localStorage key as the
single source of truth).
- Per-region toggle.
- Decryption key UI.
Closes#1454
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
After #1452 merged with width:fit-content + max-width on .gesture-hint,
CDP showed the rule was still missing from CSSOM. Tracked it down to
line 4024 of style.css which had a raw '(feat(#1062): green — implement
gesture system)' string OUTSIDE any comment, after the #1062 closing
marker. The parser ate forward through the .gesture-hint parent rule.
One-character fix removes the parenthesized commit fragment. Verified
via CDP: rule now appears in CSSOM and width:fit-content takes effect.
Final follow-up to #1452.
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Three follow-up fixes for #1065 gesture-hint discoverability:
1. **Touch-capability gate.** New `hasTouchCapability()` helper probes
`'ontouchstart' in window`, `navigator.maxTouchPoints`, and `(pointer:
coarse)`. Every `HINTS[*].relevant()` predicate now returns `false`
immediately on mouse-only viewports, so desktop browsers no longer get
"swipe a row left" tips.
2. **`width: fit-content` on the pill wrap.** The `.gesture-hint` block
previously had no explicit width and defaulted to block-level
full-width. Combined with `translateX(-50%)` on `.gesture-hint-bottom`
this rendered as a 100vw-wide bar centered with a negative-X transform,
i.e. pushed off-screen-left on narrow viewports (384px wrap on 390px
viewport).
3. **CSS-parse safety.** Moved the in-body comment (which contained an
em-dash) outside the rule block. An earlier attempt to add `width:
fit-content` together with an in-body em-dash comment caused the parent
`.gesture-hint` rule to vanish from the CSSOM in Chrome (children
`.gesture-hint-*` remained). Putting the comment above the block
sidesteps the parser bug.
## Test
`test-issue-1065-gesture-hints-gates.js` — pure source-file assertions,
no browser required. Red commit first (7 fails), green commit second
(10/10 pass). Wired into `test-all.sh`.
## Verification
After hot-deploy on staging:
- Desktop (no touch):
`document.querySelectorAll('.gesture-hint').length` === 0
- Mobile emulated (touch): hint rendered, `getBoundingClientRect().x >=
0`, `width <= 360`, `width < viewport_width`
- CSSOM: parent `.gesture-hint` rule present with `width: fit-content` +
`max-width: 360px`
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Custom navbar logos via `branding.logoUrl` were rendered squished. The
CSS rule `.brand-logo { width: 125px }` was pinned to the default
inline-SVG wordmark's viewBox aspect (~3.08:1), and when customize-v2
swapped the inline `<svg>` for an `<img>`, that `<img>` inherited the
same fixed 125px width — stretching every non-3.08:1 image into a pill.
## Root cause
- `public/style.css:520` — `.brand-logo { width: 125px }` applied
regardless of element type.
- `public/customize-v2.js:75-77` — `_setBrandLogoUrl` additionally
hardcoded `width="125" height="36"` attributes on the created `<img>`,
overriding any CSS aspect rescue.
- Mobile media query (`style.css:1729`) had the same issue with `width:
112px`.
## Fix
Split the CSS rule by element type:
- `svg.brand-logo` — keeps 125×36 pin for the default wordmark (no
regression).
- `img.brand-logo` — `width: auto`, `max-width: 200px`, `object-fit:
contain` so the operator image's natural aspect is preserved with a sane
cap so very-wide logos can't blow nav layout.
- Mobile `@media` mirrors the split (svg 112×32 pinned, img auto width
with 180px cap).
- Drop the hardcoded `width=125`/`height=36` attrs from the `<img>`
created in `customize-v2 _setBrandLogoUrl`.
## TDD
Red commit `a20b7d7`: 4 assertions, all fail on master.
Green commit `533f464`: same 4 assertions, all pass.
```
✓ img.brand-logo CSS rule exists and uses width:auto (not pinned)
✓ svg.brand-logo CSS rule still pins width:125px (no default regression)
✓ mobile media-query splits the .brand-logo rule into svg/img variants
✓ customize-v2 _setBrandLogoUrl does NOT hardcode width/height attrs on the IMG
```
## Verification plan post-merge
Hot-deploy to staging and CDP-verify:
1. Default SVG wordmark still renders at 125×36 (no default regression).
2. Square 100×100 data-URI logo renders as ~36×36 (was 125×36 pill).
3. Tall 100×300 data-URI logo renders as ~12×36 (was 125×36 pill).
Closes#1450
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Last loose end from #1446: clearOverride was leaving the root-level
inline --mc-role-{role} stuck at the previous user-pick value. Body
cascade still wins for descendants, so visible UI was correct, but
introspection (getComputedStyle on documentElement) reported the stale
color. One-line additive fix: also call root.removeProperty when preset
is active + no user override.
Verified by CDP scenario-4 chain (clearOverride → expect revert to
preset).
Closes the final loose end from #1446 / #1438 chain.
Co-authored-by: openclaw-bot <bot@openclaw.local>