Skip to content

Password reset: reset the Identity admin, not just the legacy row (ON HOLD - decide whether to keep) - #1104

Open
tvancott42 wants to merge 63 commits into
devfrom
bugfix/reset-password-identity
Open

Password reset: reset the Identity admin, not just the legacy row (ON HOLD - decide whether to keep)#1104
tvancott42 wants to merge 63 commits into
devfrom
bugfix/reset-password-identity

Conversation

@tvancott42

@tvancott42 tvancott42 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

ON HOLD. The user-facing problem is already fixed on main and dev by the script-side change, which has been tested on my primary test bed. What is left here is the app-side auth change, and the open question is whether that risk is worth taking now. Do not merge without reading the decision below.

What needs deciding

Is the app-side auth change worth doing now, or later?

The script fix works. It clears AdminSettings.Password, restarts, and copies the regenerated hash into AspNetUsers.PasswordHash itself. Verified end to end on the NAS against a migrated Docker install. Nothing about a broken reset is still broken.

So the app-side change here buys exactly one thing: durability. The script fix leans on LegacyFallbackPasswordHasher, which is documented as "Removed with the session bridge after one release" (LegacyFallbackPasswordHasher.cs:14). It works because that class still accepts a dotted legacy hash. When it is removed, the scripts silently break again - they will write a PasswordHash nothing can verify, and the reset will go back to printing a password the login page refuses.

That is a scheduled event we control, which is what makes deferring reasonable: the auth change can be done deliberately, at the same time the hasher is retired, instead of touching auth now for a problem that is already solved.

The coupling is the thing that must not be lost, whichever way this goes:

Retiring LegacyFallbackPasswordHasher breaks scripts/reset-password.* unless the app-side fix in this PR lands first.

If this PR is abandoned, that belongs in TODO.md next to the main-site agent coverage entry.

What is actually unique to this branch

Most of it has already landed elsewhere, so this is smaller than it looks:

Commit Content Status
cb8400bb Adaptive SQM boot script timeout Already on dev - pulled across separately
874e652e Script Identity sync Already on dev and main as 93b29a60
e2782e19 Busy-database timeout, false-success fix, docker run_sql routing Unique - on no other branch
3c1d225a App-side auth change + tests Unique, and the part under question

There are no unrelated fixes or enhancements riding along. Total branch content is 8 files, ~500 lines.

Rescue regardless of the decision: e2782e19

Script-only, no auth risk, and it fixes a live bug in the scripts now on main:

  • database is locked on a busy install (reported from a real NAS run). The Docker path never stops the container, so writes race the app; the clear step was also still calling sqlite3 directly instead of through run_sql, making it the one write with no timeout - exactly where it failed.
  • A failed write printed [OK] and a password that would not work. The sync runs in an || list, which switches errexit off inside the function, so a failed UPDATE fell through to the success message. That silent version is worse than the visible error that prompted it.

Cherry-picks to main cleanly, same route as the stopgap.

Worth lifting even if the rest is dropped (~60 lines)

Two of the four tests characterize existing behavior and pass on dev without any change here:

  • LegacyPasswordThatBreaksThePolicy_StillMigratesAndSignsIn - pins that an upgrading install's password bypasses the account policy (the seed copies the transcoded hash through the overload that runs only the user validators). If that ever starts validating, short legacy passwords stop migrating and people are locked out by an upgrade.
  • StoredLegacyHash_DoesNotOverwriteALaterPasswordOnReboot.

The other two depend on the new plumbing. The digit fix in GenerateSecurePassword is not independently useful - it only matters because the app-side change puts the generated password through Identity's validators; without that, it never reaches one.

Before this can be merged or properly reviewed: rebase

dev was reset to main after the v2.6.0 release, which rewrote hashes. This branch was cut from c39b7194, which no longer exists on dev. The merge base has slid back to 800baa88, with 63 commits on dev since.

So the branch is stale in the way CLAUDE.md warns about after a release reset. It needs git rebase origin/dev before it is merged or tested, or the three-way merge is computed against a base that predates the release.


The app-side change, for reference

The reset scripts clear AdminSettings.Password and restart, on the contract that startup then generates a password and prints it. Sign-in goes through Identity, and the bootstrap only seeds AspNetUsers from the legacy hash when the admin account does not exist yet - so on a migrated install the scripts printed a password that was silently refused. Shipped in v2.5.3, so it affected released installs.

  • Regenerated first-run password reaches the account - AdminAuthService hands the password it just generated to the Identity bootstrap, which applies it to admin. Fires only when there was no stored password (a first run, or a deliberate reset), so a hash that was merely read back never re-applies and a password set in the app survives every reboot.
  • Lockout is cleared with it - someone resetting a password has usually been failing sign-ins to discover they need to.
  • The generated password always contains a digit - it now goes through Identity, which enforces RequireDigit. Drawing 16 characters from an alphabet with 8 digits in 55 left roughly one in twelve with none, which would have failed the reset it was performing.

Deleting and re-seeding the admin account would have avoided touching auth code, but it is explicitly forbidden - it cannot be undone and would take the account's site memberships and roles with it.

The APP_PASSWORD path is unchanged.

Testing status

  • Script side: exercised end to end on the NAS (Docker, migrated install) - password cleared, container restarted, Identity admin synced, temporary password accepted at the login page.
  • App-side change: never run on a real install. Unit tests cover the bootstrap, not the end-to-end contract.

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 25 commits August 5, 2026 10:41
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 & 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 "&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.
The reset scripts clear AdminSettings.Password and restart, on the contract
that startup then generates a password and prints it. Sign-in now goes through
Identity, and the bootstrap only seeds AspNetUsers from the legacy hash when the
admin account does not exist yet - so on any install that had already migrated,
the scripts printed a password that was silently refused. There was no way back
in short of setting APP_PASSWORD.

Carry the freshly generated password through to the bootstrap and apply it to
the account, which is what the scripts were always promising. It fires only when
there was no stored password - a first run, or a deliberate reset - so a hash
that was merely read back still never re-applies, and a password set in the app
survives every reboot. Clear the account's lockout at the same time: someone
resetting a password has usually been failing sign-ins to discover they need to.

Deleting and re-seeding the admin account would have done this without touching
auth code, but it is explicitly forbidden - it cannot be undone, and it would
take the account's site memberships and roles with it.

The generated password now always contains a digit. It is applied through
Identity, which enforces RequireDigit, and drawing 16 characters uniformly from
an alphabet with 8 digits in 55 leaves roughly one in twelve with none - which
would have failed the reset it was performing. The redraw is bounded, and falls
back to placing a digit rather than returning a password the policy rejects.

Cover the two seed paths that had no test. A first run on an empty install
creates the admin from the generated plaintext, which is the only path where
Identity validates the password - a generated password that broke the policy
would leave a fresh install with no admin account at all. And an upgrading
install carries a password that predates the policy and need not satisfy it:
the seed copies the transcoded hash through the overload that runs only the
user validators, so the policy is never evaluated and nobody is locked out by
the cutover. Pinned with a legacy password too short and digitless to survive
validation, so the day that path starts validating, the test says so.

Both scripts also warn when local logins are disabled or the admin has MFA on.
Neither is changed automatically, but from the login page both look exactly like
a wrong password, which is a bad thing to leave someone guessing at right after a
reset. The queries are guarded so a pre-Identity install still resets cleanly -
including on PowerShell 7.4+, where a missing table would otherwise abort the
script through $ErrorActionPreference.
A first deploy runs the boot script over SSH with no timeout argument, so it
inherited the 30 second default. The script installs its dependencies inline
before it does anything else: it adds the Ookla packagecloud repo, which runs
its own apt-get update and fetches a GPG key, then apt-get installs speedtest,
bc and jq. On a cold apt cache or a slow WAN that comfortably outruns 30
seconds, and the deploy then tore down a deployment that was still working and
reported that the boot script had failed.

Give that one command five minutes, which clears a slow first install without
leaving the page sitting on a boot script that has genuinely wedged. Re-deploys
skip the whole block once the dependencies are present, which is why this only
ever bit the first attempt.
tvancott42 added a commit that referenced this pull request Aug 6, 2026
The scripts clear AdminSettings.Password and restart, on the contract that
startup then generates a password and prints it. Since v2.5.3 sign-in reads
AspNetUsers.PasswordHash, and the app only copies the legacy password across
when the admin account does not exist yet - so on an install that has already
migrated, the scripts have been printing a password the login page refuses,
with no way back in short of setting APP_PASSWORD.

Copy the freshly generated hash into the admin account ourselves, once the app
has restarted and written it. The hash is copied rather than re-derived, so this
needs no crypto and no plaintext in a shell script; it goes in as the old dotted
PBKDF2 format, which the app still accepts and quietly upgrades on first
sign-in. Clear the account's lockout with it - someone resetting a password has
usually been failing sign-ins to discover they need to.

The account is updated, never deleted: deleting it cannot be undone and would
take the admin's site memberships and roles with it.

Pre-Identity installs are unaffected. The Identity tables are probed first and
the sync is skipped when they are absent, so the reset behaves exactly as it did
before. On Windows the probes are wrapped, because a missing table exits sqlite3
non-zero and PowerShell 7.4+ turns that into a terminating error under
$ErrorActionPreference - which would have aborted the script on the very installs
it needs to keep working.

Also warn when local logins are disabled or the admin has MFA on. Neither is
changed automatically, but from the login page both look exactly like a wrong
password, which is a bad thing to leave someone guessing at right after a reset.

This is the script-side stopgap for released installs. The app-side fix, which
makes a cleared password reach the account without the scripts having to touch
Identity at all, is in PR #1104.
The scripts clear AdminSettings.Password and restart, on the contract that
startup then generates a password and prints it. Since v2.5.3 sign-in reads
AspNetUsers.PasswordHash, and the app only copies the legacy password across
when the admin account does not exist yet - so on an install that has already
migrated, the scripts have been printing a password the login page refuses,
with no way back in short of setting APP_PASSWORD.

Copy the freshly generated hash into the admin account ourselves, once the app
has restarted and written it. The hash is copied rather than re-derived, so this
needs no crypto and no plaintext in a shell script; it goes in as the old dotted
PBKDF2 format, which the app still accepts and quietly upgrades on first
sign-in. Clear the account's lockout with it - someone resetting a password has
usually been failing sign-ins to discover they need to.

The account is updated, never deleted: deleting it cannot be undone and would
take the admin's site memberships and roles with it.

Pre-Identity installs are unaffected. The Identity tables are probed first and
the sync is skipped when they are absent, so the reset behaves exactly as it did
before. On Windows the probes are wrapped, because a missing table exits sqlite3
non-zero and PowerShell 7.4+ turns that into a terminating error under
$ErrorActionPreference - which would have aborted the script on the very installs
it needs to keep working.

Also warn when local logins are disabled or the admin has MFA on. Neither is
changed automatically, but from the login page both look exactly like a wrong
password, which is a bad thing to leave someone guessing at right after a reset.

This is the script-side stopgap for released installs. The app-side fix, which
makes a cleared password reach the account without the scripts having to touch
Identity at all, is in PR #1104.
Reported from a NAS run: the reset died on "database is locked" and had to be
run again. The Docker path never stops the container, so every write races the
app's own transactions, and sqlite fails instantly by default rather than
waiting. Give it a 15 second busy timeout so it takes its turn.

Set through .timeout rather than "PRAGMA busy_timeout = ...", which returns the
new value as a result row - prepending 15000 to the output of every query that
reads one back, which silently skipped the Identity sync entirely.

Route the Docker clear step through run_sql like the other two modes. It had
been left calling sqlite3 directly, so it was the one write with no timeout -
which is exactly where the reported failure landed.

Report a failed write instead of claiming success. The sync runs as part of an
|| list, which switches errexit off for everything inside the function, so a
failed UPDATE fell through to the success message and printed a password that
would not work. That is the one outcome this script exists to avoid, and it is
strictly worse than the visible error that prompted this. Checked explicitly now,
on both the clear and the sync, and a busy database says so in words rather than
surfacing a raw sqlite error with no indication of what to do.
@tvancott42 tvancott42 changed the title Password reset: reset the Identity admin, not just the legacy row Password reset: reset the Identity admin, not just the legacy row (ON HOLD - decide whether to keep) Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant