Skip to content

v2.6.0 Release Changes - #1095

Merged
tvancott42 merged 60 commits into
mainfrom
dev
Aug 6, 2026
Merged

v2.6.0 Release Changes#1095
tvancott42 merged 60 commits into
mainfrom
dev

Conversation

@tvancott42

@tvancott42 tvancott42 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Roll-up of everything on dev since v2.5.3. Updated as work lands.

Planned for this release

Not yet on dev. Listed so the scope of the release is visible while it is built.

  • Topology history recording and playback

Settings - Audit Log

  • Reading the audit log is checked where the reading happens. The page and the export endpoints each required an Admin, but the service behind them required nothing, so the protection belonged to whatever sat in front of it rather than to the log itself. It is the record of who did what across the whole install - actors, source addresses, target names, and the site each action touched - so every read now carries the same Admin check, including the CSV and JSON exports, which leave as files. Nobody who could reach it before is affected; the requirement is the one the page already enforced. (Gate reading the audit log at the service, not only at the page in front of it #1096)

Multi-Site

  • A disabled agent stops counting against its site. The /sites card read "0/1 agents online" for a site whose only agent had been deliberately disabled, and the Agents tile counted it while Agents Online never could, so the pair read as an outage rather than a choice. Disabled agents now drop out of both halves of the per-site count and out of both overview tiles. A site whose only agent is disabled reaches 0/0, which is omitted rather than rendered as a count of nothing.
  • The Site ID preview shows the ID the site will actually get. Typing a name that collides with an existing site, or with the reserved main, promised an ID the site would not be given. The hint now asks the same generator the creation does, suffix and all.
  • A site with more than one WAN is told where the second one gets monitored. Nothing on the page said how, and the answer - enroll an agent for that WAN from the site's own Configuration - is not somewhere you would look without being told. A card on Settings - Multi-Site now names the steps, and names the site it means. It appears only where it is news: more than one WAN, and no vantage with an agent bound to it, so a site already monitoring its second WAN never sees it. With multi-site off it leads with turning that on instead, since the agent enrolls through it. Dismissed per user rather than per install - what one operator has read says nothing about their colleagues.

Monitoring

Every WAN a site has is now monitored, not just the primary one. (#1098)

  • A WAN vantage names the WAN it measures and how its probes get there - bound to a source IP the gateway policy-routes out that WAN, or run by an agent behind it. An agent on the gateway binds probes to the WAN's own interface, so no policy-based route is needed there at all. Latency points carry the WAN, upstream discovery runs per WAN, and ISP Health grades each WAN against its own counters, plan speeds, targets and hops instead of pairing one WAN's traffic with another's expectations.
  • Latency & Packet Loss takes a WAN filter - one WAN alone, several compared, or all. Shared hosts probed from every WAN line up side by side, one color per host and one line pattern per WAN. Live View takes its own selector with its own saved selection, so watching one WAN never moves the analysis views.
  • Latency Targets and Flaky Monitoring Targets name the WAN a target belongs to, and Network Tools can run a probe from any vantage, which is how a policy-based route or interface bind gets confirmed.
  • Live and analysis link both ways, carrying the moment, the WAN and the category, without either view's saved filter being written to.
  • Metered WANs cost less to monitor. Continuous probing is about 5.4 GB a month at 25 targets on the 10s default, which is most of a small satellite or cellular plan. Satellite, cellular and fixed wireless each cost a rung, and a configured Data Usage cap costs another, meaning fewer targets and a slower cadence. The allowance rotates across access hops, transit and path endpoints so a site is not left with detail on its own first mile and no way to tell whether anything it reaches is up.

ISP Health

Several of these apply to single-WAN sites too.

  • Loaded latency no longer answers "was any sample high", which one ICMP-deprioritized responder answers on its own. Samples are collapsed by instant across every non-LAN target on the WAN, since a queue on the access link sits in front of all of them. The figure no longer falls as more targets are monitored.

  • A line that stays clean under load reads as clean rather than as an absence of evidence, and a run of clean load episodes after elevated ones is read as the line having been fixed. Evidence is weighed by recency, by how loaded, and by how sustained.

  • A WAN speed test can raise the figure where it read higher and actually saturated the link, since the probes sample on their own cadence and a short event's peak queue can build and drain between two of them unseen.

  • Off-path access hops stop feeding the packet loss pool, unless they are all a site has. A hop that answers pings but appears in no trace carries none of your traffic.

  • Starlink is graded on measured dishes rather than estimates. Idle latency: 23 ms is the best the medium does and scores full, 42 ms is where a healthy Backup dish sits. Loaded: 3 ms excellent, 12 ms acceptable, replacing a ceiling that sat above the 95th percentile of real delta and could not fail anything. A dish reporting a reduced-speed plan tier is graded on whether it carries usable traffic rather than on ratio, so a backup doing its job in an emergency stops reading as a failure.

  • Reports are dropped when the inputs behind them change - a target edit or discovery run on any WAN, and the moment a site's agent and console are both finally up, since a report computed before that saw a partial site.

  • GPON upstream loaded loss is graded against 1.5%, up from 1.0%. A PON's upstream is shared on TDMA grants and a gig plan alone is most of it, so contention loss while an upload saturates is the medium behaving normally rather than a fault. It is also where SQM does its work, since an AQM controls a queue by dropping - at the old ceiling a correctly shaping line was graded as a problem for it. A genuinely oversubscribed segment still fails. Downstream, XGS-PON, DOCSIS and Starlink are unchanged.

Upstream Path Discovery

  • A UniFi Cellular Modem WAN is recognized as cellular before anything is probed. The gateway reaches an attached modem over a GRE tunnel and nothing else on a UniFi gateway presents a WAN that way, so the access technology arrives already selected rather than waiting on a first-mile device to identify itself - which on this medium it never can, since the device on the other side is the modem. Only that one topology is settled this way: a third-party modem or a bridged carrier router is equally cellular and looks like any other WAN, so those still ask.
  • The modem's own tunnel endpoint is no longer offered as a first-mile hop. It is a CGNAT address on the customer side of the radio that answers every trace in a fraction of a millisecond, so monitoring it measured the tunnel rather than the carrier. Carrier hops past it, CGNAT included, are still offered.

Live View

  • A download and an upload read the same color wherever they appear. The rate tiles and the WAN throughput charts drew the graph palette's blue and emerald while a speed test result drew a different blue and green, so one rate changed color depending on which card it was on. All of them now take the speed test pair: the tiles, the live chart, the Network Performance throughput chart, and Fabric Ingress / Fabric Egress. The Dashboard live card shares the change.
  • Round-trip figures hold two decimals below 100 ms and one at or above it. Two is the resolution worth reading on a one or two digit figure and stops earning its space at three. The step also holds the tile width steady, since "99.99" and "100.0" are the same length.
  • The tiles carry more on a phone. WAN Download and WAN Upload read as arrows with the unit set smaller, so the figure keeps the width it needs. The icon that jumps between watching a moment and analyzing it moves into the card's top right corner on Latency & Packet Loss, where it had been wrapping onto a line of its own beside empty space, and sits centered on Live View.

Device Stats

  • Restarts and alerts are marked on the charts. Every chart on the tab now carries a mark on the time axis for each restart and each alert raised against a charted device, hoverable for the reason behind it - power loss, a firmware upgrade, a kernel panic, a watchdog reset - with unexpected restarts in the warning colour and planned ones muted. Marks fold together when they would collide, so a device that restarted several times reads as one mark carrying a count rather than a smear of glyphs, and the fold lists every event it covers. They filter with the legend badges, so hiding a device drops its marks the same way it drops its line.
  • SFP Stats and ONT Stats carry their own. SFP temperature and RX/TX power breaches mark the module they belong to, matched on device and port so a switch with several modules marks only the one that breached. An attached ONT's alerts sit on the PON charts, where the counters they explain are plotted, with PON link down raised to the optics charts as well since it is what the RX and TX traces are usually being read against. GEM Frames (per interval) stays unmarked - it plots the traffic itself, where a mark would read as a comment on throughput rather than on the link. Standalone ONTs get the same treatment on ONT Stats.
  • A restart reads as one mark. Stored reboot records are collapsed to the boot they describe before they are drawn, keeping the one classified by the newest rules, so a single restart is one mark however many records were written for it. (Mark restarts and alerts on the Monitoring charts #1105)

Alerts & Schedule

  • One outage pages once, and names the WAN rather than the hosts behind it. An access-layer break raised an alert per monitored target, so a single first mile going down arrived as a notification for every host behind it. The WAN-facing categories now feed a per-WAN state machine: one open alert per WAN and per kind, classified partial or total against the persisted trace map, with a site-level rollup when every WAN of a multi-WAN site drops in the same evaluation. Recovery closes the alerts it names, a confirmed total supersedes the partial it grew out of so the two never stack in Active Alerts, and Fabric and Custom targets keep their per-target alerts. Three rules ship enabled. Rule seeding is now one-time per install as well, so a rule you delete stays deleted instead of returning on the next restart. (Per-WAN outage alerting, and the Alerts & Schedule page it lands on #1101)
  • The dish raises its own alerts. Starlink reports more about itself than any other physical source we poll, and was the only one with nothing alerting on it. Six conditions now fire from the dish poll: its own alert codes and disablement state, a sustained obstruction or persistently low SNR, alignment departing from the dish's own 7 day baseline, an Ethernet link negotiated below what that dish has been seen to reach, outage seconds accumulating over a rolling day, and the transition into a service rate limit. A recovery closes the one condition it names, so a dish that clears an obstruction keeps whatever else it still has open. These are deliberately independent of per-WAN outage alerting, and their severity does not soften for a backup: a Starlink is usually a backup WAN with no vantage, no agent and no monitored targets, so the dish is the only sensor on that link, and a degraded backup is silent by construction until the moment it is needed. (Starlink: alert on what the dish reports about itself #1102)

Config Optimizer

  • Smart Queues Not Shaping on <WAN> - UniFi Network regularly accepts the Smart Queues toggle on a WAN without provisioning the shaper: the setting reads as on, the gateway has no htb root class, and the connection runs unshaped with nothing on screen to say so. Network Optimizer now reads the gateway's traffic control over SSH and raises a recommendation when the shaper is not actually there, with the fix - add any QoS rule in UniFi Network, which makes it provision the queues. A WAN shaped in only one direction is reported naming the direction that is not. Runs with the existing Performance Suggestions option in one SSH round trip however many WANs there are, and stays silent whenever it cannot see the answer. (Config Optimizer: catch Smart Queues that UniFi Network never actually turned on #1100, closes UniFi OS 5.1.28 #1083)

Dashboard

  • A restart reason that belongs to an earlier boot is not shown. A device that had just restarted could be shown the reason for its previous run - an AP that was power cycled kept reporting a firmware upgrade from days earlier. The Device Status card reads uptime straight off the UniFi Console on every load, while the tracker's record is only as fresh as its last uptime sample, so the two disagreed in the gap. The reason is now resolved against the boot the device is currently reporting. Only the display path changed; nothing about how reasons are probed, classified, stored or alerted on is touched. (Dashboard: hide a restart reason that belongs to an earlier boot #1099)

Fixes

  • Turning off agent collection puts the console back on the direct path. Unchecking "an agent collects for this site" on the main site left the UniFi Console still being reached through the agent tunnel, so every console read failed once that agent stopped. Device SSH and modem/ONT polling already asked whether the site had been handed to its agent before routing through it; the console's own check did not, and went by the stored setting alone. The setting is deliberately kept rather than cleared, so turning collection back on restores the previous choice, which is exactly why the setting on its own cannot decide this. Changing the setting now also reconnects the console, so it moves to the right path immediately instead of on the next connection.
  • Settings agrees with itself about tunnel routing. The SSH hints read the stored setting directly while the console hints beside them asked whether the site's agent actually collects for it, so on a main site that had been switched back to server collection the two contradicted each other.
  • Run Test from Gateway says why it cannot run. It needs the WAN interface list from the site's UniFi Console, and returned silently without it, so with the console unreachable the button did nothing at all.
  • Switching agent routing on or off takes effect at once. Ticking "console via agent" reconnects the console, so the choice applies to the live connection instead of waiting for something else to reconnect it. The device routing setting drops its cache when it or agent collection changes, rather than appearing to do nothing for up to a minute.
  • Device SSH says it is waiting for the agent. When a site's devices are reached through its agent and that agent is offline, gateway SSH already said so; the shared device SSH path reported the underlying socket error instead, which surfaced on cellular modem polling. The gateway message no longer names Adaptive SQM either, since it is shown for every gateway SSH use.
  • Probe results from a main site agent that is not covering it are discarded. Configuration was already withheld from such an agent, but results were still recorded, so an agent that kept probing after collection was handed back to the server wrote the same targets the server was writing. Both series then landed in one chart at different sample rates.
  • Probing follows the setting, device polling follows the agent. One question gated both, and they are not the same question. SNMP reads a device's own counters, identical whoever asks, so this server carrying on while the agent is offline keeps the site's device history instead of letting it go dark. A probe measures the path from whoever runs it, so this server running one for a site its agent covers describes this server's route while storing it under the site's name - on a server that is not on the network it monitors, a different network entirely. Probing and upstream traces now stand down on the setting alone and report waiting for the agent, because a gap is honest and a number from the wrong vantage is not.
  • Agent collection is known before collection starts. The setting was cached with a one-minute expiry and read as off while the cache refilled, so a covered site briefly behaved as an uncovered one after every expiry: probes ran from this server, device access went direct instead of through the tunnel, and a console connecting in that window stayed direct. On a server that is not on the network it monitors, that meant measuring the wrong network and dialing the site's private addresses on the hosting provider's. The cache now holds until the setting changes and is loaded for every site at startup, and it is cleared with the other per-site state when a site is removed or created.
  • Removing the main site's last agent clears its tunnel routing. Both the console and device routing settings name a tunnel, so with no agent left they could only point at something that would never answer, and they outlived the agent on every path that removed one. Only the main site, where reaching the network directly is a real alternative: a secondary site is reached through its agent and nothing else, so the same settings describe its only way in and are kept for the agent that replaces it.
  • The QoS rule menu path reads the same everywhere. The app gave the path to UniFi Network's QoS Rules three different ways; the Cellular Data Savings recommendations and the Adaptive SQM deploy message now match the new Smart Queues finding. (Config Optimizer: catch Smart Queues that UniFi Network never actually turned on #1100)
  • The Upstream Path Discovery link in the access technology prompt goes somewhere. ISP Health needs the site's access technology before it can grade, and the prompt for it offers to set the technology during discovery - but that link rendered its own address as text, so it led nowhere.
  • Upstream Path Discovery no longer stalls on a CGNAT first mile. Attributing hops to ASNs could hang indefinitely, with the run stuck on that step and nothing in the log to say why. Shared CGNAT space carries no BGP attribution, so those hops were being asked about over the network once each - a whole first mile of them on a connection built that way - and the lookup had a deadline on its connect but none on the reply, so a rate-limited provider that accepted the connection and then said nothing blocked every later lookup behind it. Those addresses are no longer asked about at all, and the lookup that remains is bounded end to end.
  • A site's name shows as soon as multi-site is turned on. Enabling it left everything that names the current site blank - the header's site badge among them - until the page was navigated away from and back, because the name was only ever resolved on load and only when multi-site was already on.

tvancott42 and others added 30 commits August 2, 2026 22:21
Unchecking "an agent collects for this site" on the main site left the console
still dialing the agent tunnel, so every console read failed once the agent was
stopped. SiteTunnelRouting already asked whether the default site had been
handed to its agent before honoring devices.via_agent; the console's own reader
never did, and answered from the stored flag alone.

The flag is deliberately kept rather than cleared when coverage goes off, so
turning coverage back on restores the operator's choice - which is exactly why
the flag on its own cannot decide this. IsConsoleViaAgentAsync now asks the same
question its device-side counterpart does.

Removing a site's last agent clears both routing flags, for any site rather than
just the main one. They name a tunnel, so with no agent left they can only point
at something that will never answer, and they outlived the agent on every path
that removed one. SiteTunnelRouting gains an Invalidate so the cleared flag takes
effect immediately rather than after its one-minute cache expires.
The /sites card read "0/1 agents online" for a site whose only agent had been
deliberately disabled, and the Agents tile on the overview card counted it while
Agents Online never could. Both reported a site as missing an agent it had been
told to stop using.

Disabled agents now drop out of both halves of the per-site count and out of
both overview tiles. A site whose only agent is disabled reaches 0/0, which the
markup already omits rather than rendering a count of nothing.
Adding the routing cleanup to DeleteAgentAsync gave AgentEnrollmentService two
more dependencies and left the test that constructs it directly on the old
signature, so the test project stopped compiling. The app itself built and
deployed fine, which is exactly why it went unnoticed - src builds clean and
only the test assembly breaks.

The same empty service provider already used for coverage backs the routing
cleanup here, so removal logs that it could not tidy the flags and carries on.
That is the behavior worth having under test: tidying is best-effort and must
never fail the removal.
Gating IsConsoleViaAgentAsync on agent coverage fixed the routing but broke the
teardown hooks that shared it. Those ask whether the live client rides the
tunnel; the setting answers whether the console should route that way from here
on. The two diverge the moment coverage is switched off with a tunnel-routed
console still connected, and the hooks then declined to tear anything down: the
client stayed connected against a loopback proxy whose tunnel had died, with no
way back, because every automatic reconnect is gated on being disconnected. Each
console call then dialed the dead proxy and paid the full retry backoff.

The connect paths now record how they built the client, and all three hooks ask
that instead. This also closes a race in agent removal, where clearing the
routing flags could beat the tunnel's own teardown to the same check.

Changing coverage reconnects the console, so the switch takes effect on the
existing connection rather than only on the next one - a console parked in
awaiting-agent had nothing to move it. Not awaited: a reconnect takes seconds
and this runs from a checkbox.

Settings reads the SSH routing hint through the gated reader too. Read raw, it
claimed tunnel routing on a main site whose agent no longer collects for it,
contradicting the console hint beside it.
The default site was skipped when refreshing whether it has an enrolled agent,
so its enrolled flag stayed false forever and only a LIVE tunnel counted as an
agent being present. A main site handed to its agent therefore kept collecting
whenever that agent was merely offline - and with its devices routed through the
tunnel, every one of those polls dialed a loopback proxy with nothing behind it
and came back as SSH.NET's "no identification string". A secondary site counts an
enrolled agent whether or not it is connected and stands down cleanly, which is
why an agent-down secondary site has always looked right and this did not. With
coverage off the answer is false either way, so an install that has not opted in
takes an unchanged path.

Ticking "console via agent" now reconnects the console. That checkbox only
appears once coverage is on, so coverage is necessarily switched first, and
reconnecting there alone always ran against the console's old routing - the
choice went unapplied until something else happened to reconnect, which is how a
console configured for the tunnel stayed on the direct path with no error shown.

The devices flag drops its cache when it changes, and when coverage changes,
since coverage gates the answer without the flag itself being touched. It is
consulted per SSH command and per modem poll, so the switch appeared to do
nothing for up to a minute.

Gateway SSH's awaiting-agent message no longer says SQM will connect once the
agent is online. It is returned for every gateway SSH use, so naming one feature
read as a non-sequitur in Test SSH Connection.
Reverts the collection half of 89d1365. Letting the default site count an
enrolled-but-offline agent made it stand down like a secondary site, which took
the site dark until the agent returned. The fallback is the better behavior
here: a server that can still reach the network it monitors should keep
collecting rather than go quiet because an agent it was handed to is down.

The other three fixes in that commit stand - they were about settings taking
effect, not about who collects.
…wrong

Gateway SSH already answers with the awaiting-agent message when the site's
devices are tunnel-routed and no agent is online. The shared device SSH path
did not, so it dialed the loopback proxy with nothing behind it and surfaced
SSH.NET's 'no identification string' - true of the socket, useless to the
reader. It is what qmicli cellular modem polling rides on, which is where it
showed up. The cable modem, ONT and Starlink pollers are HTTP or gRPC and
never had this shape.
The push path refuses to send targets to a main-site agent unless the site has
been handed to it. The write path had no such check, so it recorded whatever
arrived. Switching coverage off stops the config going out but does not stop an
agent that already has targets: it keeps probing and pushing while the server
resumes probing the same targets itself. Both then write one series at different
cadences - identical means, mismatched sample rates - which renders as a sawtooth
rather than as obvious duplicates.

Needs an agent mid-probe when coverage flips, so it takes toggling to reach.

Not paired with pushing a stop to the agent. That would turn a transiently false
coverage read on a healthy site into a cleared target list and a real data gap,
which is a worse trade than some wasted pings that are now discarded anyway.
…repo

The Reverse proxy section jumped straight into hand-rolled Traefik/Caddy/nginx
config without mentioning NetworkOptimizer-Proxy, which ships the agent tunnel
route enabled by default. It also never stated plainly that the proxy is a
prerequisite rather than optional polish, so a reader running the app on a bare
LAN address had no cue that TLS termination was theirs to stand up.

Also preserve the original Host on the Caddy gRPC route. Optional in practice -
the tunnel service does no host-based routing and gRPC is exempt from canonical
redirect enforcement - but it keeps the example explicit.
Unlike Traefik and Caddy, nginx sends the upstream address as the Host by
default on both proxy_pass and grpc_pass, so the examples were the odd ones out.
It is cosmetic on the gRPC route - the tunnel does no host-based routing and
gRPC is exempt from canonical redirect enforcement - but it matters on the app
route, where enforcement is active and keys on Host, and an install following
the example verbatim would drive that off 127.0.0.1:8042.
…he proxy

The variable is named in the opening paragraph as where the agent's server URL
comes from, but nothing says to go set it once the proxy exists. That is the
gap operators fall into: the proxy comes up, the Agents panel still shows a
placeholder because the app has no canonical address, and they substitute the
app's own LAN address - which the agent then dials on 443, where nothing is
listening.

Adds a short step at the end of the Reverse proxy section with the health-check
curl to run before enrolling.
Publishing incrementally over a warm tree - no compilable change since the last
build - recreates package content folders such as LatoFont EMPTY rather than
leaving them alone. WiX then harvests the empty folder and packages an MSI that
is missing files, with no warning and a build that reports success. The v2.5.3
MSI lost its 19 Lato font files this way, and the only reason it was caught was
comparing the artifact size against the published asset.

The release flow does not normally hit this, since the MSI is built once right
after a merge that changed code. It does hit a rebuild after a failed upload,
and a docs-only or template-only release, where nothing needs recompiling.

Removing the publish folder first forces the publish target to repopulate it.
The build output is untouched, so the cost is a file copy rather than a
recompile. Verified against the exact failure condition: a second build on a
warm tree now produces a byte-identical file table to a known-good MSI.
Nothing invokes the standalone cfspeedtest binary any more - uwnspeedtest
superseded it for gateway WAN tests, and the app resolves that one
(UwnClientRunner). There are no references to cfspeedtest in C#, Razor, or the
WiX authoring at all, so every macOS install and update was spending a Go
cross-compile and ~5.5 MB on a binary that is never run.

The src/cfspeedtest module stays: uwnspeedtest imports its speedtest package
(src/uwnspeedtest/go.mod), and both Dockerfiles copy it for that reason. Only
the standalone binary build is dropped, and it is commented rather than deleted
in case it is wanted again.
The DownloadTraefik target only fired when traefik.exe or a template was
missing, and the script then skipped any template that already existed. Both
gates were presence checks, never freshness checks, so the first MSI build on a
machine staged the templates permanently. This build box shipped five-month-old
templates that way, missing the multi-site agent tunnel route the companion repo
had added in the meantime, and nothing surfaced it.

Templates now re-download every build. They are a few KB and track
Ozark-Connect/NetworkOptimizer-Proxy, so they are the part that drifts. The
pinned 170 MB binary is skipped when already staged, which it previously was
not once the target fired, so a build costs less network than before rather than
more.

Each template downloads to a temp file and is moved into place only on success,
so a partial fetch cannot truncate a good staged copy. A failed fetch with a
copy already staged warns and continues, keeping offline builds working; with
nothing staged it stays fatal.

Verified end to end: the target fires during the WiX build, skips the binary,
re-fetches both templates, and the resulting MSI is byte-identical in file table
to the published v2.5.3 artifact.
The coverage flag cached with a one-minute expiry, and the synchronous reader
answered false on a miss while it refilled. Every expiry therefore opened a
window in which a covered site read as uncovered: probes resolved to the local
executor, device routing dialed direct instead of through the tunnel, and a
console connecting in that window connected direct and stayed there.

Harmless on a server sitting on the network it monitors. On the off-site server
this feature exists for, it means probing from the wrong network and writing the
result as that site's, plus dialing the site's RFC1918 addresses on the hosting
provider's network.

Every writer already calls Invalidate, so the expiry bought nothing. The cache
now holds until invalidated and is warmed for all sites at startup, which closes
the window rather than shortening it. What it gives up is noticing a value
changed in the database behind the app's back - an operator editing SQLite by
hand - and a restart settles that.
The cached flag no longer expires, so it now outlives the site that set it. A
slug deleted and re-created would inherit the previous site's coverage answer
until the next restart - the same shape as the per-site registries that already
get swept, and previously hidden by the one-minute expiry.

SiteAgentCoverage joins that sweep, which runs on removal and on creation.
Nothing to tear down, so it evicts and returns null.
The handler returned silently when the WAN interface list was empty, so with the
console unreachable the button did nothing at all - no error, no state change.
The list comes from the console, and this is the one control on the page that
depends on it, so it is also the one that has to explain itself. Run Test from
Agent does not need the list, which is why it behaved.

It distinguishes waiting for the site's agent from the console simply being
unreachable, because those need different things from the reader.
…g there

One question gated both, and they are not the same question.

SNMP reads the device's own counters, identical whoever asks, so the server
carrying on while the agent is offline is a real fallback - the site keeps its
device history instead of going dark.

A probe measures the path FROM whoever runs it. The server running one for a
site its agent covers describes the server's route, not the site's, and stores
it under the site's name regardless. On an off-site server that is a different
network. The upstream tracer had the same shape, falling back to the local
executor and tracing from the wrong place.

Probing and tracing now stand down on the configuration alone and let the probe
fail while the agent is away: a gap is honest, a number from the wrong vantage
is not, and mixing the two is what put the sawtooth in the charts.

SNMP call sites are unchanged.
Switching agent collection on reconnected the console immediately, and that
reconnect read the flag through the synchronous reader - which answers false
while an invalidated entry refills. So the console reconnected on the direct
path and showed no waiting-for-agent banner, on a site that had just been handed
to its agent.

Probing was unaffected, which is what made it look inconsistent: the latency
tier reads the flag on its own cadence, by which time the background refill had
landed, so it stood down correctly. Same flag, different timing, different
answer.

The writer knows the value it just stored, so it records it. Invalidate stays
for callers that only know the value changed - site eviction.
Clearing them on every site strands a secondary one. That site is reached ONLY
through an agent, so the flags describe its sole access path rather than an
option it took - and the replacement agent does not restore them, because the
setup wizard writes them only when its proxy checkbox is ticked and that
defaults off. Swap a secondary site's agent and it would sit on direct routing
it cannot use, with generic connection failures rather than the waiting-for-the
-agent messages, which need the flags set in order to fire.

The main site keeps the clearing: direct access is a real fallback there, which
is what made removing the flags right in the first place.

Never released - the clearing landed after the v2.5.3 tag.
Both comments described the old behavior, where the gate asked whether an agent
was connected. It asks about configuration now, so a secondary site offers Add
Target and Discover before any agent exists and the probe fails with a reason.

That is the intent rather than an oversight, so it is written down: a control
that explains itself beats one that is silently absent, and targets added now
are seeded ready for the agent that arrives later. Comment only.
The Site ID hint slugged the typed name inline, which ignores what is already
taken - a name colliding with an existing site, or with the reserved 'main'
slug, promised an ID the site would not get. PreviewSlugAsync already answers
this (it runs the same generator creation does, suffix and all) and had no
caller; the hint now asks it.
…ont of it (#1096)

IAuditQueryService carried no gate at all. The surface was covered in practice - the
export endpoints have RequireAuthorization(RequireAdmin) and the Audit Log tab sits
behind an AuthorizeView on the main site - but by the endpoint and the page rather
than by the service, which is the arrangement [MutatingService] exists to replace.
Any new caller reaching this interface, a component on another page or a background
job or a future endpoint, would have inherited nothing.

That matters more here than for most reads. The log is the record of who did what
across the whole install: actors, source addresses, target names, and since the site
stamping work, the site each action touched. Reading it is closer to reading a
credential store than a status page.

Every member gets [RequireRole(Roles.Admin)] and the registration moves to
AddMutatingService so the implementation is not resolvable ungated. No
[AuditAction]: recording every read would write an entry for each page and each
page-turn of the log itself, burying the actions the log is kept for.

Both callers keep working. CallerContextMiddleware populates the caller for the
export endpoints and CallerContextCircuitHandler does it for the interactive page,
so the proxy has a principal on either path.

Tests cover Admin allowed, Viewer and Operator refused, both exports refused
separately from the page read - they leave as files through their own endpoint, so a
gate covering only the interactive read would have missed the larger disclosure -
and a reflection check that the attributes stay on the interface.
Both endpoints answer with Content-Disposition: attachment, so nothing was ever
going to render in the new tab - it opened, received a download, and stayed
behind empty. A same-tab link with the download attribute gets the file without
navigating away from the log.
Monitors every WAN a site has, not just the primary one. 189 commits squashed.

A WAN vantage names the WAN it measures and how its probes get there: bound to a
source IP the gateway policy-routes out that WAN, or run by an agent sitting
behind it. An agent on the gateway can bind probes to the WAN's own interface,
so no policy-based route is needed there at all. Latency points carry a wan tag,
upstream discovery runs per WAN, and ISP Health grades each WAN against its own
counters, plan speeds, targets and hops instead of pairing one WAN's traffic
with another's expectations.

WHERE IT SHOWS UP

Network Performance gains a vantage card and a WAN filter on Latency & Packet
Loss: one WAN alone, several compared, or all. Shared hosts probed from every
WAN line up side by side, one color per host and one line pattern per WAN.
Latency Targets and Flaky Monitoring Targets both name the WAN a target belongs
to. Upstream Path Discovery runs per WAN and opens on the one selected. Live
View takes its own WAN selector with its own saved selection, so watching one
WAN never moves the analysis views. Network Tools can run a probe from any
vantage, which is how a policy-based route or interface bind gets confirmed.

Live and analysis link both ways, carrying the moment, the WAN and the category,
without either view's saved filter being written to.

ISP HEALTH SCORING

Several changes here are fleet-wide and affect single-WAN sites too.

Loaded latency no longer answers "was any sample high", which one
ICMP-deprioritized responder answers on its own. Samples are collapsed by
instant across every non-LAN target on the WAN, since a queue on the access link
sits in front of all of them. Magnitude comes from the targets that saw it and
only the credence scales with the cohort, so the figure does not fall as more
targets are monitored.

A line that stays clean under load now reads as clean rather than as an absence
of evidence, and a run of clean load episodes after elevated ones is read as the
line having been fixed. Evidence is weighed by recency, by how loaded, and by
how sustained.

A WAN speed test can raise the figure where it read higher and reached 70% of
plan in that direction, since the probes sample on their own cadence and a short
event's peak queue can build and drain between two of them unseen.

Off-path access hops stop feeding the packet loss pool, unless they are all a
site has. Target edits and upstream discovery invalidate every WAN's cached
report, not just the primary's. A report computed before the agent's console was
up is dropped, since SNMP arrives through that console and is what classifies
load.

STARLINK

Satellite is inferred from the access ASN, satellite hops are kept out of the
candidate set, and the dish's fixed LAN-side MAC resolves to Starlink.

Latency bands are set from measured dishes rather than estimated. Idle: 23 ms is
the best the medium does at all and scores full, 42 ms is where a healthy Backup
dish sits and scores 80. Loaded: 3 ms excellent, 12 ms acceptable, replacing a
25 ms ceiling that sat above the 95th percentile of real delta and could not
fail anything. Loaded loss is unchanged.

A dish reporting a reduced-speed plan tier is graded on whether it carries
usable traffic rather than on ratio, whatever plan is configured. Latency is
deliberately not tier-aware: a cheaper plan really is worse latency, and hiding
that would never tell anyone the tier is the reason.

METERED WANS

Continuous probing costs about 5.4 GB a month at 25 targets on the 10s default,
which is most of a small satellite or cellular plan. Satellite, cellular and
fixed wireless each cost a rung, and a configured Data Usage cap costs another.
A rung means fewer targets and a slower cadence, never smaller packets. The
allowance rotates across access hops, transit and path endpoints so a site is
not left with detail on its own first mile and no way to tell whether anything
it reaches is up.

SINGLE-WAN SITES

Nothing changes. With no vantages and one WAN, none of the selectors render, the
schema additions stay null, latency points carry no wan tag, and the scoped
queries resolve to the same inputs as before, pinned by equivalence tests.

AGENTS

AgentProtocol gained an optional supports_source_bind capability and traceroute
binding compiles into the agent, so test agents need new binaries. Older agents
keep working: the capability reads as "did not say" and interface binding is not
offered for them.

ROLLBACK

Additive nullable columns only, and the data migrations are one-way by design.
An older build ignores the new columns, reads the normalized keys correctly, and
loses multi-WAN behavior rather than data.

STILL TO BUILD FOR THIS RELEASE

Per-WAN outage alerting. Alerting still fires per target, so a secondary WAN
going down announces itself as a handful of "target is down" alerts rather than
one outage on that WAN. The design is settled and intended to ship with this
work, not after it: without it a multi-WAN site gets more noise from a WAN
failure than a single-WAN site did.
…y turned on (#1100)

* Config Optimizer: catch Smart Queues that UniFi Network never actually turned on

UniFi Network regularly accepts the Smart Queues toggle on a WAN without provisioning
the queues. The setting reads as on, no shaper is ever created, and the connection runs
unshaped with nothing on screen to say so - the user just sees a lopsided upload. Until
now we only told anyone about it at the moment an Adaptive SQM deploy failed on the
missing IFB device, so nobody who wasn't deploying Adaptive SQM ever heard it.

Config Optimizer now finds it on its own. For every WAN with Smart Queues enabled it
reads the gateway's traffic control over SSH and raises a Performance Suggestion when
the htb root class isn't there, prescribing the QoS-rule workaround that un-wedges it.

Interface resolution is the controller's, unchanged: the WAN's uplink_ifname - eth6
plain, eth6.100 VLAN-tagged, ppp0 for PPPoE - plus its ifb companion, exactly the
devices Adaptive SQM and Monitoring already use. Egress (upload) rides the WAN
interface, ingress (download) rides the ifb, and a WAN shaped in only one direction is
reported naming the direction that isn't.

Everything goes out in one SSH round trip, and the check stays silent whenever it can't
see the answer: no WAN with Smart Queues on (no SSH at all then), gateway SSH off or
without credentials, an agent tunnel that isn't up, a failed command, a truncated
readout, or a WAN interface the gateway doesn't have. A direction UniFi was told to
shape at 0 isn't expected to have a shaper either.

Adds SmartqUpRateMbps to WanInterfaceInfo, which is what tells those two apart.

Closes #1083

* Cover the shaper probe's preconditions

Everything the probe refuses to do is the part that matters: a site with no WAN on
Smart Queues, a gateway with SSH off or no credentials, an agent tunnel that isn't up,
a failed command, and an interface name that has no business on a command line all have
to cost nothing and produce no state. A finding raised from a failed read would accuse
a healthy install.

* Only read the gateway's shapers when a WAN actually has Smart Queues on

Asking which interfaces to read costs a second device-list fetch from the controller,
and an install with Smart Queues off everywhere can never produce the finding that
would pay for it. The network configs this run already fetches say whether any WAN has
it enabled, so waiting on that one small call buys the skip - the rest stays parallel.

* Tour step for the Smart Queues check, and one QoS menu path everywhere

The step only reaches installs it can mean something to: a new "smart-queues" predicate,
which is UniFi's own toggle on some WAN and deliberately not the existing "sqm-enabled"
(that one is our Adaptive SQM, and the whole point of this check is the WAN that has
UniFi's Smart Queues on with nothing of ours deployed). Nothing stores UniFi's toggle
locally so the predicate asks the console, which is affordable only because predicates
resolve for a tour that is actually due, never on an ordinary Dashboard visit.

The anchor sits on the Performance Suggestions checkbox rather than the results card:
the card does not exist until you have run Analyze, so a first-visit tour would
spotlight nothing.

A step whose "requires" names a predicate that does not exist is silently dropped from
every install forever, with nothing in the logs to say a tour lost a step, so the
shipped tour JSON is now checked against the predicates that actually exist.

Also settles the QoS rule menu path, which the app gave three ways: the two Adaptive SQM
and Cellular Data Savings variants now read the same as the new finding.
An AP at a test site restarted after being power cycled and the Device
Status card kept explaining it with a firmware upgrade from days before.

The tracker clears the reason the moment it sees a device on a new boot,
but its record is only as fresh as the last uptime sample it was fed,
while the dashboard reads uptime straight off the console. In the gap
between the restart and the next sample, the card pairs a two-minute
uptime with the previous run's reason.

The reason is now looked up against the boot the device is reporting: a
reported boot later than the record's by more than the tracker's own
match tolerance means a restart it has yet to account for, so nothing is
shown until the new reason resolves. A reported boot that is earlier is
the two uptime sources disagreeing, not news, and still shows. A device
that reports no uptime at all is offline, and keeps its last reason.

The MAC-only accessor is gone with it - it could only ever speak for the
last boot the tracker was told about, which is the bug.
Swap the "Download" / "Upload" words for down / up arrows and shrink the
rate unit at the mobile breakpoint, so the two cards stop wrapping on a
phone. Both copies of the panel (Monitoring - Live View and the Dashboard
card) get the same treatment.
tvancott42 and others added 28 commits August 5, 2026 09:42
Packet Loss and Loaded Loss link to the Network Performance charts, but
they went there unfiltered - so a report read on WAN 2 opened the charts
on whatever WAN the last visit left selected, and the event you clicked
through to look at was not necessarily on screen. Both links now carry
?wan= the same way the Upstream Discovery links and the Live View tiles
already do, which the destination reads as a solo filter.

Single-WAN sites are unaffected: the query stays empty there.
…ss investigation on the selected WAN

GPON upstream high goes 1.0% -> 1.5%. That upstream is 1.244 Gbps shared on
TDMA grants and a gig plan alone is most of it, so contention loss under a
saturating upload is the medium behaving normally. It is also where SQM does
its work: we tell people to enable it, and an AQM controls a queue BY dropping,
so a 1.0% ceiling graded a correctly-shaping line as a fault. An oversubscribed
segment still fails - 3% scores 50, 4.5% scores 32. Downstream, XGS-PON, DOCSIS
and Satellite are untouched. The PPPoE overlay is unchanged and still additive,
so its derived GPON upstream high moves 1.0+1.0 -> 1.5+1.0.

The investigation on Network Performance was pinned to whichever WAN was
focused when it ran: its targets, its plan speeds, its counter interfaces. Move
the WAN filter afterwards and the charts changed WAN while the event on screen
did not, so one WAN's loss sat over another WAN's charts. Every selection path
funnels through ApplyWanSelectionAsync, so the re-run hangs there and covers
pills, All, reset and links alike. A re-scope that finds nothing lets go of the
event rather than keeping the old WAN's; stepping back and forward is unchanged,
where "no newer events" still means stay put.

The deep link had the same problem from the other end: the link-narrowing gate
required a category param, which only the Live View jump sends, so an ISP Health
investigate link naming a WAN was ignored and the investigation ran on whatever
the tab had last. Both the gate and its release now share one predicate.
The investigation fires from the top of OnAfterRenderAsync, but the link's
WAN is applied further down in the chart-mount block - so whichever render
pass happened to flip _monitoringReady decided which WAN got searched, and
it landed on the tab's original WAN about half the time.

It now waits for the WAN scope the link asked for. Delaying the firing by a
render or two costs nothing next to searching the wrong WAN. The wait ends
immediately when there is nothing to wait for: a link with no WAN, a
single-WAN site, or a WAN already applied - so single-WAN installs behave
exactly as before. _wanOptionsLoaded records that the options question has
been ASKED rather than answered, so an unreachable console cannot hold an
investigation forever, and a link naming a WAN the site does not have is
marked processed rather than retried on every render.

The firing itself moved into one method called from both places: the render
loop as before, and again right after the link's WAN is applied, since that
apply does not always raise a render of its own.
The title took the pill form of the WAN label, where the token runs on
directly after the connection's name. A title reads as a phrase, so it now
uses the prose form the helper already provides and the token sits in
parentheses after the name.
Latency & Packet Loss: the eye sits after the controls, which take a full
row on mobile, so it wrapped onto a line of its own while the space beside
the filters went unused. It moves to the card's top right corner instead,
the same absolute-corner treatment the settings link in that header already
gets at this breakpoint. Scoped by the card's id so no other chart header
moves.

Live View: the icon's margin-top auto pushed it to the bottom of a row that
gets tall once a multi-WAN filter is in it, leaving it sitting low against
the pills. Dropped at the mobile breakpoint only, so single-WAN sites - where
the row is short and the current placement is right - are untouched.
Follow-up to the placements landed in 9548d06, with the offsets tuned on a
phone: the eye sits hard into the Latency & Packet Loss card's top right
corner and needs no reserved padding in the header, and the Live View icon
centers in its row rather than aligning to the top of it.
The WAN token repeated on every pill and every stacked row, which is a lot
of "WAN" for a phone-width column that has a number to show. On mobile the
pill token is hidden outright - the connection's name is what identifies it,
and the token is the tie-breaker a narrow screen cannot afford - and the
stacked rows fall back to the index alone, "WAN2" reading as "2" beside its
value. The stacked label and value also step down a size so a three-WAN
stack fits its tile.

Hiding the pill token is not tab-scoped, so it also applies to the Latency
Targets and Upstream Path Discovery pickers, where the same reasoning holds.
Desktop is untouched everywhere.
…he All pill

The play/pause and Historic cluster sits inset from the chart's right edge to
clear the corner the single-WAN chart uses. Comparison mode does not use that
corner, so the cluster moves out to 2.25rem there. It follows the existing
comparing() path rather than introducing a second idea of multi-WAN: the class
is set in syncModeUi, with calls added to both exits of doSetWans since
remountChart rebuilds the chart but not the cluster, which is parented outside
the mount div and survives.

Mobile pins it at 0 in both modes - the compound selector is repeated in the
mobile block so the comparison rule cannot outrank it on specificity.

The All pill also takes flex 0.4 in every WAN filter (Live View, Network
Performance, the Dashboard live card, Latency Targets), so a two-letter button
stops taking the same width as a connection name.
A link's ?wan= kept describing the view long after the user had moved on, so a
reload or the back button re-narrowed to the link's WAN over the choice they
had made. It is now dropped on the same terms as the linked instant.

Two triggers, deliberately not the same one. Taking the timeline drops both
parameters in a single navigation - a second replaceState behind Blazor's
router is what desynchronized the two views of the URL last time. Changing the
WAN filter drops only ?wan=, since choosing which WAN is on screen says nothing
about the instant being viewed; that path still moves the instant's arm to the
new URL, or the navigation would re-arm the seek and pull the timeline back to
the link's moment on every WAN change.

Both tabs are covered, each at its own user-driven point: Network Performance
through ApplyWanSelectionAsync where persist:true means the pick was theirs
(link and restore pass false), and Live View through the WAN change callback.
That callback also fires while the link is being applied, which would be the
clear-on-arrival that cost a navigation per jump and tripped the browser's
throttle, so it is guarded by a flag set around the link's own selection.

Also in this batch, both mobile-only: the All pill's flex is repeated inside
the mobile block so it beats .time-btn's flex 1, and the comparison count in
the WAN rate cards reads "(4)" rather than "(4 WANs)" where the arrow has
already said what the tile is.
…al at 100 ms

Razor treats an @ that follows a word character as a literal - the email-address
heuristic - and it compiles clean either way, so a build says nothing about it.
Two spots were rendering their code as text:

- The WAN rate tiles' comparison count, added in the previous commit, showed
  "Upload@WanRateCount()" on the label.
- The Upstream Discovery link in the access-technology hint, which has been
  emitting its own href as text since well before this branch (it read
  discover=1@DiscoveryWanQuery() until the rename earlier today).

Both take the explicit @(...) form the WAN Throughput title already uses. The
neighbouring @expr@if and @expr@expr shapes are NOT affected: there the @ ends a
code transition rather than following markup text, which two long-standing
instances in Security Audit and the ISP Health profile line demonstrate.

Also in this commit: the live RTT tiles keep two decimals below 100 ms and drop
to one at or above it, which is the resolution worth reading at three digits and
holds the tile width steady as a value crosses a hundred.
…y link

Making that href dynamic in the previous commit changed how Blazor writes it.
Static markup is emitted verbatim, so the hand-written &amp; was decoded exactly
once by the browser and the link worked; as an attribute VALUE it is HTML-encoded
on the way out, so the ampersand was encoded a second time and the DOM held the
literal text "&amp;discover=1". The site-context script then parsed that to append
its site parameter, read a parameter named "amp;discover", and percent-encoded the
semicolon on the way back out - which is the &amp%3Bdiscover=1 seen in the bar.

A plain & in the source is correct here: encoding a dynamic attribute is Blazor's
job, not the markup's.

The other hand-escaped ampersands in the codebase are unaffected because they are
still static - the four discovery tooltips on Monitoring, and the map popup HTML
built in FloorPlanEditor and SpeedTestMap, which is parsed as HTML once when it
is injected.
…l hop

A UniFi gateway reaches an attached 5G/LTE modem over a GRE tunnel, and nothing
else on the gateway presents as gre*, so the interface name says what the medium
is outright. Two things follow from it.

The access technology is set to Cellular rather than left for the L2 neighbor
vendor to guess, which on a cellular WAN it cannot: the neighbor is the modem.
It still only fills an empty slot, so a technology someone chose is never
overwritten.

Hop 1 is dropped from the access hop pool. It is the modem's own tunnel endpoint
- a CGNAT address on our side of the radio that answers every trace and tells us
nothing about the carrier's first mile - so monitoring it measures the tunnel.
Confined to gre* uplinks: everywhere else hop 1 IS the first-mile device, and
carrier-side CGNAT hops beyond this one stay eligible, which is what makes the
existing CGNAT allowance still worth having.

Also adds the RBAC check discussed for WAN vantages. Add, edit, save and delete
already sat inside SiteAdminOnly in the markup; they now re-check the same policy
against the same site where the write happens, so the rule no longer depends on
which wrapper a button is inside. It cannot reject anyone who had a button to
press, and falls open when no authentication is in play at all.
…eachable

Both halves of the previous commit were dead on the WANs they were written for.

The access technology sat beside the L2 vendor inference, which returns early
when no neighbor MAC is found - and a GRE tunnel has no ARP neighbor, so on a
UniFi Cellular Modem WAN that step always returns before reaching it. It now
runs where the WAN is identified, which is where the uplink name is known.

The hop exclusion filtered the candidate pool, but the modem's tunnel endpoint
does not arrive that way. It is CGNAT with no BGP attribution, so it enters
through the positional pass for unannounced first-mile hops, which by design
admits exactly that shape of address. The exclusion moves to where the access
pool is finished, after all three contributors, so no path can bypass it, and
logs what it dropped.

The predicate moves to NetworkUtilities beside IsPppoeInterface, which is the
same idea - a data-path interface name that identifies the connection - and is
named for what it actually detects. gre* means a UniFi Cellular Modem attached
to the gateway and nothing else; a third-party modem, a bridged carrier router
or a dongle is equally cellular and presents as an ordinary Ethernet or PPPoE
WAN, so a false result says nothing about the medium and the comments no longer
imply otherwise.

Verified against sample-device-response.txt, where the cellular WAN reports both
ifname and uplink_ifname as gre1.
A Starlink discovery hung at "Attributing hops to ASNs" with nothing in the log.
Two causes, one on top of the other.

CGNAT was being sent to the whois fallback. RFC 6598 space is shared and not
announced in BGP, so neither the offline database nor bgp.tools can attribute
it - the call only pays a network round trip to learn that, once per distinct
hop, and a Starlink first mile is almost entirely 100.64/10. The address class
check now requires public space, which is what the comment above it already
claimed and what the tracer's WAN IP lookup documents. Private addresses were
already skipped; this puts CGNAT on the same footing.

The whois call also had a deadline on its connect and nothing else. A
rate-limited bgp.tools accepts the connection and then stays silent, so the read
waited on the caller's token with no timeout of its own - holding one of the two
permits indefinitely and blocking every later lookup behind it. Found with both
permits held by sockets sitting at 0 bytes in either direction, which is why the
phase stopped dead rather than slowing down, and why nothing was logged: a hang
never reaches the catch. One 8 second budget now covers connect, write and read
together, and a timeout returns null, which is already what an unattributable
hop looks like.

Also: the Latency Targets card's "N active" badge counts the targets the filter
is showing rather than every target on the site. With All selected, or on a
single-WAN site, the number is what it always was.
…tcome

A whois call left no trace unless it threw, so a run that reached the network
repeatedly, slowly, or not at all looked identical in the log - which is what
made the CGNAT stall read as a silent hang rather than a lookup.

Each fallback now logs the address, how long it took and what came back. The
offline database has already answered everything it can by that point, so every
line is a public address it did not know: none for CGNAT confirms those are
skipped rather than merely fast, and a duration near the ceiling shows the
timeout working instead of a hang.
…ored

Nothing on the page said how to start monitoring another WAN, and the answer -
enroll an agent for it from the site's own Configuration - is not somewhere you
would look without being told. A card now says so, between the enable checkbox
and the Sites table, naming the two buttons it is talking about.

Shown only where it is news: more than one WAN on this site, no vantage
configured for any of them, and the user has not dismissed it. A site that
already has a vantage has been through this, and a site with one WAN has nothing
to act on.

Dismissal is per user, in the same UserUiHints table and service as the WAN
filter hint - what one operator has learned says nothing about their colleagues.
DismissAsync records the show limit rather than adding a second flag, so the
existing ShouldShowAsync rule covers both kinds of hint and no migration is
needed.

The WAN lookup runs only while the Multi-Site tab is on screen, and once per
visit. A console it cannot reach leaves the card hidden rather than guessing,
since telling a single-WAN site about its second WAN is worse than saying
nothing.
… refinements

The card's second condition was wrong. It hid on any vantage existing, but a
vantage with no agent behind it is precisely the state the card exists to get
someone out of: they created one, nothing probes it, and the missing step is the
one the card names. It now asks whether any vantage is agent-bound.

TEMPORARY: that condition is commented out so the card can be seen on a site
whose vantages are already bound. Restore before this ships.

Also, following the CGNAT change earlier: CGNAT reaches the offline database
again and is blocked only from the network call. The GeoLite2 lookup costs
nothing and does sometimes know shared space a carrier has registered, so it is
worth asking; whois is not, because RFC 6598 space is not announced in BGP.

And when bgp.tools returns nothing usable, the first line of its reply is logged.
"No row for this prefix", a notice instead of data, and a format change were
indistinguishable before, which is most of why a rate limit took so long to
identify.
…thout multi-site on

The card used banner-text, which is display:flex column - a component built for a
bold title stacked over a description. Every inline name in the sentence became
its own row. It is a plain alert now, with a small class of its own for the icon,
paragraph and Dismiss button, so emphasis inside the prose reads inline.

It also said the same thing whether or not multi-site was on, naming a
Configuration button and an Add Agent action that do not exist on the page until
it is. The opening sentence is shared; with multi-site off it now leads with the
checkbox directly above it and says why - the agent enrolls through multi-site,
including on this site - and leaves the rest for when those controls appear. The
wording swaps as soon as the box is ticked, since only the branch depends on the
setting, not whether the card is shown.

TEMPORARY, both restored before this ships: the WAN-count and bound-vantage
conditions are commented out, so the card shows on any site. That is what makes
it testable on a single-WAN single-site install.
The bar is shrink-wrapped to its content so a two or three WAN selector does not
stretch the width of the card on a desktop. On a phone that fights the rule right
beside it: the pills are told to share the row, and a max-content parent leaves
them no free space to share, so they stay bunched in a corner of it.

Width goes back to auto at the mobile breakpoint only. The desktop shrink-wrap
and the max-width guard against a long WAN list are both untouched.
…t means

Three columns on a phone left the sentence about two hundred pixels wide and
reading as a narrow ribbon down the card. Icon and text now share the full width
on one row and Dismiss sits under them, right aligned. The desktop row is
unchanged; the icon and text are wrapped together so the button is the only thing
that moves.

The card also said "the site's Configuration" while pointing at a table of
several. It names the current site now, falling back to "this" if the name has
not loaded, so the sentence never has a hole in it.
Enabling multi-site loaded the site list but never set the current site's name,
which only the init path did and only when multi-site was already on. So
everything that names the site stayed blank until the page was left and returned
to: the header's site badge, and the WAN vantage card, which had to fall back to
wording that does not name one.

The card takes a whole phrase now rather than dropping a name into a sentence.
With no name to use, "the <blank> site's" reads as a missing word; "this site's"
is just the weaker of two correct sentences.
Both were commented out to make the card visible for review on sites that would
not otherwise show it. It is back to appearing only where it is news: more than
one WAN on the site, no vantage with an agent bound to it, and not dismissed by
this user.
…mplate

"Open the Main Site site's Configuration" is what a template produces when the
name is itself a noun, and people do name their sites that way. The name now
takes the possessive on its own - "open Main Site's Configuration" - and with no
name loaded the sentence reads "open this site's Configuration".
Two expectations were verbatim strings spanning a source-file newline,
so they inherited the checkout's line endings and failed on a CRLF
working copy while the emitted Flux filter always uses \n. Written
with explicit escapes like their siblings.
…1101)

* Monitoring: per-WAN outage evaluator replacing per-target alerts for WAN categories

One access-layer outage used to page once per target. The WAN-facing
categories (AccessIsp, Transit, InternetService, legacy Wan) now feed a
per-WAN state machine that publishes monitoring.wan_outage /
monitoring.wan_outage_partial / monitoring.wan_recovered - one open
alert per (WAN, kind), partial superseded by total, site-level rollup
when every WAN of a multi-WAN site drops in the same evaluation.
Verdicts are classified from the per-target offline states against the
persisted trace map, inheriting OutageDetector's attribution rules
(AttributeBreak and the ASN independence key are now internal for
that). Fabric and Custom targets keep their per-target alerts, and the
per-target state machine keeps running underneath for every type.

* Alerts: seed the WAN outage rules, and make rule seeding one-time per database

Three enabled-by-default rules for the new event family:
monitoring.wan_outage (Warning floor), monitoring.wan_outage_partial
(Info floor, so a non-primary WAN's partial still matches) and
monitoring.wan_recovered. Seeding now records every seeded pattern in a
new SeededAlertRules table and only inserts patterns never recorded, so
a rule the user deletes stays deleted across restarts instead of
resurrecting on every boot; existing installs are backfilled from their
current rules on first start.

* Alerts: WAN outage alerts close themselves on recovery and supersede

The wan_* family is an open/close family, unlike the rest of the
catalog: monitoring.wan_recovered resolves the WAN's open outage and
partial alerts (and any all-wans rollup, whose premise a single
recovery invalidates), and a confirmed total outage resolves the
partial it grew out of, so the two never stack in Active Alerts.
Resolution runs before rule matching so it works even when the
recovery rule is disabled, and re-derives incident status through a
shared AlertCorrelationService helper the UI resolve path now also
uses. Adds the three event types to the Rules tab pattern picker.

* Monitoring: WAN outage evaluator tests, plus three review fixes

Classifier and state-machine coverage: one WAN alert per access outage
with per-target events silenced for the WAN categories, partial vs
total classification, supersede, recovery, rollup open and staggered
release, non-primary severity, flap and staleness guards, and Fabric /
Custom left exactly as they were.

Review fixes the tests surfaced: releasing the rollup no longer
reopens an alert for a WAN whose verdict already cleared in the same
pass (it used to publish a zero-target partial and an instant
recovery); an upstream total now requires a failing internet
destination, so an access hop plus rate-limited transit hops alone can
never read as the internet being down; partial bodies say 'failing or
degraded' since sustained loss also counts toward them.

* Monitoring - ISP Health: point a WAN with no scheduled speed tests at the schedule

The collecting-data banner now checks whether the selected WAN is
covered by an enabled scheduled WAN speed test (gateway schedules by
their wanGroup in TargetConfig, server-vantage schedules count for the
primary) and, when it is not, adds a nudge plus the same Set up
schedule link the WAN Speed Test banner uses, so throughput history
builds while ISP Health waits on latency history.

* Monitoring: cut WAN outage alerting to ~30 s, and grade load-balanced WANs by impact

Timing: a target counts as failing for WAN verdicts after two failed
probes, passes run at 10 s, and two held passes open. No WAN verdict
ever rests on one target - a total needs the whole cohort failing at
once - so the cohort agreement is the flap suppression the per-target
3-strikes rule was providing. Closing still takes three passes, so a
flapping WAN gives one alert and one recovery. The per-target machine's
own thresholds, which Fabric and Custom alerts use, are untouched.

Severity now turns on whether the WAN carries traffic rather than on
the primary role alone: under load balancing every WAN carries live
sessions, so a backup going dark is a real service loss and grades
Critical. An idle failover backup still grades Warning, unchanged.

* Monitoring: drop the reassuring clause from partial outage alerts

A partial is often a total still arriving, with the remaining targets a
probe cycle behind, so telling the reader the connection itself looks
fine can be contradicted a minute later. State the evidence only.

* Monitoring: let the site rollup fire across a window, superseding per-WAN alerts

Requiring every WAN to confirm its outage in the same evaluation made
the rollup near-unreachable: WANs are polled at their own intervals, so
a site that loses everything at once still confirms a minute apart. Each
WAN now records when its total outage was confirmed and the rollup fires
while those confirmations sit within 90 s of each other, folding in any
WAN that already opened its own alert - the rollup event resolves the
per-WAN alerts it supersedes, so the worst case is one alert then the
rollup rather than one per WAN.

* Monitoring: stop live ISP and Transit tiles reading off stale probe results

A target that stops reporting kept presenting the reading it carried
before it went quiet, so the tiles read healthy through an outage.
Readings now expire after 90 s, half again the slowest poll interval a
target can be given.

The access ISP tile also read its loss off the nearest hop by RTT, and
a hop with no RTT could never become the nearest - so with every hop
dark it kept naming the one stale hop that still had an RTT, and
reported that hop's 0% loss during a total outage. With nothing
answering there is no nearest hop, and the mean of what did report is
what it shows. Observed on a WAN whose ISP targets went quiet under a
blackhole while its transit targets kept reporting 100%.

The live WAN chart's mean now returns null rather than 0 when nothing
fresh reported, and the chart plots a gap instead of a healthy zero.

* Monitoring: carry the WAN selection when the live tiles navigate

The ISP and Transit tiles are shared between the Monitoring page and the
Dashboard. On Monitoring they jump within the page, so the WAN filter
survives by staying put; from the Dashboard the same tiles navigate, and
the link carried no WAN - so clicking one WAN's tile opened Latency and
Packet Loss showing every WAN. They now build the link the way
Monitoring's own jump does, selection and all.

* Monitoring: move the ?wan= link building onto LiveWanScope

The Live View tiles and the Monitoring page's own jump were building the
same fragment two ways, and the tiles' copy arrived a commit ago. The
scope owns the selection, so it owns how a link carries it: QueryValue
for the set, QueryFragment for the fragment, with an override for a link
that means every WAN rather than the current focus (the LAN jump).
Behavior is unchanged on both call sites.

* Monitoring: land a deep link on its WAN every time, and stop releasing it snapping the view back

Two faults in how a link's ?wan= and the stored WAN filter took turns.

Network Performance applied the link from a block that runs once per
chart mount, and gave up when the WAN options had not loaded yet - so
whether the link took came down to which finished first, and on the
losing pass the stored filter, read a line earlier, stood in its place.
The link is now held until the options and the multi-WAN answer arrive,
and applied from whichever lands last.

Releasing a link also reset the restore, so the stored set was read over
the top of the current selection. The URL drops its ?wan= on ordinary
interactions - changing the time filter, resuming Live - which yanked
the view off the WAN being watched, mid-look. Releasing now clears the
link claim only; the selection on screen stays until someone changes it,
and storage is read again on the next load.

* Agent relay: stop a batch vanishing without a word

Three ways a relayed batch could be lost silently, all of them now
either impossible or logged.

Ownership is tri-state. AgentOwnsAnyContextAsync answered false when it
could not read the site contexts at all, and false there drops the whole
batch - the same answer as a definite "owns nothing". Only a definite
false drops it now; an unreadable answer falls through to the per-result
judgement, which asks the same question per target.

The drop itself is logged. It was a bare return, so a main-site agent
that owns no context lost every batch with nothing in the log to say so.

An unconfigured InfluxDB client is logged too: the latency writes no-op
on one, so the batch would be walked and stored nowhere. The batch still
runs, since the live caches and alerting do not depend on Influx.

* Monitoring: keep latency and loss on the WAN live chart across a throughput hole

Chart rows were built from the throughput series alone, so a span with
no throughput point produced no row - and dropped that span's latency
and loss with it. The gateway's SNMP counters are collected by whoever
collects for the site, and a site that leaves collection to the server
has nothing buffering them, so a server restart leaves a real hole in
throughput while the agent's probe results replay into it perfectly. The
chart drew the hole across every series and the backfilled latency and
loss were invisible.

Latency points landing in such a hole now get a row of their own with no
throughput on it. Only in a hole: a point with throughput either side
within a couple of sample intervals still rides that throughput point,
so ordinary operation keeps exactly the rows it had and the throughput
line does not turn dotted. Both series were already in hand, so this
adds no query.

Also logs two silent drops in the agent relay: the batch dropped when a
main-site agent owns no WAN context (a bare return until now), and a
batch arriving while the site's InfluxDB client is unconfigured, whose
writes no-op. Neither changes what is processed.

* Alerts: re-derive each incident once per Resolve All, not once per alert

Resolve All recalculated the incident for every alert it resolved, and
each recalculation re-reads the incident plus every alert on it - so a
list where twenty alerts shared one incident did that work twenty times,
each read and write its own SQLite transaction. That is what made the
button crawl. The alerts are written as before; the incidents they
belong to are re-derived once each, afterwards.

Also raises ISP Health MinDataHours to 5 TEMPORARILY, to bring the
collecting-data banner back on a WAN that just crossed the real
threshold so the schedule nudge can be checked. Revert to 4 after.

* ISP Health: raise the temporary MinDataHours to 9

More history has accumulated since the 5 went in, so the banner needs a
higher bar to come back. Still temporary - revert to 4 once the schedule
nudge has been checked.

* Alerts - Active Alerts: even out the per-alert buttons

Acknowledge takes the tertiary style, and on desktop the three buttons
share a 6.75rem minimum so View, Acknowledge and Resolve line up as a
column instead of stepping in and out with their label lengths. Mobile
lays them in a row and is left as it was.

* Monitoring - ISP Health: only offer the schedule nudge to those who can act on it

Creating a scheduled speed test is a site administrator's to do - Alerts
& Schedule gates the whole section on it - so anyone else was being sent
to a page that would not let them. Non-administrators keep the plain
collecting-data line; the extra sentence and the Set up schedule button
appear only for an administrator on a WAN that has no schedule.

* Alerts: name the Acknowledged section's button for what it resolves

It said Resolve All and took the whole unresolved list, acknowledged or
not. Now it says Resolve Acknowledged and takes that section's alerts.
The Active section's Resolve All still clears everything unresolved, so
acknowledging the lot and resolving in one press is unchanged - which is
what the wider scope here was for before the button said which alerts it
meant.

* ISP Health: put MinDataHours back to 4

The temporary raise had done its job: the collecting-data banner and its
schedule nudge were checked on a WAN with more history than the real
threshold needs.

* Alerts: write bulk acknowledge and resolve in one round trip

Every bulk button walked its alerts one at a time, and each of those is
an UpdateAlertAsync - a SaveChanges, so a SQLite commit - against a
change tracker that grew by an entity per iteration. A few hundred
alerts meant a few hundred commits and a quadratic walk of the tracker,
which is why the buttons locked up on a fast machine.

SetAlertStatusAsync loads the rows once, stamps them, and saves once.
Used by Acknowledge All and Resolve All in Active Alerts, Resolve
Acknowledged, and both Acknowledge All and Resolve All under Incidents,
which gather their incidents alerts first and write them together.
Incident status is still re-derived once per affected incident.

* Alerts: re-derive the incidents in one pass, and hold the bulk buttons while they work

Batching the alert writes left the incident pass as the cost: a few
hundred alerts usually means nearly as many incidents, since most hold
one alert, and each was re-derived with two reads and a commit of its
own. They are now read together, derived in memory, and saved once.

All five bulk buttons also disable and show a spinner while one runs.
They act on the same lists, so a second press mid-flight would work from
a set already being written - and until now nothing on screen said the
first press had been heard.

* Alerts - Incidents: batch the reads and writes those buttons actually make

The incident buttons were left doing per-incident work: a read of each
incident's alerts, then a save of each incident, so four hundred
incidents meant four hundred reads and as many commits. They now read
every incident's alerts in one query and save the incidents together.

UpdateIncidentsAsync also had to stop assuming its incidents were
tracked. They come from the page, which reads them detached, so the
single save it did would have persisted nothing; it attaches each one
first, reading the tracked set once rather than scanning it per
incident.

* Alerts: poll the alert and incident lists without moving them under the reader

The refresh timer reloaded Active Alerts straight into the bound list
every thirty seconds. Entries arrive newest-first, so anything new
pushed the list down - past the row being read, or out from under the
button about to be pressed - and there was no sign it had happened.

The poll now reads into a holding buffer and offers what it found as a
pill. A read with nothing new applies itself, so acknowledgements and
removals still keep up on their own; only genuinely new entries wait to
be asked for. It skips entirely while a bulk action, a modal or an
inline edit is open, and the Incidents tab is polled the same way rather
than not at all. Rows carry their id as a key so the diff keeps the DOM
it already has.

* Alerts - History: page it in SQL instead of showing the newest slice

History took the newest 200 rows and stopped, with nothing on screen to
say the rest existed - a site running since February has thousands. It
now reads a page at a time with the total the filters match, so the
pager can say where you are, and a filter change starts again from the
first page rather than leaving you on a page that no longer exists.

Uses the pagination classes the client dashboard already uses; the only
new rule is the pill the alert poll offers, which had nothing to borrow.

* Alerts: never hold a poll's findings out of reach

Two faults in the pill, both showing up on an empty list. It rendered
inside the non-empty branch, so with nothing on screen it could not be
reached at all; and the poll withheld new entries even then, though an
empty list has nothing to push down. Together that left the tab reading
All Clear with the alerts parked in a buffer and no way to ask for them.

An empty list now takes the read straight away, and both pills sit
outside the empty/non-empty split so held entries are always offered.

* Alerts - Incidents: ask the database for unresolved incidents

The tab read the newest fifty incidents whatever their status and
filtered to unresolved afterwards, so an unresolved incident disappeared
the moment fifty newer ones had been resolved. On the test site that was
every one of them: twenty-two sat unresolved from February while the
fifty-row window reached back only to August, and the tab read empty.

Filtered in SQL now, so the limit applies to what the tab actually
shows.

* Alerts API: note the incidents endpoint's status-filter trap

It takes the newest incidents whatever their status, so a caller after
the unresolved ones loses them once that many newer ones are resolved -
what the Incidents tab was suffering. Left as-is rather than changing
what the resource returns by default; the TODO says what it needs.

* Alerts - History: give the pager room below it

Scoped to this one rather than the shared pagination class, which the
client dashboard also uses between sections where it does not need it.

* Alerts: send monitoring alerts to the moment and the WAN they are about

Every monitoring alert linked to /monitoring?tab=performance and nothing
more, so following one landed on the tab's default view of now - by
which time the window that shows what happened has usually scrolled off
the live view, and on a multi-WAN site it may not even be the right
WAN's chart.

The link now carries the category, the instant and the WAN, which the
analysis page already reads. Per-target alerts use their own target's
category and WAN; a WAN outage opens on the access hops at the moment
the outage started, a partial on the destinations; a recovery opens at
the START of the episode that just ended rather than at the recovery;
and the all-WANs rollup spans every WAN.

* Monitoring: LAN alerts ask for every WAN, like the page's own LAN jump

A Fabric target is not reached over any one WAN, so its alert link
carried no WAN at all and the analysis opened narrowed to whatever the
stored filter was. It now asks for all of them, which is the choice
Monitoring's own LAN jump already makes.

* Alerts: count the poll's held entries against a set

Both pending counts are read on every render and compared each held
entry against the whole visible list, which is quadratic in a list that
reaches the hundreds - the shape the bulk buttons were just cured of.
The poll's own is-anything-new check did the same.
* Starlink: alert on what the dish reports about itself

We collect a great deal from the dish and alert on none of it. Every other
physical source has alerting - monitoring.sfp_rx_power, cellular.signal_poor -
while Starlink, which reports more about itself than any of them, is silent. A
dish can be obstructed, thermally shut down, deprioritized, negotiated at 100
Mbps on a gigabit service, or knocked out of alignment, and the product says
nothing.

Six new event types plus a closer, all starlink.*, hanging off the dish poll
rather than off MonitoringAlertEvaluator: Starlink is usually a backup WAN with
no vantage, no agent and no monitored targets, so the dish is the only sensor on
that link and these have to fire for a WAN nothing else watches. There is
deliberately no correlation with per-WAN outage alerting - both firing is
correct, they carry different evidence. Severity does not follow the per-WAN
outage table either: a degraded backup is silent by construction and is
discovered at the moment it is needed, so backup dish problems keep real
severity.

- starlink.dish_alert - the dish's own verdict: its alert codes verbatim, a
  self-test that has started failing, and a disablement code other than Okay.
  Warning, Critical when out of service.
- starlink.obstructed - a sustained obstruction fraction, or the dish's own
  persistently-low-SNR flag. Warning, Critical past the critical bar. One type
  because the remedy is the same either way.
- starlink.alignment_drift - the current hourly median departing from the dish's
  own 7 day baseline by more than 2 degrees for 30 minutes. Warning.
- starlink.eth_speed_degraded - a link negotiated below what this dish has been
  seen to reach. Warning.
- starlink.outage_burst - the dish's own outage seconds over a rolling day, with
  the most recent cause. Warning.
- starlink.service_restricted - the TRANSITION into a rate limit, never the
  standing state. Info.
- starlink.recovered - closes the one condition it names, so a dish that clears
  its obstruction keeps whatever else it still has open.

Every rule is written against a value, never against "the field is set". On the
reference dish disablement_code reads Okay, alerts carries install_pending
continuously, hardware_self_test reads Failed continuously, both restriction
reasons are permanently populated, mobility_class reads Mobile on a bolted-down
dish, and the boresight sits a steady 3.69 degrees off desired - all while
nothing is wrong. Any "has a value" test would fire on day one and never stop,
so the tests that matter most here are the ones asserting silence.

Alignment is judged against the dish's own baseline for the same reason, and is
gated on attitude uncertainty but explicitly NOT on mobility class - that gate
would have silenced the alert on exactly the installation it was written for.

WAN association is best effort: where exactly one Starlink WAN and one dish are
configured the alerts carry the usual GatewayWanHelper label, otherwise they
name the dish and fire regardless.

* Starlink: calibrate the alerting constants against the reference dish

Measured 30 days of the reference dish rather than reasoning about it, which
caught a rule that would have shipped dead.

The attitude-uncertainty gate on alignment drift was set at 1 degree on the
intuition that uncertainty near the 2 degree trigger makes drift and confusion
indistinguishable. A healthy dish is nowhere near that certain of its attitude:
p50 0.70, p95 1.49, p99 1.83, max 2.71 degrees over 30 days. The bar sat inside
the healthy distribution, so roughly a third of ordinary polls were gated out,
and because a gated poll stalls the sustain the alert could never hold its 30
minute window. Raised to 4 degrees, about 1.5x the observed maximum, with the
distribution recorded on the constant and a theory pinning the gate open across
the real healthy range.

The gate is worth keeping: mean uncertainty rises monotonically with how far
the offset has strayed from its median (0.77 / 0.90 / 1.10 / 1.19 across
deviation bands), so the excursions it must not mistake for movement do come
with the dish saying it is less sure.

Everything else measured out as the spec described, and the numbers are now on
the constants they justify:

- alerts: install_pending and nothing else in 30 days, which is real evidence
  that the motor and mast codes are not always-on for a fixed-tilt motorless
  install and belong outside the benign list. The comment no longer claims the
  other four entries were measured; they are judged on what the code means.
- hardware_self_test Failed throughout, disablement_code Okay throughout, both
  restriction reasons constant, eth_speed_mbps a clean constant 1000.
- fraction_obstructed median 0.06%, never above 0.1%, against a 2% bar.
- outage seconds 1 to 31 a day, median 13, against a 300 second bar.
- ~730 samples a day, so the hourly alignment median holds about thirty.

Also: the WAN binding cache is now dropped when a dish is added, enabled or
deleted, instead of ageing out over 30 minutes and labelling a second dish with
the first one's WAN.

Alignment drift copy now names one yardstick rather than switching between the
dish's baseline and its ideal aim mid-sentence.

* Starlink: close the review findings on the alerting rules

Four fixes from reviewing the branch.

A superseded alert could be closed and its replacement then dropped. These
types keep one open alert per (dish, condition) by having a re-publish
supersede its own predecessor, and AlertProcessingService resolves the old row
BEFORE rules are consulted - while cooldown keys are per (site, rule, device),
so the replacement shares the key of the alert it just closed. An obstruction
escalating Warning to Critical inside the hour resolved the Warning and had the
Critical suppressed, leaving a critically obstructed dish with no open alert at
all. The WAN outage family never had this because a total supersedes a PARTIAL,
which is a different rule and a different key.

Every Starlink rule now ships with no cooldown. It is redundant as well as
harmful: the evaluator publishes on state changes only, and is where the real
throttling lives - sustain windows and hysteresis on the gated conditions, "new
evidence only" on the dish's own codes, edge-triggering on restriction. A test
pins the invariant so nobody restores a cooldown without reading why.

That also fixes recoveries quietly going missing. starlink.recovered is one
rule closing six different conditions, all sharing the dish's device id, so any
cooldown swallowed the second recovery whenever two cleared together. The alert
still closed - resolution runs ahead of rule evaluation - but nobody was told.

Per-dish evaluator state is now serialized. The timer poll is single-flighted
against itself, but the Starlink Stats panel calls PollStarlinkAsync directly
(Refresh, moving between terminals, first paint on an empty cache) with no such
guard, so two polls of the same dish can be in flight together. The sample
windows are plain Queues, whose concurrent Enqueue/Dequeue corrupts their
internal indices rather than merely racing.

An obstruction alert raised purely on persistently low SNR no longer quotes the
obstruction fraction, which is healthy in that case, against the poor
obstruction threshold.

* Starlink: make the alerting observable on a dish with nothing wrong

A healthy dish publishes no alerts, which is correct and completely useless for
telling a working evaluator from a broken one - both are silent. Two Debug
lines close that.

Per poll, after the checks so the open set is current: the WAN the dish was
bound to, the obstruction fraction and SNR flag, the alignment median against
its baseline with the sample count and whether attitude uncertainty gated it,
negotiated against capable Ethernet speed, outage seconds over the rolling day,
the restriction state, and which conditions are currently open. Built only when
Debug is enabled, since it medians the alignment window and sums the outage
window to produce it.

Per baseline refresh: what came back from Influx and how many points it rests
on. A null median there is the difference between alignment drift watching and
alignment drift being switched off, and the two are otherwise indistinguishable
from outside.

* Starlink: stop saying Starlink twice where the wording already says it

Dishes and Starlink WANs get named "Starlink Roof", "Roof Starlink", or just
"Starlink", so anywhere our own wording opens with the word, the name doubles
it: "Starlink has taken Starlink Roof out of service", and a diagnostic line
reading "Starlink Starlink Roof alerting".

The name is trimmed at both ends, because our templates put the word in front
and the two arrangements double up identically. A name that is nothing but
"Starlink" strips to nothing and falls back to a generic noun rather than
leaving a hole in the sentence. The word is left alone anywhere else in a name -
"My Starlink Dish" stays whole, since cutting from the middle mangles names
more often than it tidies them.

Only the sentences and log templates that carry the word themselves are
trimmed. Alert TITLES keep the full name: a title is often all that reaches a
notification channel, and dropping the service from it would lose what the
alert is about.
Six steps covering Multi-WAN monitoring, the Live View / Latency & Packet
Loss jump buttons, WAN outage alerts, Starlink alerts, and the Smart Queues
(SQM) check.

Engine additions the tour needed:

- multi-wan and starlink predicates on TourPredicateResolver. multi-wan
  counts enabled WANs from the console config rather than WanProfiles rows,
  which are an ISP Health side effect and so are both stale-prone and
  incomplete.
- hideFromList, so the second half of a two-stop feature walks the user
  through it without spending a second bullet on a list capped at six.
- matchText, which narrows the spotlight to the row inside the anchor whose
  text matches and scrolls to it. A list whose rows depend on the user's own
  configuration can only carry the anchor on the list itself. Absent text
  falls back to the anchor, so a step never fails over wording it hoped to
  find.

Anchors: the Live View WAN selector, both jump buttons, the Active Alerts
list and the Rules card.
* Device Stats: mark restarts and device alerts on the Device Health charts

The charts showed what a device was doing but never what happened to it, so a
temperature step or a memory reset had to be correlated by hand against the
Alerts page. Every chart on the tab now carries a mark on the time axis for each
restart and each alert raised against a charted device, hoverable for the reason.

Two sources, deliberately not one:

- Restarts come from the reboot records in the long-term Influx bucket, which
  hold one point per boot with the category, the evidence and the firmware. A
  new QueryDeviceRebootsInRangeAsync returns every boot in a window, where the
  existing query collapses to the latest per device.
- Alerts come from the site's own alert history, matched to the charted devices
  on the normalized MAC because DeviceId carries whatever spelling the
  publishing evaluator had.

device.rebooted is dropped from the alert side: it describes the same restart as
the Influx record but without the category, the evidence or the firmware, and it
is only published for boots recent enough to alert on.

Marks filter with the legend badges rather than the fetch, so hiding a device
drops its marks the same way it drops its line. Glyph carries the kind and color
carries the severity, read from the existing CSS variables so they match the
rest of the app in both themes.

* Device Stats: fold colliding event marks instead of smearing them

A device that flapped for a week laid a solid row of glyphs across the 30d view.
Marks closer together than the width of their own label now fold into one.

The threshold is pixels, not a count: a count cap drops data and ignores how much
room the chart actually has, where folding on collision distance leaves 15m fully
expanded, folds 30d hard, and bounds the mark count at plotWidth/24 however many
events land in the window. Nothing is lost - a folded event is still listed in
its cluster's tooltip, which scrolls rather than truncating.

Geometry comes from the chart's own gridWidth and drawn extents rather than the
requested range, measured once off the temperature chart and reused, so the folds
line up vertically across every chart on the tab instead of each one clustering
to its own slightly different answer. The gap is measured from a cluster's first
member so the mark stays where the run started rather than drifting right as it
absorbs more.

Colour and glyph come from the worst event in the fold, so a cluster holding one
kernel panic reads as a panic rather than as the routine restart beside it.

Resize recomputes the folds, debounced: a narrower plot fits fewer marks.

* Device Stats: make the event marks work, and stop one boot reading as nine

Five defects from review, and one found in the data behind them.

The marks were unhoverable. ApexCharts' own stylesheet puts pointer-events: none
on every annotation label, so the tooltips could never fire - the feature looked
like it worked and did nothing. The label now carries our own class, which the
library concatenates onto its own rather than replacing, so app.css wins on
specificity without !important.

Alert marks landed hours from the event they described. TriggeredAt is written as
UtcNow but comes back from SQLite as Unspecified, and "o" on an Unspecified value
emits no zone, which the browser reads as local time.

Two Fabric targets sharing a DeviceMac threw out of ToDictionary and took the
whole chart response with them, series included.

Folding read geometry only from the temperature chart, but alignedPoints returns
an empty series when a field is null throughout - so a site whose switch
temperatures never arrive silently lost folding on CPU and Memory, the charts
actually carrying marks. Any chart can answer; they share a width and an x-window.

Every poll replaced the label elements and orphaned their Tippy instances. They
are now destroyed before each redraw, and a signature guard skips the redraw
entirely when the marks and the geometry have not changed.

Tooltips also bind by the rel index ApexCharts stamps on each label rather than
by NodeList order, which was an assumption rather than a fact.

The high mark counts turned out to be neither flapping devices nor an alert
storm: 77 stored reboot records on the NAS site describe 11 real boots, in bursts
spanning under a second, carrying different classifier versions and sometimes
contradicting each other on the category. The reboot-reason display never saw it
because it reads only the latest record per device. That is a write-path defect
and is NOT touched here; the chart collapses records within the tracker's own
five-minute boot tolerance and keeps the one classified by the newest rules.

* Device Stats: confine the annotation pointer-events unlock to Device Health

The rule re-enabled hovering on ApexCharts annotation labels by class alone, so
any chart that happened to carry that class would have been unlocked with it.
Three other modules draw annotations - ISP Health, latency and the WAN live chart
- and they should keep the library's behaviour.

Now scoped to #device-health-charts-container as well as the class, which also
raises specificity further above the library's own rule.

The folded-mark tooltip styles are scoped to the tooltip instead of the card:
Tippy renders the popper on document.body, so a card-scoped selector would never
have matched them.

* Device Stats: give the event marks the tooltip structure the rest of the app uses

The marks explained themselves as four lines separated by breaks, which read as a
run-on next to the speed test and Wi-Fi tooltips. They now use the same shape
those do - a title, then label/value rows built from .device-tooltip-row and
.device-tooltip-label, reused rather than cloned so a mark and a hop tooltip
cannot drift apart. Detail wraps full width under its own label, because it is
prose rather than a value.

A folded mark leads with its count and lists every event it covers as a time
paired with what happened and where. Times in that list drop the date when the
whole fold falls on one day and keep it when it does not, which it will at 30d.

The mark layer moves to chart-event-marks.js. SFP Stats is the same shape as this
tab and wants the same marks, so this lands as a module rather than as something
to copy: a series is an opaque key the server supplies, and every value the
tooltip shows rides down with the event, so the layer knows nothing about devices
or ports. Device Health passes a MAC; SFP will pass device plus port, and the
Port row appears on its own.

* SFP Stats: mark the SFP alerts on the module charts

Same marks as Device Stats, on the same shared layer, over RX/TX power,
temperature and the three PON charts.

SFP alerts only. A restart or a device-wide alert belongs to the device rather
than to any one module, so putting it here would mean repeating it against every
module the device carries; Device Stats already covers it once.

The match is made on the event context rather than on DeviceId. An SFP alert
records its device and port in Context, and DeviceId alone carries only the
device - which on a switch with several modules would mark all of them for a
condition on one.

Marks name the condition alone (High temperature, RX power low, TX power high)
because the stored alert titles spell out the device and port, which the tooltip
now carries in its own rows. The message loses the same trailing location for the
same reason.

The PON charts mount lazily, so they are paired with the layer on demand and the
marks are redrawn once they exist.

pointer-events stays a per-container list rather than a bare class: ISP Health,
latency and the WAN live chart draw annotations too and keep the library's
behaviour, so a card earns the unlock by being named.

* SFP Stats: mark the ONT alerts on the PON charts, and weight the tooltip labels

Attached ONTs already surface on this tab, so their alerts belong on it. They
mark the three PON charts only - PON link down, BIP/FEC/HEC spikes, RX power and
temperature describe the link riding over the module, not the transceiver optics
the power and temperature charts plot. Those two keep the SFP alerts alone. The
PON charts carry both, since an RX power dip is often the explanation for the BIP
errors beside it.

The module comes from the link the alert already carries. An ONT alert identifies
itself by ont_id, which says nothing about which SFP it is attached to, but the
collection agent sets SourceUrl to that module's deep link precisely because
attached ONTs surface here. That also makes it the right discriminator rather
than a shortcut: a standalone ONT gets a tab=ont link instead and is left alone,
which is what should happen on a tab about SFP modules. No write path changed.

Mark labels come from the alert's own title with the ONT name and any site suffix
taken off the ends, rather than being reworded, so the two cannot drift.

Tooltip labels go semi-bold. Scoped to the mark rows, so the shared
.device-tooltip-label keeps its weight and the speed test and Wi-Fi tooltips are
untouched.

TODO.md records the reboot write-path defect the chart work surfaced (#1103).

* SFP Stats: raise PON link down to the optics charts, and leave GEM Frames unmarked

A PON link going down is the one ONT condition worth seeing while reading the
optics, since it is what the RX and TX traces are usually being checked against.
It now marks every chart on the tab; the rest of the ONT alerts stay on the PON
charts as before. Which is which comes down from the server as a scope rather
than being inferred in the browser.

GEM Frames (per interval) takes no marks at all. It plots per-interval frame
counts whose shape is the traffic itself, so a mark there reads as a comment on
throughput rather than on the link.

ONT temperature marks now read "High temperature" like every other mark, instead
of the "Temperature high" the ONT alert copy words it as. Normalized on the mark
only - the alert itself keeps the wording it has always had. Everything else the
ONT titles produce already matched.

* ONT Stats: mark the ONT alerts on the standalone ONT charts

The third tab of the same shape, so it gets the same marks: RX/TX power,
temperature and the errors chart.

Matching is simpler here than on SFP Stats. An ONT alert carries its config id in
the event context, and that is the same id the series is keyed by, so no link has
to be reconstructed. The standalone/attached split falls out for free in both
directions: this tab charts standalone configs only, so an attached ONT matches
no series and stays on SFP Stats where its charts are, exactly as a standalone
one is left alone there.

Marks are drawn ahead of updateVisibility's single-ONT short-circuit, which
returns before touching any series - otherwise an install with one ONT, the
common case, would never see a mark.

Only ONTs that actually became a series can be marked. The chart's visibility
filter treats an unknown key as visible, so an event keyed to an ONT with no data
in the window would have drawn a mark belonging to no line on the plot.

The ONT label and severity mapping move to ChartEventMarks, shared with SFP Stats
so the same alert cannot read one way on one tab and another way on the other.

* SFP Stats: match an ONT mark's module key verbatim

The key was rebuilt from the alert's link with the whole value lower-cased, while
the key the series is registered under lower-cases the MAC and leaves the port
name as the device spells it. The two agree only while port names are numeric,
which is the only shape either test site has - the first alphanumeric port name
would have made the ONT marks quietly stop appearing, with nothing to see but
their absence.

Also drops a chart element reference that stopped being read when GEM Frames was
excluded from the marks.

* Guided tour: add the event marks step to 2.6.0

Anchored on the Temperature chart rather than the Device Health card, because the
marks only read as marks with a plotted line behind them. Optional, since the
card is only there once monitoring is set up.
@tvancott42 tvancott42 changed the title v2.6.0 TBD Release Changes v2.6.0 Release Changes Aug 6, 2026
@tvancott42
tvancott42 merged commit d3d2f03 into main Aug 6, 2026
1 check passed
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.

UniFi OS 5.1.28

1 participant