Skip to content

feat(tailscale): opt-in tailnet monitoring per Spark - #43

Open
siraustin wants to merge 1 commit into
MiaAI-Lab:mainfrom
siraustin:feat/tailscale-monitoring
Open

feat(tailscale): opt-in tailnet monitoring per Spark#43
siraustin wants to merge 1 commit into
MiaAI-Lab:mainfrom
siraustin:feat/tailscale-monitoring

Conversation

@siraustin

Copy link
Copy Markdown

The problem

A Spark can be entirely healthy — SSH answering, GPU reporting, LLM serving — while tailscaled has lost its session with the coordination server. Short connections retry and survive, so every LAN-based check keeps reporting up and is correct. But the box is unreachable from anywhere off the LAN: phone on cellular, the admin console, another tailnet node.

This bit me this morning on a 7-Spark fleet. sparkDash said all 7 up. Three of them (aria, nova, lynx) had been invisible on the tailnet for ~15 hours, logging this every ~2 minutes since 43 minutes after boot:

control: map response long-poll timed out!
Received error: PollNetMap: Post ".../machine/map": context canceled

540 occurrences on one node. Node keys were fine, DNS fine, MTU fine, routes fine, clocks fine. Just no control-plane session. systemctl restart tailscaled fixed all three. The dashboard never had a reason to say anything, because nothing it measures was wrong.

What this adds

Opt-in tailscaleMonitoring per Spark (default off), a TailscaleProbe reading tailscale status --json, and a Tailnet card next to Network.

The card, rendered on a live Spark (dark theme, native styling):

TAILNET                          [Running]
Status  online
IP       100.x.x.x
Host     lynx
Relay    nyc
Version  1.98.10-t0ee734d30

When the node reports itself off the tailnet the card goes red with OFF TAILNET and prints Tailscale's own Health strings — so it explains the failure rather than just flagging it:

Tailscale hasn't received a network map from the coordination server in 2m7s.

Also surfaced: BackendState (Running/Stopped/NeedsLogin), tailnet IP, DERP relay, version, and an expired-node-key warning (worth distinguishing, because a restart will not fix that one — it needs re-auth).

Design notes

  • Asks each node about itself. Reads Self.Online, never a peer's view. During the incident my Mac's tailscale status claimed nova was online while nova's own tailscaled said Online: false — one node's view of a peer can be stale, so peer state is never the verdict.
  • Default off, so nothing changes for users who don't run Tailscale.
  • 30s poll (POLL_INTERVAL_TAILSCALE). Each poll is an SSH round-trip and tailnet state moves slowly; 2s like the Comfy probe would be wasteful.
  • Read-only. Never runs tailscale up/down/login.
  • Local Sparks under Docker: tailscaled's socket lives on the host, so a plain sh -c in the container finds neither the CLI nor the socket. The probe enters the host mount namespace via nsenter, mirroring SystemCollector._execOnHost for nvidia-smi, and falls back to plain exec when /host/proc isn't mounted.
  • No timestamps in the snapshot payload, preserving the byte-identical broadcast dedupe noted in SparkMonitor.snapshot().
  • Parsing is a pure exported function (parseTailscaleStatus) so it unit-tests without I/O, matching how summarizeComfyPrompt / estimateQueueEtaMs are tested. There's no existing DI seam for sshExec, so the two probe-level tests stub the instance's exec method; happy to switch to a constructor-injected exec if you'd prefer a consistent pattern.

Followed the comfyMonitoring precedent throughout: Boolean() coercion in _normalizeConfig, flag-gated probe + interval in SparkMonitor, mirrored flag in snapshot(), Edit Spark checkbox, validate.js untouched (nothing host/user-shaped).

Testing

  • 97/97 tests pass, 9 new: parser on healthy / wedged-netmap / expired-key / missing-and-malformed / whitespace-only input, plus probe default-shape on command failure, unparseable output, and stale-error clearing on recovery.
  • tsc --noEmit clean; vite build clean.
  • Live on a 7-Spark fleet for the four boxes I enabled: correct IP / relay / version / key-expiry on each.
    • Includes the local Spark inside the Docker container — the nsenter path works.
    • Stopped tailscaled on one node and watched the card degrade to an error and then clear on recovery.

One honest gap: I did not capture the red OFF TAILNET state live. Reproducing it needs a wedged control session (not a stopped daemon), and I wasn't willing to wedge a production node to stage a screenshot. That state is covered by unit tests against captured real-world output.

Version

Bumped to 1.7.0 with README + CHANGELOG entries, since a new opt-in monitoring feature matched the 1.6.0 precedent. Happy to drop the bump if you'd rather own versioning — just say and I'll amend.

A Spark can be entirely healthy — SSH answering, GPU reporting, LLM serving —
while tailscaled has lost its session with the coordination server. Short
connections retry and survive, so every LAN-based check (including sparkDash's
own SSH liveness) keeps reporting "up" and is correct, but the box is
unreachable from anywhere off the LAN: phone on cellular, admin console, other
tailnet nodes. The dashboard looks right and the Spark is invisible.

Adds `tailscaleMonitoring` (opt-in per Spark, default off), a TailscaleProbe
that reads `tailscale status --json`, and a Tailnet card beside Network. It
reports the node's OWN `Self.Online` rather than a peer's possibly-stale view,
and surfaces Tailscale's `Health` messages so the card explains the failure
("hasn't received a network map from the coordination server in 2m7s") instead
of just flagging it.

Read-only: never runs tailscale up/down/login.

Notes:
- Default off, so nothing changes for users who don't run Tailscale.
- 30s poll (POLL_INTERVAL_TAILSCALE) — each poll is an SSH round-trip and
  tailnet state moves slowly.
- Local Sparks under Docker: tailscaled's socket lives on the host, so the
  probe enters the host mount namespace via nsenter, mirroring the existing
  nvidia-smi approach in SystemCollector.
- No timestamps in the snapshot payload, preserving the byte-identical
  broadcast dedupe.
- Parsing is an exported pure function (parseTailscaleStatus) so it is unit
  tested without I/O, matching how summarizeComfyPrompt is tested.

Tests: 9 new (parser incl. wedged-netmap/expired-key/malformed cases, probe
default-shape on failure, error-clearing on recovery). Suite 97/97, tsc clean.
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.

1 participant