Skip to content

feat(#1845): filter nodes by how long they have been silent - #1973

Merged
efiten merged 2 commits into
masterfrom
feat/1845-silence-window
Sep 6, 2026
Merged

feat(#1845): filter nodes by how long they have been silent#1973
efiten merged 2 commits into
masterfrom
feat/1845-silence-window

Conversation

@efiten

@efiten efiten commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #1845 for the question in its title. The alerting ask that came later in the thread is deliberately not in here; see the bottom.

The gap

@Jonher937 asked to flag repeaters that stopped communicating for x days, to find remote gear that has died. Today the Nodes page has Active/Stale with thresholds fixed at 72h for infra and 24h for everything else, plus a Last Heard filter that selects nodes heard within a window. Neither answers "show me what has been quiet for over a week".

What this adds

A Silent for select beside Last Heard: 1d, 3d, 7d, 14d, 30d, each labelled with the number of nodes it would select.

[ All ] [ Active ] [ Stale ]   Last Heard: Any v    Silent for: over 7d (23) v

Counts are computed before the silence filter is applied, so the dropdown keeps showing what the other windows would select instead of collapsing to the one already chosen. The choice persists in localStorage like the neighbouring filters and is mirrored into the URL as ?silent=7d, so the view can be pasted to whoever owns the silent gear. The URL sync is wrapped in try/catch, because it is a convenience and must never stop the filter working.

The part worth reviewing: one definition of freshness, not two

getNodeStatus has been relay-aware since #1598, while nodes.js separately computed statusAge from the ADVERT timestamp alone. Filtering on the latter would have listed a repeater as silent for ten days while its own badge on the same row said active, and it would have done so for exactly the nodes #1598 exists to protect.

So the freshness rule is extracted into window.getEffectiveHeardMs in roles.js, and getNodeStatus now calls it. Behaviour is unchanged, there is now one source. Reviewers should look hardest at that refactor rather than at the select.

A node never heard from at all scores Infinity, so it matches every window instead of silently dropping out of the filter.

Why the thresholds are fixed values and not derived

I measured the alternative before writing this, on a 1179-repeater mesh, and posted it on #1611: replacing a fixed threshold with 3 x per-node advert median fixes 8 false "silent" flags and newly mis-flags 28 currently-active nodes, because a 3h-median node gets a 9h threshold. Raising the global default to 144h rescues 7 and hides 27 genuinely dead repeaters. Both are net-negative. A user-chosen window sidesteps the whole question: the operator picks what "too long" means for their mesh, which is what @Jonher937 asked for in the first place.

Verification

  • test-frontend-helpers.js: 666 → 680 passed, 0 failed. Fourteen cases covering NaN rather than 0 when nothing is known (0 is a real timestamp and would sort as very old rather than unknown), the full _liveSeen > _lastHeard > last_heard > last_seen precedence, a recent relay beating a stale advert, a stale relay not dragging a fresh advert backwards, relay alone sufficing, room counting as infra while companion does not (a last_relayed on a companion is meaningless and must not rescue it), case-insensitive roles, the legacy (role, ms) call shape, and the 72h boundary asserted at 71h and 73h.
  • One of those failed on first run and the test was wrong, not the code: 9e7 ms is 25h, which is correctly active for infra. Fixed, and the boundary is now asserted explicitly so nobody repeats it.
  • eslint on the changed files: 0 errors. The warnings present are the same ones master already reports.
  • No server change, no new API, no new column. No cache-buster bump needed: __BUST__ is substituted at startup in cmd/server/main.go:570.

Not done

No alerting. @fokcuk asked on the thread for notification when a repeater they look after goes silent, which is a different product: subscriber identity, an evaluation loop and delivery, none of which exist today. That deserves its own issue and a design call rather than being shimmed into nodes.js, which is also what the triage concluded. This PR gives the operator the view; it does not push to them.

Sizes and boundaries (1d/3d/7d/14d/30d) are a judgement call. Say the word if a different set fits real operator habits better.

The issue asks to "flag repeaters that stopped communicating for x days" so an
operator can find remote gear that has died. The Nodes page had Active/Stale
with thresholds fixed at 72h for infra and 24h for the rest, and a Last Heard
filter that selects nodes heard WITHIN a window. Neither answers "show me what
has been quiet for over a week", which is the question in the title.

Adds a "Silent for" select next to Last Heard: 1d, 3d, 7d, 14d, 30d, each
labelled with how many nodes it would select. Counts are computed BEFORE the
silence filter is applied, so the dropdown keeps showing what the other windows
would select instead of collapsing to the one already chosen. The choice
persists in localStorage like the neighbouring filters, and is mirrored into
the URL as ?silent=, so a view can be pasted to whoever owns the silent gear.
URL sync is wrapped in try/catch: it is a convenience and must never stop the
filter from working.

One definition of freshness, not two. getNodeStatus was relay-aware (#1598)
while nodes.js separately computed statusAge from the ADVERT timestamp alone.
Filtering on the latter would list a repeater as silent for ten days while its
own badge on the same row said active, for exactly the nodes #1598 exists to
protect. So the freshness rule is extracted into window.getEffectiveHeardMs in
roles.js and getNodeStatus now calls it. Same behaviour, one source.

A node never heard from at all scores Infinity, so it matches every window
rather than dropping out of the filter entirely.

Tests: 666 to 680 passed, 0 failed. Fourteen cases on getEffectiveHeardMs and
getNodeStatus: NaN rather than 0 when nothing is known (0 is a real timestamp
and would sort as very old instead of unknown), the full _liveSeen >
_lastHeard > last_heard > last_seen precedence, a recent relay beating a stale
advert, a stale relay NOT dragging a fresh advert backwards, relay alone being
enough, room counting as infra while companion does not (last_relayed on a
companion is meaningless and must not rescue it), case-insensitive roles, the
legacy two-argument call shape, and the 72h boundary at 71h and 73h.

One of those tests failed on first run and the test was wrong, not the code:
9e7 ms is 25h, which is correctly ACTIVE for infra. Fixed and the boundary is
now asserted explicitly so the next person does not repeat it.

No new API, no new column, no server change. Cache busting needs no bump:
__BUST__ is substituted at startup in cmd/server/main.go:570.

Not done: no alerting. @fokcuk asked on the thread to be notified when a
repeater goes silent, which needs subscriber identity, an evaluation loop and
delivery, none of which exist. That is a separate product surface and a
separate issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
@Jonher937

Copy link
Copy Markdown
Contributor

I'm tagged multiple times but I have not interacted on this before now. Might you have meant to tag @fokcuk ?

# Conflicts:
#	test-frontend-helpers.js
@efiten efiten closed this Sep 6, 2026
@efiten efiten reopened this Sep 6, 2026
@efiten
efiten merged commit 5b689f7 into master Sep 6, 2026
7 checks passed
@efiten
efiten deleted the feat/1845-silence-window branch September 6, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: flag repeaters that stopped communicating for x days

2 participants