Skip to content

fix(backend): service data-collection bugs across all 13 integrations - #145

Merged
EraPartner merged 26 commits into
mainfrom
claude/project-thread-ze36w4
Oct 28, 2026
Merged

EraPartner merged 26 commits into
mainfrom
claude/project-thread-ze36w4

Conversation

@EraPartner

@EraPartner EraPartner commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Requested by EraPartner · project thread

This PR fixes bugs in how the backend collects data from monitored services, and in the polling and alert pipeline. All 13 integrations, plus the poller, transport and infra layers, were reviewed against the code and the 2026-10-21 audit notes. Items the audits had already fixed, or had deliberately left alone, were skipped. Every fix below has a regression test except where marked. Each commit message has the details.

Fixes

Area Bug Fix
Test connection (POST /config/services/:id/test) Returned ok:true for an unreachable service, because probes resolve ok(reachable:false). Returns ok:false with error.code: UNREACHABLE and keeps the snapshot. OpenAPI and generated.ts descriptions are updated, and docs/api/config.md is rewritten (it described a shape the code never had).
qBittorrent On a cold start or after session expiry there were 4–5 parallel logins per poll. With a wrong password this hit qBittorrent's default IP ban (5 failures) in one poll, and the ban was renewed every poll. Logins are single-flight. After a rejected login there is a 5-minute backoff. Wrong credentials now give a clear message.
qBittorrent activeTorrents spread every maindata field, including magnet_uri and save_path, into history and the WebSocket. Keeps only the 11 documented fields.
ttlMemo (slow lanes: AdGuard, IPFS, Homebridge, Hue, qBittorrent) A failing slow-lane fetch was retried on every poll. A failure is remembered for min(ttl, 60 s), serving the stale value or the same error during that window. A failure caused by the caller aborting is not remembered.
AdGuard 401 and 403 were reported as UNAVAILABLE. Now UNAUTHORIZED, so the tile asks for credentials.
Alby Hub Unread response bodies (401 wallet calls, legacy probes) leaked undici sockets. NWC health lost the timeout reason. All bodies are read, and the reason is kept.
IPFS A full repo/stat walks the whole blockstore on every 30 s poll. Uses size-only=true per poll, and gets the object count on the 10-minute slow lane.
Bitcoin Node warm-up errors (-28) were dropped, leaving "data incomplete". A 403 from rpcwhitelist was reported as UNAVAILABLE. The node's message now reaches health and stats, and 403 maps to UNAUTHORIZED.
Bitcoin / ZMQ A failed ZMQ connect looked like a working setup that had seen no blocks. The socket leaked when connect threw, and a connect that finished after onStop was never closed. New zmqConnected and zmqError metrics. The socket is closed in both cases.
Hue After the bridge closed the eventstream cleanly, light counts were never re-fetched. Health downloaded every light every 10 s without reading the body. Latency was measured before the API call. A clean end triggers a re-seed. Health probes /resource/bridge and reads the body. Latency is now the API round trip.
WebSocket alerts A down service, or one whose stats kept failing, flapped an error toast and a "recovered" toast every stats interval. Error state is tracked per id and scope, and reachable:false no longer counts as a recovery.
Live health fallback Before the first poll, GET /services probed with no time limit, so one unreachable host failed the dashboard request (e.g. after every profile switch). A probe cut short could overwrite newer state. 5 s limit. The result is cached only if the probe was not cut short, the instance is unchanged, and no newer poll result exists.
Poller Every poll was cut at a fixed 10 s, so raising timeoutMs past 10 s never worked. The per-poll limit is now max(10 s, 2 × timeoutMs), which is unchanged at the default.
SNMP (Synology, router) net-snmp session 'error' had no listener, so a malformed packet crashed the backend. Abort listeners leaked. Settles with UnavailableError, and listeners are cleaned up.
Synology DSM-only mode always showed the NAS as offline, and a total DSM failure returned zeros as success. A failed or partial SNMP walk caused fake network-rate spikes. DSM code 105 (permission) caused a re-login on every poll. DSM-only health uses SYNO.DSM.Info, and a total failure now returns an error. Walk failures no longer move the rate baseline. 105 now gives UNAUTHORIZED with no re-login.
Router Rate spikes after a failed walk, or after switching between HC and 32-bit counters. Same baseline rules as Synology.
Raspberry Pi Stats needed pigpiod even when SSH worked (so Pi 5 got no stats). The pigpio client leaked on failed connects, and a connect finishing after close() also leaked. Health could hang forever. throttled stayed set for past events. pigpiod is now optional (pigpioError is reported). Commands are time-bounded and the leaks are fixed. New throttledNow and throttledSinceBoot metrics.
SSH pool Exec channels were never closed on timeout or abort, so OpenSSH's MaxSessions was exhausted. Channels are closed, including ones that open late.
Mac mini smartctl was not found on Homebrew installs, because PATH was set in another subshell. Disk showed 0 when df failed. Each segment sets the Homebrew PATH. Disk is null when unknown.
Tor The control socket had no permanent 'error' listener (crash on reset). Event-subscription close() could hang for about 15 minutes. Traffic deltas went negative after a Tor restart. Onionoo was queried on every poll. Permanent listeners, bounded waits, null deltas after a reset, and a 10-minute Onionoo cache.
Ping A high pingCount was killed mid-run, so a host that was up was reported down. The count is capped to fit timeoutMs, and replies printed before the kill still count.
Roon The pairing token was written to ./config.json in the cwd. Stored in DATA_DIR/roon-state.json (0600), one entry per Core. The old file is still read as a fallback.

Docs updated: docs/api/config.md, docs/api/services-health.md, docs/features/real-time-updates.md, docs/features/service-monitoring.md, and docs/integrations/{adguard,bitcoin,ipfs,macmini,philips-hue,qbittorrent,raspberry-pi,roon,router,synology,tor}.md.

Decisions (standing rule: pick the recommended option)

  • qBittorrent backoff is 5 minutes. That is shorter than qBittorrent's default 1-hour ban. Saving new credentials recreates the service, which clears the backoff.
  • Slow-lane failure window is min(ttl, 60 s). Failures caused by the caller aborting are not remembered.
  • Poll budget is max(10 s, 2 × timeoutMs). Two, because probes chain at most two bounded requests in series.
  • Ping: no -i flag; the count is clamped instead. Short intervals need root on some platforms.
  • Roon state key is host:port. This keeps two Cores from overwriting each other.
  • Branch kept current by merging main in (GitHub "update branch"), not rebasing. No force-push needed; the PR is squash-merged.

Not changed (needs a decision or a live device)

  • SSH host-key verification: none today (Pi and Mac mini). Adding it needs a new config field (known-hosts or fingerprint pin) and UI. This is a feature-level change.
  • Synology over https://…:5001 with DSM's self-signed certificate: needs a new allowSelfSigned or certHash field plus UI. Today DSM over HTTPS fails silently.
  • Better-method ideas, not done: Tor PROTOCOLINFO auto-auth, a persistent Tor control connection, Mac route get default for the interface, router ifName matching, reusing the SNMPv3 session, Kubo API.Authorizations, and an Alby expired-JWT warning.
  • Frontend follow-ups for the UI thread: the Pi renderer should warn on throttledNow. The Synology chart should handle networkRx/networkTx being absent.

Read-only rule

No new calls to monitored services. Removed calls: Hue /resource/light on health checks, the full IPFS repo/stat on every poll, Onionoo on every poll, and DSM re-logins on code 105.

Checks

Nothing ran in the cloud (no npm access). CI on a574e39: all checks green (backend tests, frontend tests, typecheck, lint, generated-artifact check, CodeQL, Trivy). The first CI runs (019b6c3, 2ad418b) failed on two dsmClient tests that still expected a re-login on code 105; a574e39 updated them. CI re-runs after the main merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_018QJtaL6RxEK3gXc6YDZdPD

…eachable

Most checkHealth implementations resolve ok() with reachable:false when the
service can't be reached (withHostPing), so POST /config/services/:id/test
returned ok:true for a dead service. It now returns ok:false with
error.code UNREACHABLE and keeps the snapshot for diagnosis. Docs for the
endpoint described a request/response shape the code never had; rewritten.
… torrent detail; remember slow-lane failures

qBittorrent: getStats fanned out 4 parallel requests that each logged in on a
cold start or expired session, so a wrong password made ~5 failed logins per
poll and tripped qBittorrent's default IP ban (5 failures), renewed every
poll. Logins are now single-flight and a rejected login is not retried for
5 minutes. Response bodies on login and 403 are consumed. Wrong credentials
(200 "Fails.") now say so. activeTorrents spread every maindata field
(magnet_uri, save_path, ...) into each snapshot; it now keeps the 11
documented fields.

ttlMemo: a failing slow-lane fetch was retried on every poll. A failure is
now remembered for min(ttl, 60s), serving the stale value or the same error.
…dentials

Every non-2xx was UNAVAILABLE, so a wrong password showed as an outage.
Docs no longer claim a protection toggle (Watchman is read-only).
…C health

Unparsed responses (401 wallet calls once the JWT expires, every legacy probe
response, non-2xx info) were never read, so undici held their sockets until
GC. NWC health said "returned no response" for timeouts; it now carries the
error message.
@EraPartner EraPartner self-assigned this Oct 28, 2026
…slow lane

A full repo/stat walks the whole blockstore to count NumObjects, which on a
large repo exceeded the timeout every poll while Kubo kept working.
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 86.51% 1732 / 2002
🔵 Statements 84.06% 1888 / 2246
🔵 Functions 79.09% 488 / 617
🔵 Branches 73.29% 1534 / 2093
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
apps/frontend/src/types/generated.ts 0% 0% 0% 0%
Generated in workflow #379 for commit 546cd00 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Coverage Report for backend

Status Category Percentage Covered / Total
🟢 Lines 93.18% (🎯 80%) 2761 / 2963
🟢 Statements 90.02% (🎯 80%) 3024 / 3359
🟢 Functions 85.96% (🎯 80%) 588 / 684
🟢 Branches 78.14% (🎯 72%) 1974 / 2526
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
apps/backend/src/core/ttlMemo.ts 100% 100% 100% 100%
apps/backend/src/domain/services/adguard/AdGuardService.ts 92.13% 74.46% 95.23% 97.56% 246, 268, 283, 285, 291, 299, 306
apps/backend/src/domain/services/albyHub/AlbyHubService.ts 82.39% 70.43% 80.95% 86.15% 205-207, 262-272, 296, 378-380, 388-393, 411-413, 430, 477, 486
apps/backend/src/domain/services/bitcoin/BitcoinService.ts 83.75% 66.88% 82.75% 89.43% 136, 171, 179, 186-187, 196, 350, 361, 419, 426-429, 432, 439, 455-458, 461-464, 468-470, 477, 480-481, 490
apps/backend/src/domain/services/ipfs/IpfsService.ts 80% 59.49% 72% 84.05% 153-162, 167, 174, 232-235, 247, 249, 278, 296, 305
apps/backend/src/domain/services/macMini/MacMiniService.ts 96.47% 78.28% 100% 97.7% 199-201, 214, 250, 360, 410
apps/backend/src/domain/services/philipsBridge/PhilipsBridgeService.ts 92.68% 83.72% 88.88% 95.65% 151, 157, 292, 311-312, 337-338
apps/backend/src/domain/services/qbittorrent/QBittorrentService.ts 90.54% 66.66% 80.95% 93.93% 209-213, 217-220, 246, 282, 318, 327, 391, 415-418, 421
apps/backend/src/domain/services/raspberryPi/PiStatsCollector.ts 89.17% 66.27% 95% 97.63% 146, 217, 219, 220, 222, 223, 224, 226-227, 265, 340, 348, 356, 364, 374, 381, 389
apps/backend/src/domain/services/router/RouterService.ts 99% 94.93% 100% 98.94% 300
apps/backend/src/domain/services/synology/SynologyService.ts 98.7% 88.88% 100% 99.26% 269, 287
apps/backend/src/domain/services/tor/TorService.ts 89.49% 81.73% 73.33% 93.75% 166, 170-171, 193, 199, 214, 251, 264, 347, 376, 383, 386, 395, 400, 476, 480, 500, 510, 546-548, 600, 659, 663, 665
apps/backend/src/infra/gpio/sharedPigpioClient.ts 90.9% 68.75% 77.27% 97.61% 32, 42, 92, 104
apps/backend/src/infra/http/sseClient.ts 89.13% 75% 90% 95.12% 33, 39, 57, 86-87
apps/backend/src/infra/scheduler/poller.ts 87.5% 81.48% 78.94% 90.32% 70, 75, 81, 141, 151-158, 166, 172-178
apps/backend/src/infra/ssh/sshPool.ts 91.05% 77.58% 100% 93.2% 61, 80-81, 129-134, 137, 195, 246, 260
apps/backend/src/infra/synology/dsmClient.ts 97.95% 89.74% 88.88% 100% 44
apps/backend/src/infra/tor/controlClient.ts 87.5% 72.34% 88.23% 92.14% 41, 53, 70, 73, 79-81, 137-140, 145, 163-165, 187, 204, 206-207, 212, 222-223, 271, 284, 329, 331
apps/backend/src/infra/tor/eventSubscription.ts 82.63% 68.18% 80% 85.62% 38, 50, 67, 70, 76-78, 110, 120, 162-163, 171-174, 183-185, 192, 218-219, 224, 241-242, 246-247, 272-273
apps/backend/src/infra/zmq/zmqSubscriberImpl.ts 96.96% 100% 100% 96.66% 35-40
Generated in workflow #379 for commit 546cd00 by the Vitest Coverage Report Action

claude added 18 commits October 28, 2026 08:16
- Per-call batch errors were dropped, so a node warming up (-28 "Loading
  block index…") showed only "data incomplete". The node's message now
  reaches the health message and the stats error.
- HTTP 403 (rpcwhitelist rejecting a method) maps to UNAUTHORIZED.
- A failed ZMQ connect left stats looking like a working setup with no
  blocks. New zmqConnected/zmqError metrics say why. A connect that
  finishes after onStop is closed, and the zeromq socket is closed when
  connect/subscribe throws.
- sseClient reported transport errors but not a clean end, so after the
  bridge closed the stream the service kept serving its light map without
  ever re-fetching, missing every change made during the reconnect gap.
  A clean end now goes to onError.
- Health probed /resource/light (every light, every 10 s) and never read
  the body. It now probes /resource/bridge and reads the body; non-2xx
  bodies are read everywhere.
- Health latency was measured before the API call (≈0 with ping off). It
  is now the API round trip, falling back to ping.
…erval

Error state was one set per service id for both lanes, and any health
update counted as recovery, including ok(reachable:false). A down service
(or one whose stats keep failing) produced an error toast every stats poll
and a false "recovered" toast on the next health tick, forever. Error
state is now per id+scope, and an unreachable health snapshot is not a
recovery.
… newer state

Before a service's first poll, GET /services and /services/:kind/health
probe live on the request signal with no limit, so one unreachable host made
the whole dashboard request time out (frontend gives up at 10 s), e.g. after
every profile switch. The probe now has its own 5 s limit, and its result is
cached only if it was not cut short, the instance is still the registered
one, and no poll published meanwhile.
…utMs

Every poll and cached stats read was cut at a fixed 10 s, so raising
timeoutMs above 10 s (slow NAS, Bitcoin node) never helped: every poll
failed with "aborted after 10000ms" and tripped the breaker. The limit is
now max(10 s, 2 × timeoutMs), unchanged at the 5 s default.
…ted signals

net-snmp emits 'error' on a session when an incoming datagram fails to
parse. With no listener that is an uncaught exception that exits the
backend (Synology and router both use this getter). get and walk now share
one settle path that also removes the abort listener and rejects at once
for an already-aborted signal.
105 means the account lacks privilege for the API. The client treated it
as session expiry, so a non-admin account logged in again on every poll
and left a new DSM session each time. It now fails with an
UnauthorizedError naming the API. Only 106/107/119 re-login, and the
retry uses the sid its own login returned.
…m partial walks

- DSM-only mode (no SNMPv3 credentials) always reported the NAS offline,
  because health only probed SNMP. Health now probes SYNO.DSM.Info getinfo.
- DSM-only getStats where every DSM call failed returned ok() with zero
  CPU, memory and disk. It now returns the first DSM error.
- A failed ifDescr or counter walk was summed as 0 and stored as the rate
  baseline, so the next poll reported the whole counter as one interval's
  traffic (~1e12 B / 30 s). Failed walks now give no sample, the baseline
  is kept, and rates are only computed between samples of the same
  counter kind (HC vs 32-bit).
A failed ifDescr walk left no active interfaces, so octets were summed as
0 and stored as the rate baseline; the next poll reported the whole
counter as one interval's traffic. A failed HC walk also fell back to the
32-bit sum, mixing counter families. Now a failed walk gives no octet
sample and keeps the baseline, and rates are only taken between samples of
the same counter kind.
…lies and close

controlClient and eventSubscription called removeAllListeners() once
connected. That left the control socket without an 'error' listener (a reset
between commands was an uncaught exception that kills the backend) and also
stripped net.Socket's internal 'end' handler, so a peer FIN never closed the
socket and reads waited for the abort signal. Only the connect listeners are
removed now; the control handle keeps permanent 'error'/'close' listeners and
fails reads fast once the socket is gone.

The BW event subscription waited for replies with no limit, so close() (and
TorService.onStop) hung on a half-open socket until TCP gave up. Each reply
is now awaited for at most timeoutMs, after which the socket is dropped.
…b on SETEVENTS failure

- Onionoo mode searched onionoo.torproject.org on every health (10 s) and
  stats (30 s) poll. Health and stats now share one lookup per instance for
  10 minutes; failures are not cached.
- trafficDelta* went hugely negative after a Tor restart reset the counters.
  That poll now reports null and rebases.
- subscribe() leaked the authenticated socket when SETEVENTS failed.
…led run

ping sends one echo per second, so pingCount 8 with a 5 s timeout ran ~7 s,
was killed, and a responsive host was reported unreachable. The count is now
capped at timeoutMs/1000, and a run killed after printing a reply line counts
as reachable (average taken from the per-reply times).
…rotocol reference

The cited configure flag does not exist; document the real cookie locations
(<DataDirectory>/control_auth_cookie, Debian /run/tor/control.authcookie),
CookieAuthFileGroupReadable, and that cookie auth needs Watchman on the Tor
host. trafficDeltaRead/Written directions were swapped. Note the Onionoo
cache, the pingCount cap and the bounded subscription close.
…io connections; split throttled flags

- PiStatsCollector: a pigpiod connect failure no longer fails stats when
  direct SSH or the relay works (pigpiod doesn't run on the Pi 5 family).
  hwRevision/pigpioVersion stay null and pigpioError says why; stats fail
  only when every source failed.
- pigpioClientImpl: end() the client when the connect times out or errors
  (it kept retrying in the background), and keep an 'error' listener for
  the life of the connection so a later socket error can't go unhandled.
- sharedPigpioClient: bound each command by timeoutMs (a powered-off Pi
  never answers, which hung the health check) and end a connection that
  finishes after close().
- New throttledNow (bits 0-3) and throttledSinceBoot (bits 16-19) metrics;
  the sticky since-boot bits made any past brownout look active.
- Docs: compound exec, reachability, relay fallback, pigpiod optional,
  throttled bits, SSH pool behaviour.
A hung remote command kept its channel open on the pooled connection; after
sshd's MaxSessions (default 10) every later exec on that host failed. The
channel is now closed when the exec times out or is aborted, including one
that opens after the request already settled.
Each compound segment runs in its own subshell, so the Homebrew PATH set by
the temperature segment never reached smartctl, and SMART metrics were
always null on Homebrew installs. Disk fields were 0 (shown as "Disk 0%")
when df produced nothing; they are now null like the other unmeasured
metrics. Docs: health is ping only, Mac prerequisites, memTotalBytes.
node-roon-api writes its state to config.json in the process cwd unless
given persisted-state hooks: an untracked secret in a dev checkout, or lost
on a read-only cwd so every restart asks to enable the extension again.
The token now lives in DATA_DIR/roon-state.json (0600), keyed per Core
address. The old config.json is read as a fallback so existing pairings
survive.
@EraPartner EraPartner changed the title fix(backend): service data-collection bugs (test connection, qBittorrent, AdGuard, Alby, slow-lane cache, …) fix(backend): service data-collection bugs across all 13 integrations Oct 28, 2026
Replaces the old 105 retry test (the behaviour it pinned was the bug) and
uses 106 for the retry-also-fails case.
@EraPartner
EraPartner marked this pull request as ready for review October 28, 2026 08:25
@EraPartner
EraPartner enabled auto-merge (squash) October 28, 2026 08:25
@EraPartner
EraPartner merged commit bdef00a into main Oct 28, 2026
18 checks passed
@EraPartner
EraPartner deleted the claude/project-thread-ze36w4 branch October 28, 2026 08:27
EraPartner pushed a commit that referenced this pull request Oct 28, 2026
Rebased onto #145/#147/#148: document SSH host-key pinning (Mac Mini,
Pi), the DSM certificate fingerprint and DSM-only health, the now-used
Bitcoin rawtx ZMQ endpoint, Roon pairing state in DATA_DIR, and the
new error messages and login backoffs.
EraPartner added a commit that referenced this pull request Oct 28, 2026
…144)

* docs: add per-service setup guides with least-privilege credentials

Add docs/guides/service-setup/ with an index and one guide for each of
the 13 service kinds: what to enable on the service, which read-only or
least-privilege credentials to create, what to enter in Watchman, and
troubleshooting keyed to the error messages in the code.

Link the guides from the integration pages and indexes, and fix doc
drift found while checking against the code: AdGuard no longer claims
toggle control, the Homebridge health probe path, Bitcoin's Tor claim,
and the unused zmqRawtxEndpoint.

* docs: drop Test connection caveat, fix useServiceHealth hook doc

#146 made Test connection report an unreachable service as Failed, so
the setup guides no longer need the known-issue callout.

docs/components/use-service-health.md described a protection-toggle
mutation hook and a cache-clear hook that don't exist; rewrite it from
apps/frontend/src/hooks/useServiceHealth.ts (read-only, ADR-032).

* docs: update setup guides for the service bug fixes on main

Rebased onto #145/#147/#148: document SSH host-key pinning (Mac Mini,
Pi), the DSM certificate fingerprint and DSM-only health, the now-used
Bitcoin rawtx ZMQ endpoint, Roon pairing state in DATA_DIR, and the
new error messages and login backoffs.

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants