Skip to content

2.0 / Multi-Site GA -> main#958

Merged
tvancott42 merged 304 commits into
mainfrom
release/2.0-multi-site
Jul 11, 2026
Merged

2.0 / Multi-Site GA -> main#958
tvancott42 merged 304 commits into
mainfrom
release/2.0-multi-site

Conversation

@tvancott42

@tvancott42 tvancott42 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

2.0 / Multi-Site GA merge into main.

origin/main has been merged into this branch (up to date as of the latest merge commit), and the agent-install blocker is resolved on the branch - so this is clear to merge as the GA release once the release steps are run.

Agent-install cleanup - RESOLVED

main carried a temporary hack: the beta agent-install one-liner (docker/agent/docker-compose.yml, scripts/agent/install-docker.sh, scripts/agent/install-native.sh) was hardcoded to the current 2.0 preview tag so the beta one-liner worked before GA. This branch's copies use the correct GA-final references - ghcr.io/ozark-connect/agent:latest and /releases/latest/download/... - and the main merge resolved those files to the branch's clean versions.

  • grep TEMPORARY HACK on this branch: 0 hits.
  • Merging this PR removes main's beta pins and lands the clean one-liner on stable.

Those :latest refs are correct for stable: on GA, /releases/latest resolves to the GA release (which carries the agent artifacts) and agent:latest resolves to the GA agent image, so they do not 404. They only failed during the beta window because betas are prereleases and /releases/latest skips them - which is the whole reason the temporary main pin existed.

Remaining before / at GA

  • Confirm the GA release pipeline attaches the agent artifacts (NetworkOptimizer.Agent-${RID}, uwnspeedtest-${RID}) and publishes ghcr.io/ozark-connect/agent:latest. That is what makes /latest resolve on stable.
  • Merging this PR = cutting the GA release. Run it through the release runbook (notes, tag, MSI build), not as a bare merge.

Scope

Full 2.0 / Multi-Site scope and GA release notes finalized here at ship time.

tvancott42 added 30 commits July 4, 2026 19:52
…eadlock

Iperf3ServerService.ctor resolved SpeedTestServiceRegistry.GetDefault()
to grab the default site's ClientSpeedTestService. That call builds the
whole default speed-test bundle during construction, and the bundle's
UwnSpeedTestService depends back on Iperf3ServerService (via
WanSpeedTestServiceBase) - a constructor cycle. Because the registry
mediates it with ConcurrentDictionary.GetOrAdd + ActivatorUtilities,
.NET DI's own cycle detection never fires; instead the GetOrAdd factory
for 'main' re-enters itself on the same thread and host startup hangs
(Kestrel never binds). Caught on deploy: local dotnet build/test don't
exercise the DI graph's construction, and a managed stack dump of the
hung process showed the recursion.

Fix: keep the registry reference and resolve GetDefault().ClientSpeedTest
lazily at the single use site (recording a completed iperf3 result). By
then this singleton is fully constructed, so building the bundle
resolves it from cache without re-entering the constructor. Verified: the
app now reaches 'Now listening' locally where it previously hung after
admin-auth setup.

Audited every other constructor-time registry GetDefault()/GetFor()
call: the speed-test bundle is the only one with an internal back-edge,
and Iperf3ServerService was its only eager (hosted-service) constructor
entry point. No other cycles.
The per-site registries hand their singletons to request/circuit scopes
through scoped forwarding registrations (AddScoped(sp => reg.GetFor(slug))).
When the forwarded type is IDisposable/IAsyncDisposable, the DI container
disposes it at scope end - tearing down a shared, registry-owned instance.
For MonitoringInfluxClient this was the outage: the first chart request /
page visit disposed the shared client's config + flush semaphores, and
every subsequent ReconfigureAsync (collection agent tiers, ISP Health,
history reads) then threw ObjectDisposedException. That's why the live
view stalled and ISP Health could not compute - not an InfluxDB bucket
issue; the default site reads the unchanged unprefixed bucket names.

Fix, mirroring the existing UniFiConnectionService.Dispose/DisposeOwned
precedent, for every scope-forwarded disposable:
- MonitoringInfluxClient: DisposeAsync -> no-op; real teardown moved to
  DisposeOwnedAsync, called by MonitoringInfluxRegistry at shutdown.
- CableModemMonitorService, OntMonitorService, CellularModemService:
  Dispose -> no-op; poll-timer teardown moved to DisposeOwned, called by
  ModemMonitorRegistry.
- MonitoringCollectionAgent (BackgroundService): override Dispose to a
  no-op so a scope can't cancel its stopping token and kill the site's
  collection loops; MonitoringCollectionRegistry owns start/stop.

The non-disposable scope-forwarded services (speed-test bundle, SSH,
live-stats) are unaffected. Regression test reproduces the exact failure:
a scope-disposed client must survive and ReconfigureAsync must not throw.
… powers

Reframe the on-site agent from 'monitors the site's devices' to what it
actually unlocks from the central server: network monitoring (SNMP,
device stats, Live View), ISP Health, LAN speed tests, and cable modem /
ONT status. Tightened and kept the no-inbound-access / VPN framing.
- checkbox-toggle: wrap each label's text in a single <span> so the bold
  title and its description flow as one block instead of fragmenting into
  separate flex items and wrapping raggedly; keep the checkbox from
  shrinking.
- Define .form-hint (it was used but never defined, relying on inline
  margin hacks) with muted color, size, line-height, and top margin - this
  is what left the Multi-Site panel's port-forward hint jammed against the
  checkboxes.
- Add margin above the wizard action buttons so they aren't flush against
  the last checkbox.
…rbiage

Re-enter the wizard's agent step for an existing site whose agent was
never enrolled, instead of only offering a bare "New Agent Token".

- SiteSetupWizard.StartAgentSetupFor(site): public entry that jumps to
  step 3 on the feature-selection sub-step for an existing site.
- Settings Multi-Site panel: "Set up agent" / "Finish agent setup"
  button (shown for non-default sites with no enrolled agent) drops into
  the wizard's agent step and scrolls to it. Bare "New Agent Token"
  demoted to secondary.
- Verbiage: "modem monitoring" -> "modem / ONT monitoring" in the agent
  feature descriptions (wizard proxy feature, Settings devices toggle).
…sites gear

Agent server URL now comes from REVERSE_PROXIED_HOST_NAME (already set on every
deployed site) instead of a hand-entered setting - one host serves both the app
and the gRPC tunnel, split by path at the reverse proxy.

- AgentServerUrlProvider: derives the agent-facing HTTPS URL from
  REVERSE_PROXIED_HOST_NAME; null on a bare local run (UI then prompts to set it).
- AppVersionInfo: centralizes the (source build) vs released detection that the
  footer already used; MainLayout now consumes it.
- AgentInstallInstructions (shared by the wizard and the Multi-Site panel):
  released builds show a Docker one-liner as the primary path; only a source
  build shows build-from-source + agent.json. Reachability copy leads with VPN /
  site-to-site, public exposure as the fallback.
- Removed the manual central-server-URL setting and its DB getters/setters.
- Sites page: standard gear icon in the header linking to Multi-Site settings.
- SitesOverviewCard: LinkToSwitcher param hides the "Open site switcher" CTA and
  the click-through when the card is shown on /sites itself.
…ain date

- Agent setup reuses a site's not-yet-enrolled agent (reissues its token)
  instead of creating a fresh "Agent 1" every time setup is re-entered, which
  was stacking up duplicate rows. Only creates a row when the site has none.
- Added a Remove button beside Disable on each agent (with confirm) plus
  AgentEnrollmentService.DeleteAgentAsync / ReissueTokenAsync.
- Sponsorship nag only loads on the default site now (never per managed site,
  including the Settings preview), gated on SiteContextService.IsDefault.
- Hide the created date for the default site (its date is when multi-site was
  enabled, not install time) in both the /sites cards and the Settings table.
…tcher

- Enforce the BSL 3-site limit (personal, non-commercial use). CreateSiteAsync
  throws past the limit; the wizard's Add Site step shows a limit notice instead
  of the form. SiteManagementService.GetSiteLimitAsync is the single seam a
  future unlock key hooks into (GetSiteLimitAsync/RemainingSiteSlotsAsync).
- Site switcher cards show each site's UniFi Console host under the site name
  (read from the site's own DB) to help orient and catch a wrong console before
  switching in.
…RL model

- Source-build install block in the UI is now a full sequence: build (with RID
  note + output path), agent.json, copy the self-contained binary to the box,
  run, and run-as-service, plus the single-host reverse-proxy note.
- Agent README rewritten to the single-URL model (serverUrl == tunnelUrl == the
  reverse-proxied app host): reverse-proxy path routing for the gRPC tunnel
  (Traefik / Caddy / nginx examples on one host), a systemd unit, and a local
  dev / testing build-and-copy walkthrough.
Wrap AgentInstallInstructions in .agent-install and scope its styles so the
same block renders consistently in both the wizard and the Settings panel:
harmonized font sizes (steps match section-description, code matches the rest),
padded code blocks with margins, and tidy list spacing. Standardize the agent
README install dir/service on /opt/netopt-agent.
…per-site)

AdminAuthService went through the scoped, site-routed settings repository, so on
a managed site it read that site's empty AdminSettings - reporting "auto-
generated password" per site and even writing a generated password into each
site's DB (and risking poisoning the shared auth cache). Route admin settings
through the main DB factory so the login credential is one instance-wide value.
…tidy-ups

- UniFi Console card shows a "Via Site Agent" badge when the current site's
  console is routed through its agent tunnel.
- Sites table rows show at-a-glance agent status (Online / Offline / Awaiting /
  No agent) via SiteAgentStatus; reuses status-dot styling.
- Per-site "Agents (N)" button renamed "Configuration" (it holds the via-agent
  toggles too, not just agents).
- Admin Password card hidden on non-main sites (admin login is instance-wide).
…te-name header

On non-default sites, hide the instance-wide cards (Application Settings, UI /
Display, Map/Mapbox, Data Management, Alert Channels, CrowdSec - Admin Password
already hidden) and the now-empty Application tab; fall back off that tab if
targeted by URL. Per-site cards (console, SSH, monitoring, modems, speed tests,
audit, threat intel) stay on every site. Settings header shows a badge with the
current site name when multi-site is enabled.
…collapsible

- AuditService cached its audit state (last result/time/id, dismissed issues,
  running flag) in the app-wide IMemoryCache under global keys, so the Security
  Posture and Findings cards showed the previous site's data after a switch.
  Scope every audit cache key by the current site slug.
- Multi-site "Disable" moved into a collapsible panel below the sites/wizard;
  the Enable checkbox stays front-and-center at the top and only shows while off.
- Instance-wide Settings cards show a "Global" badge (multi-site only) so it's
  clear which apply to all sites vs the current one.
- Sites table: inline rename via a pencil edit (Enter/Escape/Save/Cancel);
  the slug/Site ID stays immutable and the header badge stays in sync.
- SitesOverviewCard reworked into the monitoring stat-tile design language:
  header + Open-switcher CTA, centered colored values over labels
  (Sites / Enabled / Agents / Agents Online).
…tile polish

- Fetch the UniFi Console display name from v2/api/info (system.name) via the
  shared V2 path builder (correct for UniFi OS and self-hosted), cache it per
  site on connect, and show it instead of the console host in the Sites listing
  and during the setup wizard.
- GetSitesAsync orders the default (main) site first everywhere (Settings table,
  /sites, switcher dropdown, overview card).
- Sites overview card: kill min-height (sites-scoped) and add per-tile color +
  under-bar accents.
…ame on auto-connect

- LanFlowMapCache was a single global snapshot, so viewing a secondary site's
  Monitoring rebuilt into that one slot and overwrote the main site's map (and
  drove the "MonitoringSettings not configured" flood via the secondary's empty
  Influx client). Wrap it in LanFlowMapCacheRegistry with scoped per-site
  forwarding so each site keeps its own snapshot/build lock. Fixes the main-site
  monitoring break when viewing a secondary site.
- Also fetch/cache the console name on auto-reconnect, not just manual Connect/
  Test, so the Sites listing shows it without a manual reconnect.
… tile polish

- On startup / agent restart the secondary console auto-connect ran before the
  agent tunnel was up and gave up, requiring a manual reconnect. Now, when an
  agent opens its tunnel, AgentProbeResultSink reconnects that site's console if
  it routes via the agent and isn't connected (fire-and-forget).
- Sites overview card: reuse the clickable-card hover (matches CM/ONT/Cellular
  cards) instead of a custom bg-tertiary hover; give the stat tiles a
  bg-tertiary background.
SnmpDetectionService detects SNMP settings from the scoped (current-site)
console connection but persisted the result through the main database
factory. Opening a secondary site's Monitoring page runs detection on
page load (and on every console connection-change event), so the
secondary console's SNMP settings overwrote the main site's
MonitoringSettings row - detection state, version, and community.

The main collection agent rebuilds its poller when that row changes, so
it started polling every device with the wrong community string. v2c
requests with a bad community are silently dropped, which surfaced as
simultaneous BulkWalk timeouts on all devices followed by the 5-minute
failure-threshold exclusion cascade: the SNMP data gap. Loss/latency
probes shell out to ping, so they were unaffected.

Route GetOrCreateSettingsAsync and SaveDetectionResultAsync through the
current site's database instead.
… shared Influx connection derivation

Monitoring bleed fixes (secondary sites showed the main site's data):

- IspHealthService and PhysicalLinkResolver are now per site, owned by a
  new IspHealthRegistry (one instance per site with its own report cache,
  compute state, influx client, console connection, and modem monitors).
  Scoped resolution forwards to the current site's instance.
  UpstreamTracerService keeps the default site's instance, matching its
  other default-pinned dependencies.
- LanFlowMapService, ApMapService, and OntStatsPanel read placements,
  monitored SFPs, and fabric targets from the current site's database
  instead of the main one, so a secondary site's map and ONT card no
  longer show the main site's objects.
- Monitoring page settings reads/writes (thresholds, enable toggle,
  Influx reset, upstream review flag) are site-routed the same way.

Shared InfluxDB connection: the server, org, and token are instance-wide;
only bucket names differ per site. When a secondary site's row carries no
Influx config, MonitoringInfluxClient now derives its client from the main
row with site-prefixed bucket names, and the Monitoring Setup tab reflects
the shared connection instead of prompting for per-site Influx setup.

Also: bottom padding on the site switcher's all-sites link.
A secondary site inherits the shared InfluxDB connection (URL and org from
the main site), so its setup shrinks to a single step: paste an all-access
token, and the wizard creates the site's slug-prefixed buckets, mints a
least-privilege token scoped to just those buckets, saves it to the site's
own config, and discards the all-access token. The main connection's token
is never modified - each site holds its own scoped token, revocable
independently.

The full detect-URL/pick-org flow still runs when the main site has no
InfluxDB configured yet.
A secondary site shares main's InfluxDB server, org, and token, differing
only in slug-prefixed bucket names. Setup flow:

- On a secondary site's Setup tab, first try to create that site's buckets
  with the shared token. If it succeeds (the shared token is already
  org-scoped from an earlier site), we're done with no user interaction.
- If it can't (main still holds its original two-bucket token), prompt for
  an all-access token once: create this site's buckets, then upgrade the
  shared credential - mint an org-scoped token that can read/write and
  create every bucket in the org - and replace main's token with it. No
  per-site token is stored; every site derives the shared token. The
  all-access token is held in memory only and discarded.

So the token upgrade happens exactly once, at the first extra site; every
site added afterward provisions silently.

Also fixes the secondary Setup tab falsely showing InfluxDB 'Connected':
it now health-checks this site's own derived client against its own
buckets instead of parroting main's stored reachability.

The org-scoped token grants read:orgs (scoped to the org id, so InfluxDB
will mint it) plus org-wide read/write on buckets. Single-site installs
are untouched - the default-site wizard still mints a per-bucket token and
the upgrade only triggers when a second site is added.
…t ready

A secondary site now counts as configured only once its own buckets are
reachable on the shared InfluxDB. Before that the Setup tab reports
not-configured, so the wizard shows immediately instead of a misleading
'Unreachable / Re-run Setup' state. When the shared token is already
org-scoped the wizard provisions this site's buckets silently (zero
clicks); otherwise it drops the user straight onto the single all-access
token field.
…en wording

Export now VACUUMs each secondary site's data/sites/{slug}/network_optimizer.db
into the archive under sites/{slug}/, and import restores them - with the same
settings-only history pruning/preservation as the main DB. Sites present locally
but absent from the archive are left untouched (additive, never destructive).
Single-site installs have no sites/ directory, so the archive stays
byte-identical and the path is a no-op - no regression.

Also normalized the InfluxDB setup copy: 'All Access API Token' everywhere, and
the secondary-site prompt now names the shared org ('in the <org> Organization').
…te Influx prompt

Startup race: a secondary site whose console routes via its agent tunnel tried to
validate its API key immediately at startup, before the agent tunnel connected.
The loopback proxy had no agent behind it, so the connection was refused and
surfaced as a spurious 'SSL connection could not be established / unexpected EOF'
error on the dashboard - even though it self-healed ~30s later when the agent came
online. Now the startup auto-connect defers when the console is agent-routed and no
agent is online yet; OnAgentConnectedAsync (the existing reconnect-on-agent-connect
path) establishes it as soon as the tunnel is up. Default/single-site consoles
aren't agent-routed, so they connect eagerly exactly as before.

Also reworded the secondary-site InfluxDB prompt to name the org on the token line
rather than up front.
…direction; icon-only mobile logout

Site wizard's limit message now links straight to the Licensing settings
and adapts to free-tier vs licensed. Client Dashboard shows a dash for a
missing speed-test direction instead of 0.0. On mobile the logout button
collapses to its icon when the multi-site selector is present.
The server URL override remains a DB-only setting for testing; no UI knob.
@tvancott42 tvancott42 marked this pull request as ready for review July 11, 2026 02:04
# Conflicts:
#	README.md
#	docker/DEPLOYMENT.md
#	docker/agent/docker-compose.yml
#	scripts/agent/install-docker.sh
#	scripts/agent/install-native.sh
#	src/NetworkOptimizer.Agent/README.md
#	src/NetworkOptimizer.Web/Components/Pages/Settings.razor
#	src/NetworkOptimizer.Web/Components/Shared/UpdateChecker.razor
@tvancott42 tvancott42 changed the title 2.0 / Multi-Site -> main (GA) [TBD - do not merge] 2.0 / Multi-Site GA -> main Jul 11, 2026
…gent copy, hide Multi-WAN card, agent README SSH proxy shipped
…al-allowlist/host-key-pinning aren't effective (agent README, code comments, TODO); ignoreSslErrors warning; hide Multi-WAN card without CS0162
…he data volume (Docker secret); document data volume + .nopt exports as secret material; proportional self-hosted hardening TODO
… comes up (re-render only, no auto audit run)
@tvancott42 tvancott42 merged commit e9069d5 into main Jul 11, 2026
1 check passed
@tvancott42 tvancott42 deleted the release/2.0-multi-site branch July 11, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants