Skip to content

v2.3.0 Release Changes#1025

Merged
tvancott42 merged 25 commits into
mainfrom
dev
Jul 20, 2026
Merged

v2.3.0 Release Changes#1025
tvancott42 merged 25 commits into
mainfrom
dev

Conversation

@tvancott42

@tvancott42 tvancott42 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Rolls up dev into main for the v2.3.0 minor release, headlined by ONT Device Monitoring.

Monitoring

Live View

  • Bridged device names on the LAN Flow Map - A device bridged onto the LAN through a UniFi Device Bridge (e.g. a UniFi Protect camera) carries only an auto-generated hostname, so it rendered as that raw hostname on the 2D/3D LAN Flow Map. It now resolves the friendly name the console keeps for it, reading as e.g. "[Camera] Front Door", with the bridge itself shown as "[Bridge] ...". The same console-name fallback applies to Client Performance and its device picker, so a bridged camera reads as its friendly name there too rather than a MAC.

  • UniFi Device Bridge throughput on the LAN Flow Map - A UDB uplinks over Wi-Fi with its bridged client on a single downlink port, and UniFi reports that client's own wired counters as zero, so both the bridge's uplink link and the bridged-client leaf showed no throughput. Both now show the real rate, read from the UDB's own port table and folded into the existing mesh-AP backhaul synthesis (gated to bridges, so switches, their clients, and mesh APs are untouched). It works in live view and in historic timeline playback (the bridge rate is persisted so the scrubber can replay it), for both directly-monitored and agent-relayed sites.

ISP Health

  • IX Peering in Transit Health - When your monitored internet targets are reached directly over your ISP's own peering/IX - no transit provider on the forward path, and a low best-case latency beyond the first hop - they're graded end-to-end and surfaced as a single IX Peering entry (shown as Direct peering on the Networks on Your Path card). It reflects the real measured jitter, loss, and stability of those peered paths, so a mostly-peered connection gets a Transit Health backed by actual measurement rather than an assumed value. Each peered target is graded on its own and averaged, so one flapping anycast doesn't drag the whole entry; and where your peering reaches the internet at a fraction of the transit RTT, the entry earns back part of its jitter penalty for that proximity. A connection where every target crosses a transit provider (a rural backhaul) shows no IX Peering entry.

  • Destination-absolved transit jitter - A transit ASN's jitter is now discounted by any monitored internet target (or a deeper transit ASN) proven to route through it, the same clean end-to-end upper bound the ISP tier already used. An ICMP-deprioritized transit router no longer gets penalized for control-plane noise its forwarded traffic never sees. Strict: only where stored ancestry proves the path.

  • Involvement-weighted Transit Health - Each transit network counts toward Transit Health in proportion to how much of your internet actually rides it (the share of monitored internet targets whose forward path crosses it), and IX Peering and the transit networks are ordered by that weight (Networks on Your Path leads with your Access networks). The transit you use dominates; a side-path or backup transit is de-weighted to a 25% floor so its problems ding the number only lightly - but its real grade always counts, since it may be on your return path or a failover route a forward traceroute can't see. Transit Health is a straight involvement-weighted average of those real scores, so it always reads within the range of the networks listed under it.

  • Involvement pie icon - Transit factors and the Networks on Your Path cards show a small pie filled to the ASN's involvement weight, with a tooltip stating how many internet targets ride it on the forward path and its resulting weight (and, when none do, that it's held at the floor because it's likely the return path from popular services, asymmetric routing a forward traceroute can't see).

  • Off-path hop disable hint (ISP Network) - An ISP hop that answers pings but appears on no discovery trace (e.g. an OLT/CMTS that deprioritizes traceroute) and whose jitter is degrading its score now shows a "Not on path" hint. It links straight to Latency targets on Network Performance and highlights the hop so you can pause it instead of letting phantom jitter skew ISP Network.

Upstream path discovery

  • Transit witness hardening - When Level 3 (or another curated transit) is genuinely near-transit, its stable anycast witness is now attached alongside any real routers, not only as a fallback when none answer, keeping a consistent Lumen transit anchor across runs even as the real hops shift POP-to-POP or start dropping ICMP (the closer real hops are kept, never replaced). The witness is also traced during discovery so the real transit hops preceding it are recorded as its ancestry, which is what lets its clean end-to-end jitter absolve a jittery near hop it provably routes through in ISP Health.

ONT Device Monitoring

Deep PON-layer visibility for a fiber ONT, folded into the SFP module you already monitor.

  • Network Optimizer Custom (HTTP JSON) provider - a new ONT provider that pulls PON-layer stats from an ONT over a plain HTTP JSON endpoint: PLOAM activation state, GTC status and counters, GEM frame and allocation counters, bridge-port discards, and host-link counters. The JSON contract is vendor-neutral and documented, so anyone can implement it for their own ONT hardware.

  • Attach an ONT to a monitored SFP module - a Network Optimizer Custom config can attach to a monitored SFP module (Settings - ONT Device Monitoring). Attached ONTs are polled on the gateway's SFP cycle and their PON metrics fold into that module's existing SFP series, so the module's optics and its PON internals form one picture instead of appearing as a separate ONT device on the Dashboard, ONT Stats, and ISP Health.

  • SFP Stats: PON charts - for a supplemented module, new per-interval charts for PON errors, host-link errors, and GEM frames, plus a PLOAM detail table (state, ONU ID, FEC DS/US, response time, ONT uptime). The whole section only appears for a module that has the supplemental data and is selected in the filter, so it stays out of the way for the majority who won't use it.

  • SFP ONT card (Dashboard and ONT Stats) - now shows PON Status and a combined BIP/FEC/Drops counter when available. The middle counter shows HEC (the always-on header-error signal) instead of FEC on a link running with payload FEC disabled, where the FEC counters are meaningless.

  • ONT Stats: FEC / BIP Errors (per interval) - a new delta chart for external ONTs that report those counters, plotted as per-interval deltas so a steadily-climbing cumulative total doesn't render as an unreadable ramp.

  • BIP and HEC error alerts - new ONT: BIP Error Spike and ONT: HEC Error Spike rules (Alerts & Schedule - Rules) covering the always-on error signals, so a link running with payload FEC disabled - where the FEC alert can never fire - isn't left without error-counter alerting. BIP's threshold adapts to FEC state (strict when off, relaxed when on, where BIP is just pre-FEC noise). The rules enable automatically once ONT monitoring is configured, and each ONT alert deep-links to the tab that shows the triggering device (SFP Stats for an attached module, ONT Stats for a standalone ONT).

  • Zyxel GPON-SFP PMG3000 provider - a read-only telemetry provider for the Zyxel PMG3000 GPON-SFP stick (a MaxLinear/T&W-based module), for GPON FTTH setups that terminate the fiber in an in-gateway SFP stick rather than a standalone ONT. It reports PON link state and optics (Rx/Tx power, temperature, voltage, bias current) plus serial identity over the stick's plain-HTTP CGI API, no separate box required. (Add Zyxel GPON-SFP (PMG3000) ONT provider #1033, thanks @Optic00)

  • Provider-aware default host on the ONT form - picking a provider on the Add/Edit ONT form (Settings - ONT Device Monitoring) now suggests and fills that device's known management address (AT&T 192.168.1.254, Nokia and Telekom 192.168.100.1, 8311 192.168.11.1, Quantum 192.168.0.1, Zyxel 10.10.1.1), the way the port default already worked, instead of the same generic placeholder for every provider. A host you typed yourself is never overwritten.

Security Audit

Multi-Site

  • On-site agent tunnel behind your own reverse proxy - The agent tunnel is gRPC, and our canonical-host redirect was 302'ing it whenever a reverse proxy forwarded the tunnel path without the original Host header. A gRPC stream can't follow a redirect, so the tunnel silently failed while REST heartbeats kept the agent showing online. This bites proxies that rewrite Host by default (nginx) or present the upstream authority on an HTTP/2 backend (Caddy); Traefik's defaults happened to avoid it. gRPC is now exempt from the redirect, so the tunnel connects no matter how your proxy handles the Host header.

  • Honest agent online/offline status - An agent now reads Online only when its tunnel is genuinely up (or briefly reconnecting), not merely because REST heartbeats are landing. A broken or unpublished tunnel - where monitoring relay, config push, the proxied UniFi Console, and relayed speed tests are all down - now shows Offline and raises the offline alert instead of a misleading green. Single-box installs with no tunnel listener are unaffected.

  • Add-a-Site wizard resets when you configure a site - after the Add-a-Site wizard reaches its "... is ready" success screen (Settings - Multi-Site), opening any site's Configuration from the Sites table now returns the wizard to a fresh form rather than leaving the completed screen sitting beside the site you're configuring. It stays put while you're mid-way through adding a site, so nothing in progress is discarded.

Fixes

  • Nokia XS-010X-Q ONT monitoring - Some Nokia XS-010X-Q firmware answers our login with a malformed Set-Cookie header that made .NET silently drop the hand-set session cookie, so the ONT returned 401 and never connected. We now disable the client cookie engine (the real session token arrives in the response body) with a byte-for-byte raw-socket request-replay fallback for any still-pickier firmware, so these ONTs report stats. (Nokia ONT: disable handler cookies (root cause) + raw-socket curl replay fallback #1029, thanks @jakerobb for isolating the root cause and field-testing)

  • Device names on the LAN Flow Map and Client Performance - Devices the UniFi Console has a friendly or fingerprint name for, but that you never manually renamed, were showing as a raw MAC on the 2D/3D LAN Flow Map (Monitoring - Live View) and on Client Performance - even though Wi-Fi Optimizer - Client Stats already displayed the proper name. Both now also read the UniFi Console's system-selected display name from the v2 active-clients endpoint and use it as the top of the label fallback chain (display name, then name, then hostname, then MAC), so an unnamed PlayStation or Fire tablet reads as its device name instead of a MAC. The name is served from a small per-site cache (5-minute TTL), so this adds no meaningful load on the console.

  • Duplicate Health Issues entries - On Wi-Fi Optimizer - Overview, the Health Issues card intermittently showed every finding twice (doubled rows and (2) count badges) when the page's initial load overlapped its auto-refresh. The two concurrent health-score refreshes were sharing and mutating a single score object, so each finding got appended twice. Refreshes are now serialized, and the score is assembled in isolation and published only once complete, so findings appear exactly once.

  • Spurious "disposed object" console-connection error - When a UniFi Console connection was re-established while a login was still in flight (for example an agent-tunnel site's console coming back after a restart), the console-connection warning could surface Cannot access a disposed object instead of a normal transient retry. The login now degrades gracefully in that reconnect window and self-heals on the next poll.

  • ControlD gateway-probe diagnostics (omit from release notes) - The on-gateway ControlD detector (verify.controld.com CNAME probe) now logs at Debug when it starts, and on a no-match logs the actual CNAME/A records the resolver returned, instead of returning silently. This makes it possible to tell from a reporter's logs whether the probe ran and what came back. Diagnostic logging only, no behavior change (the probe still only runs when DoH is otherwise unconfigured).

tvancott42 and others added 3 commits July 19, 2026 13:43
The maps (DiscoveredClient via stat/sta) and Client Performance's online
identify path only had v1 name/hostname, so name-less clients rendered as a
raw MAC while Client Stats showed UniFi's friendly display_name (v2
active-clients). Plumb that display_name into both as the top of the fallback
chain (DisplayName > Name > Hostname > MAC) via a dedicated per-connection
labels-only cache (5 min TTL) that leaves the real-time active-clients call
uncached.
… error (#1026)

* Fix intermittent duplicate Wi-Fi Optimizer Health Issues entries

GetSiteHealthScoreAsync assigned the fresh score to the shared
_cachedHealthScore field and then mutated that field in place (MLO/6 GHz
checks + EvaluateRules). With no synchronization, an initial load racing the
Overview auto-refresh could interleave so both calls appended their findings
onto the same score object, doubling every Health Issues entry (the '(2)'
badges). Serialize refreshes with a semaphore and build the score in a local,
publishing it to the field only once fully assembled.

* Don't surface a disposed-client login as a console-connection error

On reconnect (e.g. a restart while an agent-tunnel site's console is coming
back up) the UniFiApiClient and its _authLock can be disposed while a LoginAsync
is still in flight. The finally's _authLock.Release() then threw
ObjectDisposedException, which bubbled up and showed as a 'Cannot access a
disposed object' error in the Wi-Fi Optimizer console-connection warning box.
Guard just that Release so a disposed-mid-login degrades to the normal transient
retry instead of a scary error.
…he LAN Flow Map (#1027)

* Absorb UDB device names and bridged-client throughput

Two related gaps for UniFi Device Bridge (UDB) setups (e.g. a Protect camera
wirelessly bridged onto the LAN):

Names: UDB-bridged clients carry no user name/display_name, only an auto
hostname, but expose a friendly name in unifi_device_info_from_ucore (e.g.
'[Camera] Driveway'). Add that field to UniFiClientResponse and use it as a
label fallback (display name > name > ucore name > hostname > MAC) on the
2D/3D map and Client Performance.

Rates: a UDB uplinks over Wi-Fi with the bridged client on its single downlink
port, whose own client counters UniFi reports as zero. Its real throughput is
on the UDB's own port_table. Fold DeviceBridge into the mesh-AP backhaul
synthesis (new contributor reading the bridge's downlink port, gated to
DeviceBridge) so the UDB's uplink link shows the flow, and source the bridged
client's leaf-link rate from that same aggregate (gated to bridge parents).
Switches, their clients, and mesh APs are untouched.

* Record UDB bridge rates to InfluxDB for historic map playback

The live UDB rate fix routes through LanFabricAggregator + MonitoringLiveStats,
which are in-memory only. Historic LAN-flow-map playback re-derives rates from
the durable interface_counters measurement instead - which works for switches/
APs/mesh (their bytes are in an SNMP port or vwiresta series) but not for a UDB,
whose throughput lives solely on its own port_table (a UniFi-API signal, never
SNMP). So the UDB uplink and its bridged-client leaf were blank in playback.

Persist each UDB's summed downlink port_table rate to interface_counters under a
synthetic 'bridge-downlink' series (BridgeInterfaceRecorder), from the shared
recording path so both directly-monitored and agent-relayed sites record it.
Teach the historic MeshBackhaul and WiredClient resolvers to read that series
(the latter via the existing BridgeParentMac marker), matching the live
directions. No new measurement; switches/APs/mesh playback is unchanged.

* Use a generic example device name in doc comments

* Keep UDB device badge symmetric in historic playback

The historic node-badge fabric-sum treated the UDB's synthetic bridge-downlink
series as switch fabric, summing its single directional flow into ingress/egress
(low in, high out) - so a bridge looked asymmetric during playback while live
showed it symmetric. Exclude the bridge-downlink series from the fabric-sum so
the UDB falls back to adjacent-link summing, matching the live badge. Real
switches never carry that series, so they're unaffected.
@tvancott42 tvancott42 changed the title v2.2.1 v2.2.1 Release Changes TBD Jul 20, 2026
… transit jitter, off-path hop guidance (#1028)

* Upstream discovery: always attach the Level 3 transit witness when near-transit

Previously the anycast witness (4.2.2.2) was injected only as a fallback,
when no real AS3356 router cleared the reachability gate. When real Level 3
hops respond, they'd suppress it - so a site with pingable Level 3 hops never
got the stable anycast anchor, and ISP Health's Lumen transit signal drifted
POP-to-POP across discovery runs.

Drop that suppression: whenever a curated transit ASN is genuinely near-transit
(and its witness itself clears the gate), attach the witness alongside any real
routers. Real hops are kept, never replaced - the anycast endpoint is usually a
more distant POP than the closest real hop. Its samples fold into the ASN's
transit tier in ISP Health automatically. Same-address dedup on write means a
hand-added witness at the same address is respected, not duplicated. Generalizes
to every entry in the witness table.

* Upstream discovery: traceroute the transit witness so it carries ancestry

The witness was injected as a ping-only target, so it landed in
UpstreamDiscoveries with empty AncestorHopIps and could never satisfy
FarClusterRoutesThroughNear - meaning ISP Health could never use the witness's
clean end-to-end jitter to absolve a jittery near hop it routes through, even
though the whole absolution mechanism already exists for traced destinations.

Trace the witness at injection and append the result to the discovery traces.
PersistHopOrderAsync then records the real transit hops preceding it as its
ancestry, unchanged. The proof is honest: ancestry comes from the anycast
endpoint's actual path, so an absolve only fires when that path genuinely
traverses the monitored hop; a different POP yields no overlap and no absolve.

No schema change (AncestorHopIps already exists); scorer-side absolution and
the ancestor gate are already covered by IspHealthScorerTests.

* ISP Health: destinations absolve transit jitter + involvement-weighted Transit Health

Arm A: transit ASNs are now absolved by any monitored destination (or another
transit ASN) proven to route through them, routes-through gated on stored
ancestry - the same clean-end-to-end upper-bound logic already used for ISP
hops. Folded with the existing within-ASN far-cluster absolution; strict, never
on faith.

Arm 4: Transit Health is now a weighted average - each transit ASN weighted by
its internet-host involvement (share of monitored destinations proven to route
through it): floor 0.25 + 0.75 * reach/maxReach, so a graded ASN never zeroes
but a side-path transit counts as little as a quarter of the main-path one. When
no host is attributable (transit invisible on destination traces), all weights
collapse to equal - identical to the prior plain average. A fraction-icon
tooltip surfaces the share/weight, shown only when involvement differentiates.

No schema change - both compute from ancestry already loaded in the scorer.
Tests cover destination->transit absolution and involvement weighting.

* ISP Health: floor transit involvement at 25% on peered sites (true zero)

The involvement weighting conflated two different zeros: "can't attribute" (no
hop order / no destinations) and "attributed, and the answer is zero" (a peered
site whose destinations cross no transit). The first should fall back to equal
weight; the second is a true-zero involvement and should sit at the 25% floor.

Gate the involvement weighting on attribution being KNOWN (hop order + at least
one destination) rather than on maxReach > 0. When known but no transit carries
a host, every transit ASN floors at 25% and shows the fraction-icon tooltip
("0 of N hosts") instead of silently reverting to equal weight with no icon. The
dimension score is unchanged where the flooring is uniform; the point is correct,
visible attribution.

* ISP Health: blend uninvolved transit toward neutral, pie-slice involvement icon

Scoring: a transit ASN now contributes weight*own + (1-weight)*100 to Transit
Health, involvement-weighted - so a transit carrying none of your hosts can't
drag the number (its uninvolved share reads neutral), while the 25% floor keeps
a graded ASN from vanishing. Fixes the case where a peered site's unused transit
(e.g. Cogent at 74) pulled Transit Health down via the plain average, even though
it carries zero monitored hosts. Involvement now lives on IspAsnHealth as the
single source for both the dimension and the card.

UI: replaced the wrapping text glyph with a pie-slice SVG that fills to the
involvement weight, kept on the same line as the ASN name (the factor label is a
flex column, so name+icon are now grouped in a row), and added it to the Networks
on Your Path cards too. data-tooltip carries the host fraction and weight.

* ISP Health: transit involvement tooltip explains the 25% floor (asymmetric routing)

The floored-transit tooltip now makes clear the ASN is still scored, not dropped:
our involvement is measured on the forward path (traceroute), but a transit we
never cross outbound often carries the return path from popular services
(hot-potato / asymmetric routing) - so it's held at the 25% minimum rather than
zeroed. The carrying-hosts tooltip is clarified to "on the forward path".

* ISP Health: trim transit involvement tooltip to one line

* ISP Health: transit tooltip says 'internet targets', not 'hosts'

* ISP Health: flag off-path jittery ISP hops with a disable hint linking to Latency targets

An ISP-network hop that answers pings but appears on no discovery trace (HopNumber 0
- e.g. an OLT/CMTS that ICMP-deprioritizes traceroute) isn't a forward-path jitter
signal, yet its jitter still drags ISP Network. When such a hop is off the traced
path AND its jitter score is a real deficit (<=70), and it isn't the graded nearest
hop, surface a "Not on path" hint on its ISP Health row with a tooltip; clicking it
switches to the Setup tab and highlights the target in Latency targets to pause it.

Detection reuses the existing per-target hop distance (HopNumber 0 = never traced);
no new discovery data. IspHealthPanel raises OnReviewTarget; Monitoring maps the
TargetId to the row and jumps once the card mounts.

* ISP Health: disable hint jumps to the right tab; commonize + align the hop badges

- Fix: the "Not on path" hint switched to the Setup tab, but Latency targets lives
  on the Network Performance tab (same as JumpToLatencyTargetAsync). Jump there so
  the target actually highlights.
- CSS: extract a shared .isp-target-badge base for the "Lowest RTT" and "Not on
  path" badges (margin, padding, radius, font, uppercase, spacing, vertical-align);
  only color/cursor/hover stay per-badge. Bumps margin to 0.5rem and vertically
  centers them against the hop name.

* TODO: AWS S3/DynamoDB endpoints for transit trace exposure (Monitoring)

* TODO: DynamoDB regional endpoints (ICMP-pingable) for transit trace exposure
…with SFP module attachment (#1030)

* ONT provider: Network Optimizer Custom (HTTP JSON) with SFP module attachment

- New netopt-custom ONT provider polling a vendor-neutral PON stats JSON
  endpoint (contract doc in docs/features/netopt-custom-pon-contract.md)
- Attach-to-SFP-module mode: polled on the gateway SFP collection cycle and
  merged onto that module's sfp measurement; pon_link_status / fec_errors /
  bip_errors reuse the ont measurement's field names and encodings
- Standalone mode feeds the ont measurement and ONT alerts like any provider
- Settings - ONT Device Monitoring: provider entry + Attach to SFP Module
  selector (OntConfigurations.AttachedSfpId migration)
- SFP Stats: conditional PON errors / GEM frames / host link delta charts and
  PLOAM details table for supplemented modules
- ONT Stats: FEC / BIP per-interval delta chart (all providers reporting
  the counters)

* ISP Health: uniform hop badge height and centering

* Attached ONT configs: exclude from standalone ONT UI (Dashboard, ONT Stats, ISP Health)

An ONT config attached to an SFP module surfaces as PON data on that module
(SFP Stats), so it must not also appear as its own ONT device. Add
GetStandaloneConfigsAsync (filters AttachedSfpId) and use it for the ONT Stats
tab, Dashboard ONT card, and ONT chart endpoint; exclude attached configs from
ISP Health PON candidates too.

* SFP ONT card: show PON status and absolute BIP/FEC/drops when available

Cache the latest PON supplement (link status + cumulative BIP/FEC/GEM-dropped)
in SfpLiveStats during the SFP poll, and surface it on the SFP ONT Connection
card: PON Status in the Connection box (same format as the external ONT card)
and absolute BIP/FEC/Drops in the Stats box, each shown only when present.

* SFP ONT card: combine BIP/FEC/Drops into one row to save space

* SFP ONT card: reorder PON stats, adaptive FEC/HEC, even columns

- PON Status moves directly below Link Type in the Connection box
- BIP/FEC/Drops error counts move into the Connection box
- Middle error slot is adaptive: shows FEC when payload FEC is enabled, HEC
  (the always-on header-error signal) when FEC is disabled, label flips to match
- SFP Voltage moves to the Stats column only when a PON supplement is present,
  keeping the two columns even

* SFP/ONT charts: lazy-mount PON + FEC/BIP charts, filter-aware PON section

- Don't create the PON charts (SFP Stats) or the FEC/BIP errors chart (ONT
  Stats) until a module actually reports supplemental data. Setups without
  supplemental polling - the large majority - never mount instances they'd
  never see.
- PON section and its detail table now follow the filter: shown only while a
  PON-capable module is selected, table lists just the selected modules.

* SFP Stats: fix filter-chip breakage and PON chart sizing; drop TX idle

- PON section refresh is now fire-and-forget off updateVisibility, so an
  ApexCharts error can never abort the synchronous chip re-render (was leaving
  chip highlight desynced and blocking re-selection)
- Show the PON section before mounting/updating its charts so ApexCharts sizes
  them correctly instead of loading blank until the next tick
- Rebuild PON series from only the visible modules each refresh (no per-series
  show/hide bookkeeping)
- Drop TX idle from the GEM chart - it's an upstream-utilization indicator, not
  a health signal, and dominated the scale

* Review fixes: per-endpoint poll gate, awaited PON alert eval, resilient chart refresh

- NetOptCustomPonOntProvider: key the request gate per (host, port) instead of
  one global semaphore, so distinct endpoints/sites poll in parallel
- CollectPonSupplementalAsync: await the PON alert evaluation inside its own
  try/catch (was fire-and-forget; unobserved exceptions escaped the poll catch)
- sfp/ont charts: guard the PON/errors chart refresh in loadAndUpdate so a chart
  failure can't abort badge/stats-table rendering

* ONT alerts: add BIP and adaptive HEC spike alerting

- OntAlertEvaluator now evaluates BIP (always-on) and, when payload FEC is
  disabled, HEC in place of FEC - mirroring the SFP ONT card's adaptive display.
  Shared per-poll spike helper, reset-guarded like the existing FEC check.
- Seed ONT: BIP Error Spike and ONT: HEC Error Spike rules (disabled by default).
- Attaching augmented PON polling to an SFP ONT enables the BIP/HEC rules
  immediately (AlertRuleAutoEnable.EnablePatternsAsync); existing ONT users also
  get them via the standard freshly-seeded startup pass. Closes the gap where a
  FEC-disabled attached ONT had no error-counter alerting.
- Feed BIP from the standalone ONT path and BIP/HEC/FEC-enable from the attached path.

* ONT alerts: tune BIP/HEC thresholds, make BIP FEC-state-aware

- HEC spike threshold 100 -> 10 (uncorrectable header errors; healthy reads ~0)
- BIP spike threshold is now FEC-state-aware: strict 25/poll when payload FEC is
  disabled (every BIP is uncorrected data loss), relaxed 1000/poll when FEC is on
  or unknown (BIP counts pre-FEC line errors FEC corrects, so a healthy link at
  the normal operating point reads hundreds/poll)

* ONT alerts: deep-link to the triggering ONT

Alerts now carry a per-ONT SourceUrl instead of a fixed /monitoring?tab=ont:
standalone ONTs link to the ONT tab focused on that device (?ont=id), attached
SFP ONTs link to SFP Stats focused on that module (?sfp=mac:port), so clicking
an alert lands on the tab that actually shows it.

* TODO: note wrap-aware PON counter deltas as a low-priority follow-up

* SFP Stats: group PON error charts, GEM frames last

Reorder the PON section to PON Errors -> Host Link Errors -> GEM Frames so the
two error charts sit adjacent (faster upstream-vs-host fault localization,
consistent units) with throughput last.
@tvancott42 tvancott42 changed the title v2.2.1 Release Changes TBD v2.3.0 Release Jul 20, 2026
@tvancott42 tvancott42 changed the title v2.3.0 Release v2.3.0 Release Changes TBD Jul 20, 2026
tvancott42 and others added 20 commits July 20, 2026 00:50
* ISP Health: grade direct-peered internet destinations as an IX Peering entry in Transit Health

When internet targets are reached directly over the access ISP's peering/IX
(no transit ASN on the forward path, and a small best-case delta beyond the
first clean ISP hop), grade their measured end-to-end quality as a synthetic
"IX Peering" transit entry instead of leaving the Transit dimension averaging
against the neutral-100 fill. Selection requires BOTH arms: no transit on the
path (AS-path, reusing the routes-through ancestry) AND best-case delta under
10 ms. Far-but-untransited peers and low-latency-but-transited destinations are
both excluded. No entry appears where every destination crosses a transit
provider. Adds two scorer tests.

* ISP Health: grade IX Peering per-destination and average, not pooled

Pooling all peered destinations' samples into one series manufactured cross-target
variance (targets at different RTT baselines look like one swinging series), craters
the stability sub-score, and let one target's jitter tail dominate the pooled P95.
Grade each peered destination on its own series and average the grades, so one flapping
peer counts 1/N. Log each member's grade at Debug for diagnosis.

* ISP Health: IX Peering proximity jitter absolution + weight-desc network ordering

Proximity absolution: when peered destinations reach the internet at a fraction of the
median transit ASN's RTT, buy back part of the IX Peering jitter penalty, scaled by the
RTT advantage and bounded to jitter's own weighted share of the grade (never masks loss,
stability, or a real congestion penalty). Where transit is as close as peering, advantage
< 1 and no bonus applies.

Ordering: sort the transit/IX entries by involvement weight descending (nearer network
breaks ties) for both the Transit Health factor list and Networks on Your Path; Access
renders first as before.

* ISP Health: Transit Health = plain involvement-weighted average (drop neutral-100 blend)

The dimension blended each transit ASN's uninvolved fraction toward a neutral 100
(effective = w*own + (1-w)*100), which inflated Transit Health above every real entry
(e.g. entries 88/73/89 -> 90) and re-introduced the phantom-100 the IX Peering entry was
meant to eliminate - masking a real congested-but-off-path transit up toward 100. Replace
with a plain involvement-weighted average of the entries' own scores: the dimension now
always lands within its entries' range, and a congested off-path transit dings it lightly
(0.25 floor) rather than hiding at 100. Floor unchanged (return-path/failover accountability).
Add a start line and, on the no-match path, log the actual CNAME/A
records returned so logs show what the resolver gave back for
verify.controld.com (it only carries the ControlD record when the
query traverses ControlD). Makes on-gateway ctrld detection diagnosable.
…lay fallback (#1029)

* Nokia ONT: keep-alive connection fallback for firmware that 401s getUpdateinfo (#929)

Some XS-010X-Q firmware binds the auth session to the login TCP connection,
so presenting the session cookie on the separate getUpdateinfo socket (forced
by the per-request ConnectionClose) is rejected 401 -> /login.html despite a
successful login. Add a fallback that retries login + getUpdateinfo over a
single shared keep-alive connection (MaxConnectionsPerServer=1) when the
fresh-connection path yields no RX reading. Also log the LoginForm Set-Cookie
header to reveal whether a non-working unit uses a header cookie with a
different name.

* Nokia ONT: send browser Referer/Origin/X-Requested-With on GponForm calls (#929)

HARs from two testers show the successful getUpdateinfo carries Referer:
/moreinfo.html, Origin, and X-Requested-With: XMLHttpRequest, and no cookie at
all. A T-Fiber/Metronet CLEI variant gates the call on that Referer and 401s to
/login.html without it, while the firmware this was first built from accepted the
sessionid cookie. Send both the cookie and the browser headers so a single request
shape works for both, no per-firmware branching. Supersedes the keep-alive
connection fallback (the connection-binding theory the HARs disproved). Keeps the
Set-Cookie diagnostic log.

* Nokia ONT: page-walk auth flow for CLEI firmware, cached per config (#929)

Confirmed by curl on a T-Fiber/Metronet CLEI XS-010X-Q: getUpdateinfo is gated
behind the forced PON-password walk (GET /ponpasswd.html, POST ponpasswd_GetConfig,
GET /moreinfo.html) with the session cookie on every step. Add that as a PageWalk
flow, tried after the Direct flow and cached per config (ConcurrentDictionary keyed
by OntConfiguration.Id, re-detected after restart), same idiom as NetgearCmProvider.

Direct stays the bare cookie-only getUpdateinfo that is the only flow confirmed
working on real hardware (Liosnel's), tried first so it's never regressed; only a
box that fails Direct pays for the walk probe, once, until cached. Browser headers
(Referer/Origin/X-Requested-With) and User-Agent apply to login and the PageWalk;
Direct's data call is left untouched.

* Nokia ONT: gate User-Agent to browser flows, fresh login per flow (#929)

Ground-truth check of v2.1.1/v2.2.0: both ship a cookie-only getUpdateinfo (no
browser headers), v2.1.1 on a plain client, v2.2.0 adding ConnectionClose. So the
Direct data call must carry no User-Agent either - move the UA off the shared
client and behind the browserHeaders gate, so it rides login and the PageWalk but
not the Direct getUpdateinfo, which now matches the shipped request exactly.

Also give each flow its own fresh login: a Direct probe that 401s on the CLEI
variant leaves a forced-PON-password session state that could poison a following
PageWalk if they shared one login. Matches how the confirmed curl ran (one login,
then walk).

* Nokia ONT: raw-socket curl replay for firmware that rejects HttpClient framing (#929)

The picky CLEI firmware accepts the login+getUpdateinfo sequence from curl
and browsers but 401s the logically identical HttpClient requests. Byte
captures show the framing delta: HttpClient omits User-Agent and Accept,
appends ;charset=utf-8 to Content-Type, orders content headers last, and
adds Connection: close - none fully controllable through HttpClient.

Replace the PageWalk flow with CurlReplay: a raw TCP replay of the
tester-proven curl script, byte-for-byte (headers, order, casing), fresh
connection per request. Direct stays first for uncached configs and is
reverted to the exact shipped v2.1.1/v2.2.0 bytes (the one flow confirmed
working on real hardware).

* Nokia ONT: disable handler cookies so the sessionid header survives (#929, thanks @jakerobb)

The picky firmware answers login calls with a malformed
'Set-Cookie: Path=/; HttpOnly' header. A default HttpClientHandler's
CookieContainer parses 'Path=/' as a literal cookie and then silently
substitutes it for the hand-set 'Cookie: sessionid=...' header, so the
device never saw the session id - the actual root cause jakerobb isolated
and verified on his unit. UseCookies=false makes the Direct flow work on
that firmware; the CurlReplay raw-socket fallback stays as defense in
depth (immune to handler rewriting by construction).
…and report agent(s) offline when it's down (#1032)

* Agent status: don't report online off REST heartbeats when the tunnel is down

When the server offers an agent tunnel, an agent that only ever REST-heartbeats
has a dead or unpublished tunnel (e.g. the gRPC tunnel path isn't reverse-proxied)
with every tunnel-dependent feature down. Heartbeat freshness alone no longer
counts as online in that case - only an open tunnel, or a short reconnect grace
after a previously-connected one drops. When the server offers no tunnel at all,
REST heartbeat freshness still means online (single-box/legacy deployments).

LAN speed-test target resolution keeps the looser reachability check
(IsReachableForLanTest): those tests hit the agent's nginx directly, not the
tunnel, so a heartbeat-only agent is still a valid target.

* Review: keep the client speed-test placeholder on the LAN reachability check

The Client Speed Test target placeholder is documented to mirror
SiteSpeedTestTargetResolver's pick, which resolves via GetOnlineAgentLanIpAsync
(IsReachableForLanTest). Splitting IsAgentLive left the placeholder on the
stricter status check, so a heartbeat-only agent would show the generic example
instead of the real auto-resolved LAN target. Point it at the same reachability
predicate and refresh a now-stale comment.

* Rename IsReachableForLanTest -> IsReachable

The predicate only checks reachability (open tunnel or fresh heartbeat); it does
not verify the agent actually hosts a LAN speed test (that's an opt-in agent flag
the server has no signal for yet - separate TODO). Name it for what it checks and
document the limitation, so it no longer implies a capability guarantee it can't
make. Pure rename, no behavior change.

* Never redirect gRPC from the canonical-host middleware

The canonical-host redirect (REVERSE_PROXIED_HOST_NAME) runs on the shared
pipeline, so it also sees the agent tunnel's gRPC requests on the HTTP/2 listener.
A gRPC stream cannot follow a 302, so when a reverse proxy forwards the tunnel
path without presenting the canonical Host (e.g. Caddy proxying to the
https://localhost tunnel upstream sends Host: localhost), the Connect stream was
redirected to death - while REST heartbeats still landed and kept the agent
showing online. Skip the redirect for application/grpc requests: gRPC is
machine-to-machine and has no canonical-host concern.
Read-only telemetry provider for the Zyxel PMG3000 GPON-SFP stick (a
MaxLinear/T&W-based unit) over its HTTP CGI API. Basic auth (default
admin/1234), GET /cgi/get_gpon_info for optics/link and GET /cgi/get_sn
for serial identity. Bodies are JavaScript object literals, not strict
JSON, so a small provider-local lenient tokenizer decodes them; no new
dependency. Maps line_status via the shared O1-O7 parser plus Rx/Tx power,
temperature, voltage and bias current onto OntStats. get_gpon_info is the
required endpoint; get_sn is best-effort and never logged (it carries
cur_pass). Fixture-driven unit tests against a scrubbed live capture.

The existing Realtek provider was ruled out first (endpoints 404, form
login vs this device's Basic auth); see the design spec for the evidence.
- get_sn (best-effort serial) timeout no longer discards optical telemetry:
  an HttpClient.Timeout surfaces as OperationCanceledException with the caller
  token not cancelled; rethrow only on genuine caller cancellation, otherwise
  swallow (inner) or return null (outer) per the IOntProvider contract.
- line_status is matched as an exact single ordinal 1-7 (new MapLineStatus)
  instead of the shared substring parser, so a malformed value like "51" no
  longer substring-matches O5 and gets misreported as a healthy Up link.
- Regression tests for both.
The Add/Edit ONT form showed the same generic 192.168.1.254 host placeholder
for every provider. Make it provider-aware, mirroring the existing port
default: selecting the Zyxel GPON-SFP provider now placeholders and prefills
(when the host field is empty) the stick's fixed management IP 10.10.1.1, so
the correct value is suggested instead of a meaningless generic one. Other
providers keep the generic placeholder and are never prefilled.
…-ont

# Conflicts:
#	src/NetworkOptimizer.Web/Components/Pages/Settings.razor
#	src/NetworkOptimizer.Web/Program.cs
Add Zyxel GPON-SFP (PMG3000) ONT provider thanks @Optic00
Suggest a known default Host/IP when selecting an ONT provider whose
device ships on a fixed management address (AT&T 192.168.1.254, 8311
192.168.11.1, Quantum 192.168.0.1, Telekom/Nokia 192.168.100.1), matching
the existing port-default behavior. Realtek, netopt-custom and generic
stay blank (no single sensible default).

Rename the Zyxel option/display name to "Zyxel GPON-SFP PMG3000 (HTTP)"
so only the transport sits in the trailing parenthetical.
- Prefill the ONT Host for the default provider. att-gateway is selected by
  default and never fires OnOntProviderChanged, so its known host was only
  shown as a placeholder, not filled. Centralize the fresh-form config in
  NewDefaultOnt() and seed Host/Port from the provider defaults.
- Generic host placeholder now 192.168.100.1 (the most common ONT/modem
  management address) instead of 192.168.1.254.
- Add-a-Site wizard: reset the completed "... is ready" screen back to a fresh
  form when a site's Configuration is opened from the Sites table, so the stale
  success state does not linger. No-op mid-setup, so input is never discarded.
Add a close button to the upper-right of the site config panel (Settings -
Multi-Site, the row that opens from Configuration) so it can be dismissed
without hunting back for the Configuration toggle. Collapses via the existing
ToggleAgents handler.
…es (#1034)

* ISP Health: score jitter at P90 instead of P95

Make the jitter scoring percentile a config option (AsnJitterScoringPercentile,
default 0.90) instead of a hardcoded 0.95 in ScoringJitterOf. Drives per-ASN/hop
jitter scoring, the path jitter floor, absolve/witness comparisons, the ISP/transit
cap, and the cards - one lever. IX Peering members are graded through the same
GradeAsn path, so they move with it.

P95 double-counted the jitter tail with the separate congestion detector (which
already catches intermittent bursts off median jitter + p90 RTT); P90 reads the
typical tail and relieves the harshness without going blind like P50. Scoring is
floor-relative, so numerator and floor track the percentile together.

Rename P95JitterMs -> ScoredJitterMs (percentile-neutral) and the "P95 Jitter" UI
label -> "P90 Jitter". Add a CI-safe replay harness (JitterPercentileReplayTests,
no-ops without ISP_HEALTH_REPLAY_DIR) that isolates the jitter arm at P50/P90/P95.

* ISP Health: caveat tooltip on reach-0 transit scores below 80

A transit ASN that no monitored internet target routes through on the forward
path is held at the 0.25 involvement floor. When such a network scores below 80,
the score text (on both Transit Health and the Networks on Your Path card) now
carries a tooltip noting the low score may just be its routers deprioritizing
ICMP rather than real path trouble - it's kept in view because it may carry the
return path or a failover route. Score values also get a default cursor instead
of the text I-beam.

* ISP Health: tighten reach-0 transit caveat tooltip copy

* ISP Health: fix caveat tooltip clause join

* UniFi Console Connection: gate awaiting-agent state on a configured console
…et wizard on site removal

- The default-site "New Agent Token" flow hardcoded LanSpeedTest=false, so its
  generated install command could never include --lan-speed-test. Add a LAN speed
  test checkbox (mirroring the wizard) that live-updates the command; non-default
  sites already prompt via the wizard.
- After removing a site, reset the Add-a-Site wizard's completed screen (same
  ResetIfComplete used when opening a site's Configuration) so it doesn't linger.
Removal used ResetIfComplete (step-4 only), but you can delete a site while the
wizard is on the agent step (step 3) for that very site. Add a public Reset() that
clears the form from any step and use it on removal; opening a site's Configuration
keeps the conditional reset so mid-add input isn't discarded.
…ll, tidier output (#1035)

* agent installer: auto-remediate AppArmor-confined nginx for LAN speed test

Some distros ship an enforcing AppArmor profile on the nginx binary that only
permits nginx's stock paths, so our dedicated speed test master - which keeps
its pid, logs, and webroot under the install dir - is denied even as root
(a MAC denial, not file perms), and the LAN speed test never serves.

The installer now, ONLY after `nginx -t` has already failed AND the kernel log
shows AppArmor denying our install dir, adds a local, additive override under
/etc/apparmor.d/local granting the confined nginx access to the install dir,
reloads just that one profile, and re-tests. It never edits the vendor profile;
a parse error makes apparmor_parser abort and keep the running profile, so a bad
override can't unconfine the host's own nginx. If the profile can't be found or
doesn't consume the local include, the override is inert and we fall back to a
precise manual hint (local override, or `aa-complain`).

Reactive by design: on any host where `nginx -t` already passes, none of this
code is reached, so currently-working installs are byte-for-byte unchanged. The
speed test failure remains non-fatal - the agent itself is unaffected.

* agent installer: correct AppArmor fix hint for non-standard nginx profiles

The fallback hint hardcoded the profile filename (usr.sbin.nginx) and pointed
aa-complain at the resolved binary, both wrong when the confining profile is
named differently (e.g. /usr/bin/nginx) or isn't file-backed under
/etc/apparmor.d (snap/custom profiles - Mother's case).

Now derive the real profile name from the denial record, search the snap profile
dir as well as /etc/apparmor.d, and only attempt the local-include override when
the profile is actually a file under /etc/apparmor.d (the only place local/ is
consumed). When it isn't, the hint leads with `aa-complain '<real-profile>'`
instead of a bogus local-override path.

* agent installer: --uninstall, root-worker for non-traversable dirs, safe override

- Add `--uninstall` to the bare-metal installer (parity with the gateway one, and
  the README already implied it existed). Stops both services BEFORE deleting their
  unit files - so it never leaves a running "ghost" unit like a manual rm does -
  then removes the units, enable symlink, our AppArmor override, and the install
  dir. Leaves the host's own nginx and AppArmor profile untouched. Documented in
  the agent README.

- Fix LAN speed test 403 when the install dir isn't world-traversable (e.g. under
  /root, mode 700): unprivileged nginx workers can't reach the webroot. Detect a
  non-traversable ancestor chain (handling the sticky bit, so /tmp is fine) and run
  the workers as root only then; the /opt default keeps the safer unprivileged user.

- Make the AppArmor local override additive and marker-bracketed instead of
  clobbering the whole /etc/apparmor.d/local file, so a user's own rules there
  survive and --uninstall can remove exactly our block.

* agent installer: find nginx AppArmor profiles under /usr/share/apparmor.d too

Some distros ship the nginx AppArmor profile under /usr/share/apparmor.d (with
/etc/apparmor.d reserved for local overrides) rather than in /etc/apparmor.d, so
the auto-fix couldn't find it and fell back to the hint. Search that dir as well,
and attempt the local override for a profile found there (the override still lives
under /etc/apparmor.d/local, the standard include base; we reload the profile
source wherever it ships). The nginx -t re-test still gates success, so if the
profile has no local/ hook we fall back to the (now simpler) complain-mode hint.

* agent installer: don't match/reload AppArmor cache files; skip cache on reload (-rT)

Mother's nginx profile ships no findable source file - only a compiled cache at
/usr/share/apparmor.d/cache/usr.bin.nginx. The broadened search matched that cache
and apparmor_parser -r on a binary cache produced the confusing 'reload failed'.
Exclude cache/ (and local/) from the profile search so an unfindable source now
falls straight to the clear aa-complain hint, and reload with -rT so a read-only
cache dir can't fail the reload on appliance distros.

* agent installer: detect aa-complain in the hint; scope uninstall's summary

- AppArmor hint now detects aa-complain: shows it when apparmor-utils is present,
  otherwise the tool-free, source-free securityfs unload (echo profile >
  .remove) - which works on appliance distros that ship neither aa-complain nor a
  profile source file (the Mother case).
- --uninstall summary only mentions nginx/AppArmor when they were actually
  involved: a monitoring-only install (no --lan-speed-test) never touches nginx,
  so it just says "agent removed"; a speed-test install says nginx is untouched;
  and it notes the AppArmor override only when one was actually removed.

* agent installer: cleaner, structured output

Silence the curl progress tables (downloads are fast; -fsSL), and add colorized,
sectioned output - a bold title, per-section dividers + "==>" headers, "✓" for
successes, "⚠" for warnings, dim detail lines. Colors/markers collapse to plain
text when stdout isn't a terminal, so piped/logged output stays clean. Suppress
the systemd "Created symlink" noise with `systemctl enable --quiet`. Same for the
--uninstall teardown output.

* agent installer: AppArmor hint gives persistent guidance only

Drop the temporary securityfs unload (echo profile > .remove) from the fallback
hint - it evaporates on reboot, which is useless in a production deployment. The
hint now frames the speed test as optional (agent + monitoring unaffected) and
points to persistent remediation only: aa-complain (edits+reloads the profile, so
it survives reboot) when apparmor-utils is present, otherwise a persistent policy
exception added by the host admin.

* agent installer: AppArmor changes are opt-in (--configure-apparmor)

The installer no longer modifies the host's AppArmor policy by default - an app
installer silently editing a security team's policy is a non-starter for
enterprise deployments. The scoped local override now only runs under the new
--configure-apparmor flag.

Default path: detect, stay non-fatal (agent + monitoring unaffected), and the
guidance tells the operator to re-run the install command with --configure-apparmor
to add a persistent, scoped exception. When the flag IS given but the host ships a
source-less / hook-less profile (no scoped exception is possible), the guidance says
so plainly and defers to the admin rather than pretending or hacking. Documented in
the agent README.

* agent installer: make it obvious nginx is only for the LAN speed test

Clarify in --lan-speed-test / --configure-apparmor help and the README that nginx
is used solely to serve the speed test - core monitoring never touches it - so a
monitoring-only user doesn't read the AppArmor/nginx handling as something central.

* agent installer: trim the nginx/apparmor help + docs wording

* agent installer: sudo in the nginx diagnose hint (runs as non-root later)

* agent installer: spruce gateway + docker installer output too

Same structured, colorized output as install-native (title, section dividers +
"==>" headers, "✓"/"⚠", dim detail; collapses to plain text when not a TTY).
Output-only: no logic, conditions, curl targets, compose, or systemctl behavior
changed. Gateway also silences the binary download meter (-fsSL) and the systemd
"Created symlink" line (enable --quiet; verified accepted on systemd 247).

* agent README: fix flag scoping + note AppArmor on the speed test

- --uninstall and --configure-apparmor are bare-metal-only; they were listed under
  "Both scripts accept" but the Docker installer has neither. Split them out.
- Add a short note in "LAN speed test serving" on AppArmor-confined nginx: the page
  may be blocked (monitoring unaffected), re-run with --configure-apparmor for a
  scoped exception, else an admin grants it.
@tvancott42 tvancott42 changed the title v2.3.0 Release Changes TBD v2.3.0 Release Changes Jul 20, 2026
@tvancott42
tvancott42 merged commit a19fbde into main Jul 20, 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.

3 participants