Files
meshcore-analyzer/.github
127dd511a7 fix(health): judge favorites and home status cards by the node's role (#2215)
## The problem

A starred repeater can show two different health statuses. The My
Repeaters tab (#1761) and `getNodeStatus` judge it by the infra
thresholds (degraded after 24 h, silent after 72 h). The favorites
dropdown and the home page cards judge every node by the node thresholds
(degraded after 1 h, silent after 24 h), because they never look at the
node's role.

| repeater last heard | favorites / home cards (before) | My Repeaters
tab | after this PR |
|---|---|---|---|
| 5 h ago | degraded | healthy | healthy |
| 30 h ago | silent | degraded | degraded |
| 80 h ago | silent | silent | silent |

If you set `healthThresholds` in `config.json`, every view follows your
values.

## The change

Three call sites now use `getHealthThresholds(role)`, the helper the My
Repeaters tab already uses:

- `renderFavDropdown` in `app.js`
- the "My Nodes" grid card in `home.js`
- the hero search-result card in `home.js`

The search card's fallback text, "Not heard in 24+ hours", was only true
for non-infra nodes, so it now reads "Not heard recently".

## Scope

- I kept the existing three-state healthy / degraded / silent logic and
changed only which thresholds apply.
- Not included: counting a repeater that is relaying traffic as alive
(#1598, which `getNodeStatus` does). That is a bigger behavior change
and would be its own PR.

## Risk

Low. It is 9 lines in two files, and the helper and thresholds already
exist and are used elsewhere.

## Tests

New `tests/unit/test-health-card-role-thresholds.js`, added to
`test-all.sh`. It checks that infra roles get looser thresholds than
nodes, and that each of the three call sites goes through
`getHealthThresholds(role)` with no node-only comparison left. On
current `master` 5 of its 7 assertions fail; with this change all pass.

`sh test-all.sh` passes. It prints a `STALE ALLOWLIST` warning from an
a11y self-test, which it also does on `master`.

## Update after review (commit `0f2376df`)

The text above is the description efiten reviewed on 2026-10-15. This
section describes the code as it is now.

### The change

- New `getHealthStatus(role, heard, now)` in `roles.js`. It returns
`healthy`, `degraded` or `silent` by the role's thresholds, or `null`
when there is no usable timestamp. It only applies the cut-offs; each
caller picks the timestamp.
- All six places that compared an age against `degradedMs` / `silentMs`
now call it:
  - the favorites dropdown (`app.js`);
  - the "My Nodes" card and the home health card (`home.js`);
  - the analytics network status and My Repeaters (`analytics.js`);
  - the live node panel (`live.js`).

Each keeps its own labels and timestamp, so the last three show the same
as before.

`analytics.js` and `live.js` no longer guard with `typeof
getHealthThresholds`. `roles.js` loads before both, and both already
used it without a guard elsewhere: the old network status called
`getHealthThresholds` directly, and `live.js` reads `ROLE_COLORS`.
- The favorites dropdown and both home cards now fall back to the node's
`last_seen` when the packet store has no packet for it. My Repeaters
(`last_heard || last_seen`) and the node page (`nodes.js:729`) already
do this. Without it, with a packet store window under 72 h, a repeater
could be silent on the cards and degraded on My Repeaters. The "ago"
text uses the same timestamp.

With the default thresholds, the fallback changes a status only when the
packet store holds less than 72 h of packets. `retentionHours` unset
(unlimited) or at the example's 168 h holds more than that. Otherwise a
node with no packet in the window is silent either way, and the views
now show how long ago it was last seen instead of "never" or nothing.
- With no timestamp at all, the home health card said "Not heard in 24+
hours". It now says "No packets in the current window".

### Why a new helper and not `getNodeStatus`

`getNodeStatus` has two states (active / stale) and counts a repeater
that relays traffic as alive (#1598). Using it here would change more
than the thresholds. `getHealthStatus` keeps these views' three states.

### Scope

- Not included: counting a repeater that relays traffic as alive (#1598)
in these views. That is a bigger behavior change and would be its own
PR.
- The live node panel still judges by `last_seen` alone, and the node
page stays relay-aware. Both now use the shared thresholds; aligning
their timestamps would also be its own change.

### Tests

- `tests/unit/test-health-status.js` runs `roles.js` in `vm`. It covers:
- repeater, room, companion, sensor, an unknown role and a missing role,
at ages either side of 1 h, 24 h and 72 h;
  - ISO and epoch input;
  - null, empty and invalid timestamps;
  - a `healthThresholds` override through `/api/config/client`.

One source guard remains: no age-vs-threshold comparison outside
`roles.js`.
- `tests/unit/test-my-repeaters-dashboard.js` now uses the real helper
instead of a stub, which gave repeaters the node thresholds. It also
checks that a repeater heard 30 h ago is Degraded.
- New `tests/e2e/test-health-status-e2e.js` mocks
`/api/nodes/<pk>/health` for six nodes. It checks five views: the
favorites dropdown, the "My Nodes" card, the home health card, the
analytics network status and the live node panel. It fails on `master`
and on this PR's first commit. I put it on shard 2, which was the
shortest shard in the last CI run.
- Mutation check: I made 33 deliberate breaks. In the helper they cover
each comparison and boundary. At each of the six call sites they cover
the timestamp field, the role argument and at least one label mapping.
Each one makes the suite fail.
- I checked a `config.json` with `infraDegradedHours: 2` and
`infraSilentHours: 6` in Chromium. Repeaters heard 3 h and 7 h ago show
Degraded and Silent.
- `sh test-all.sh` passes. It prints a `STALE ALLOWLIST` warning, which
comes from a deliberate fixture in the a11y self-test.

### Cost

The analytics network status loops over every node. In headless
Chromium, 30,000 synthetic nodes take 9.0 to 9.2 ms before this change
and 9.1 ms after (median of 21 runs, on one machine).

### Risk

Low. The change is frontend only, with no API, config or data change.
Rolling back means reverting the commits.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: nullrouten <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:38:11 +02:00
..