2.0 / Multi-Site GA -> main#958
Merged
Merged
Conversation
…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.
… no casual uncheck)
…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.
…e-site implicit seat)
…reshes without a manual license refresh
… on license change
…mode / multi-NIC)
# 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
…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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
2.0 / Multi-Site GA merge into
main.origin/mainhas 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
maincarried 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:latestand/releases/latest/download/...- and themainmerge resolved those files to the branch's clean versions.grep TEMPORARY HACKon this branch: 0 hits.Those
:latestrefs are correct for stable: on GA,/releases/latestresolves to the GA release (which carries the agent artifacts) andagent:latestresolves to the GA agent image, so they do not 404. They only failed during the beta window because betas are prereleases and/releases/latestskips them - which is the whole reason the temporary main pin existed.Remaining before / at GA
NetworkOptimizer.Agent-${RID},uwnspeedtest-${RID}) and publishesghcr.io/ozark-connect/agent:latest. That is what makes/latestresolve on stable.Scope
Full 2.0 / Multi-Site scope and GA release notes finalized here at ship time.