From f0c67772cbd9d5c46c133e87c8afa4e2010eed6c Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 22:21:40 -0500 Subject: [PATCH 01/63] Turning off agent coverage puts the console back on the direct path Unchecking "an agent collects for this site" on the main site left the console still dialing the agent tunnel, so every console read failed once the agent was stopped. SiteTunnelRouting already asked whether the default site had been handed to its agent before honoring devices.via_agent; the console's own reader never did, and answered from the stored flag alone. The flag is deliberately kept rather than cleared when coverage goes off, so turning coverage back on restores the operator's choice - which is exactly why the flag on its own cannot decide this. IsConsoleViaAgentAsync now asks the same question its device-side counterpart does. Removing a site's last agent clears both routing flags, for any site rather than just the main one. They name a tunnel, so with no agent left they can only point at something that will never answer, and they outlived the agent on every path that removed one. SiteTunnelRouting gains an Invalidate so the cleared flag takes effect immediately rather than after its one-minute cache expires. --- .../Services/AgentEnrollmentService.cs | 40 +++++++++++++++++++ .../Services/SiteTunnelRouting.cs | 7 ++++ .../Services/UniFiConnectionService.cs | 12 ++++++ 3 files changed, 59 insertions(+) diff --git a/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs b/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs index 2b65d377bb..cd64aeb092 100644 --- a/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs @@ -25,6 +25,8 @@ public class AgentEnrollmentService : IAgentEnrollmentService private readonly IDbContextFactory _mainDbFactory; private readonly AgentTunnelRegistry _tunnelRegistry; private readonly SiteAgentCoverage _agentCoverage; + private readonly IServiceProvider _serviceProvider; + private readonly SiteTunnelRouting _tunnelRouting; private readonly ILogger _logger; private readonly Authorization.ISiteAccessFilter _siteAccess; @@ -33,15 +35,47 @@ public AgentEnrollmentService( AgentTunnelRegistry tunnelRegistry, Authorization.ISiteAccessFilter siteAccess, SiteAgentCoverage agentCoverage, + IServiceProvider serviceProvider, + SiteTunnelRouting tunnelRouting, ILogger logger) { _siteAccess = siteAccess; _mainDbFactory = mainDbFactory; _tunnelRegistry = tunnelRegistry; _agentCoverage = agentCoverage; + _serviceProvider = serviceProvider; + _tunnelRouting = tunnelRouting; _logger = logger; } + /// + /// Clears the console and device tunnel routing flags for a site. Both name a tunnel, so once + /// the site has no agent they can only point at something that will never answer. + /// + private async Task ClearAgentRoutingAsync(string siteSlug) + { + try + { + using var scope = _serviceProvider.CreateScope(); + scope.ServiceProvider.GetRequiredService().OverrideSite(siteSlug); + var db = scope.ServiceProvider.GetRequiredService(); + foreach (var key in new[] { UniFiConnectionService.ConsoleViaAgentKey, SiteTunnelRouting.DevicesViaAgentKey }) + { + var setting = await db.SystemSettings.FindAsync(key); + if (setting == null || !bool.TryParse(setting.Value, out var on) || !on) continue; + setting.Value = bool.FalseString; + } + await db.SaveChangesAsync(); + _tunnelRouting.Invalidate(siteSlug); + _logger.LogInformation("Cleared agent routing for site {Slug} - its last agent was removed", siteSlug); + } + catch (Exception ex) + { + // The agent is already gone; failing to tidy the flags must not fail the removal. + _logger.LogWarning(ex, "Could not clear agent routing flags for site {Slug}", siteSlug); + } + } + /// Agents registered for a site, newest first. public async Task> GetAgentsForSiteAsync(int siteId) { @@ -165,6 +199,12 @@ public async Task DeleteAgentAsync(string siteSlug, int agentId) await db.SaveChangesAsync(); DropLiveTunnel(agent.Id, agent.Name, "removed"); _logger.LogInformation("Removed agent {Name} (id {Id}) for site {SiteId}", agent.Name, agent.Id, agent.SiteId); + + // Removing the last agent leaves nothing to route through, so the console and device + // routing flags are cleared with it. They outlived the agent otherwise, and every console + // read and SSH command went on addressing a tunnel that could never come up again. + if (!await db.SiteAgents.AnyAsync(a => a.SiteId == agent.SiteId)) + await ClearAgentRoutingAsync(siteSlug); } /// diff --git a/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs b/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs index fa16b6df38..b5987bc933 100644 --- a/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs +++ b/src/NetworkOptimizer.Web/Services/SiteTunnelRouting.cs @@ -36,6 +36,13 @@ public SiteTunnelRouting(IServiceProvider serviceProvider, SiteAgentCoverage age _logger = logger; } + /// + /// Forget the cached flag for a site. Called when the flag is cleared out from under the cache + /// - removing a site's last agent - so routing stops within the request rather than after the + /// cache expires. + /// + public void Invalidate(string slug) => _flags.TryRemove(slug, out _); + /// Whether the site's devices are configured to be reached through its agent tunnel. public async Task IsViaAgentAsync(string slug) { diff --git a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs index ca62ad6348..7e5a58d487 100644 --- a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs +++ b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs @@ -275,6 +275,18 @@ public async Task IsConsoleViaAgentAsync() { try { + // The default site answers no unless it has been handed to its agent, matching + // SiteTunnelRouting.IsViaAgentAsync. The flag is deliberately kept rather than cleared + // when coverage is switched off, so re-enabling coverage restores the operator's + // choice - which is exactly why the flag on its own cannot be trusted here. Without + // this, unchecking coverage left the console still dialing an agent that is no longer + // meant to serve the site, and every console read failed. + if (SiteSlug == SiteManagementService.DefaultSiteSlug + && !_serviceProvider.GetRequiredService().Covers(SiteSlug)) + { + return false; + } + using var scope = CreateSiteScope(); var db = scope.ServiceProvider.GetRequiredService(); var setting = await db.SystemSettings.FindAsync(ConsoleViaAgentKey); From 8d5a9ff36e6e2be7d48b99781e94b326adee22c9 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 22:26:16 -0500 Subject: [PATCH 02/63] Multi-Site: a disabled agent stops counting against its site The /sites card read "0/1 agents online" for a site whose only agent had been deliberately disabled, and the Agents tile on the overview card counted it while Agents Online never could. Both reported a site as missing an agent it had been told to stop using. Disabled agents now drop out of both halves of the per-site count and out of both overview tiles. A site whose only agent is disabled reaches 0/0, which the markup already omits rather than rendering a count of nothing. --- src/NetworkOptimizer.Web/Components/Pages/Sites.razor | 8 ++++++-- .../Components/Shared/SitesOverviewCard.razor | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/Sites.razor b/src/NetworkOptimizer.Web/Components/Pages/Sites.razor index c9226a5dc1..aadab2a8c5 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Sites.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Sites.razor @@ -244,10 +244,14 @@ else return Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ? uri.Host : url.Trim(); } - private int AgentCount(int siteId) => _agents.Count(a => a.SiteId == siteId); + // Disabled agents are excluded from both halves. A disabled agent is not expected to be + // connected, so counting it in the denominator reports a site as missing an agent it was told + // to stop using - and a site whose only agent is disabled reads 0/0, which the markup omits + // entirely rather than showing a count of nothing. + private int AgentCount(int siteId) => _agents.Count(a => a.SiteId == siteId && a.Enabled); private int OnlineAgentCount(int siteId) => - _agents.Count(a => a.SiteId == siteId && TunnelRegistry.IsAgentLive(a)); + _agents.Count(a => a.SiteId == siteId && a.Enabled && TunnelRegistry.IsAgentLive(a)); private static string LicenseBadgeClass(SiteLicenseStatus status) => status.State switch { diff --git a/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor b/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor index cda5c33ca0..7a4f054fab 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SitesOverviewCard.razor @@ -59,8 +59,11 @@ private List _sites = new(); private List _agents = new(); - private int EnrolledAgents => _agents.Count(a => a.EnrolledAt != null); - private int OnlineAgents => _agents.Count(a => TunnelRegistry.IsAgentLive(a)); + // Both tiles skip disabled agents, for the same reason the per-site count does: a disabled + // agent inflates Agents while it can never appear in Agents Online, so the pair reads as an + // outage rather than as a deliberate choice. + private int EnrolledAgents => _agents.Count(a => a.Enabled && a.EnrolledAt != null); + private int OnlineAgents => _agents.Count(a => a.Enabled && TunnelRegistry.IsAgentLive(a)); private void HandleCardClick() { From c04c69208ed5eb386c090946d268a8c588426604 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 22:37:08 -0500 Subject: [PATCH 03/63] Fix the agent enrollment tests for the widened constructor Adding the routing cleanup to DeleteAgentAsync gave AgentEnrollmentService two more dependencies and left the test that constructs it directly on the old signature, so the test project stopped compiling. The app itself built and deployed fine, which is exactly why it went unnoticed - src builds clean and only the test assembly breaks. The same empty service provider already used for coverage backs the routing cleanup here, so removal logs that it could not tidy the flags and carries on. That is the behavior worth having under test: tidying is best-effort and must never fail the removal. --- .../AgentEnrollmentServiceTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs index 02f824894d..9c787fccc3 100644 --- a/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/AgentEnrollmentServiceTests.cs @@ -38,9 +38,15 @@ public AgentEnrollmentServiceTests() .Options; _factory = new TestDbFactory(options); // No service provider behind it: every coverage read fails closed to "the server still - // collects", which is what these tests assert against. + // collects", which is what these tests assert against. The same empty provider backs the + // routing cleanup on agent removal, so it logs and moves on rather than clearing anything - + // these tests are about enrollment, and the removal must not depend on that tidying working. + var emptyProvider = new ServiceCollection().BuildServiceProvider(); _service = new AgentEnrollmentService(_factory, _tunnelRegistry, new UnfilteredSiteAccess(), - new SiteAgentCoverage(new ServiceCollection().BuildServiceProvider()), + new SiteAgentCoverage(emptyProvider), + emptyProvider, + new SiteTunnelRouting(emptyProvider, new SiteAgentCoverage(emptyProvider), + new Mock>().Object), new Mock>().Object); } From f8820987dee151c82a3b54ca383816b05e51eae2 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 22:43:21 -0500 Subject: [PATCH 04/63] Console teardown follows the path its client was built on Gating IsConsoleViaAgentAsync on agent coverage fixed the routing but broke the teardown hooks that shared it. Those ask whether the live client rides the tunnel; the setting answers whether the console should route that way from here on. The two diverge the moment coverage is switched off with a tunnel-routed console still connected, and the hooks then declined to tear anything down: the client stayed connected against a loopback proxy whose tunnel had died, with no way back, because every automatic reconnect is gated on being disconnected. Each console call then dialed the dead proxy and paid the full retry backoff. The connect paths now record how they built the client, and all three hooks ask that instead. This also closes a race in agent removal, where clearing the routing flags could beat the tunnel's own teardown to the same check. Changing coverage reconnects the console, so the switch takes effect on the existing connection rather than only on the next one - a console parked in awaiting-agent had nothing to move it. Not awaited: a reconnect takes seconds and this runs from a checkbox. Settings reads the SSH routing hint through the gated reader too. Read raw, it claimed tunnel routing on a main site whose agent no longer collects for it, contradicting the console hint beside it. --- .../Components/Pages/Settings.razor | 10 +++---- .../Services/ISiteConfigurationService.cs | 26 ++++++++++++++++++- .../Services/UniFiConnectionService.cs | 16 +++++++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor index a11ac6039f..20fc429791 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor @@ -13,6 +13,7 @@ @using Microsoft.EntityFrameworkCore @inject UniFiConnectionService ConnectionService @inject SiteContextService SiteContext +@inject SiteTunnelRouting TunnelRouting @inject IGatewaySshService GatewaySshService @inject ISshKeyService SshKeyService @inject ISshSettingsAdminService SshSettingsAdmin @@ -4896,11 +4897,10 @@ hasControllerPassword = !string.IsNullOrEmpty(connectionSettings.Password); hasControllerApiKey = connectionSettings.HasApiKey; _currentSiteConsoleViaAgent = await ConnectionService.IsConsoleViaAgentAsync(); - await using (var sshAgentDb = SiteDb.CreateForSite(SiteContext.Slug, SiteContext.IsDefault)) - { - var sshAgentSetting = await sshAgentDb.SystemSettings.FindAsync(SiteTunnelRouting.DevicesViaAgentKey); - _currentSiteSshViaAgent = bool.TryParse(sshAgentSetting?.Value, out var sshViaAgent) && sshViaAgent; - } + // Through the same gated reader the routing itself uses, rather than the stored flag. Read + // raw, the SSH hints claimed tunnel routing on a main site whose agent no longer collects + // for it - the console hints beside them, which ask the gated question, said otherwise. + _currentSiteSshViaAgent = await TunnelRouting.IsViaAgentAsync(SiteContext.Slug); UpdateControllerStatus(); await LoadSshKey(); diff --git a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs index 29f596c934..d86dcb85ec 100644 --- a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs +++ b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs @@ -57,11 +57,16 @@ public sealed class SiteConfigurationService : ISiteConfigurationService { private readonly SiteDbContextFactory _siteDb; private readonly SiteAgentCoverage _agentCoverage; + private readonly SiteConnectionRegistry _siteConnections; + private readonly ILogger _logger; - public SiteConfigurationService(SiteDbContextFactory siteDb, SiteAgentCoverage agentCoverage) + public SiteConfigurationService(SiteDbContextFactory siteDb, SiteAgentCoverage agentCoverage, + SiteConnectionRegistry siteConnections, ILogger logger) { _siteDb = siteDb; _agentCoverage = agentCoverage; + _siteConnections = siteConnections; + _logger = logger; } /// @@ -97,6 +102,25 @@ public async Task SetAgentCoversSiteAsync(string siteSlug, bool enabled) // The collection paths read this through a one-minute cache; a setting that decides whether // the server collects at all should not wait that long to take effect. _agentCoverage.Invalidate(siteSlug); + + // The console is reached by whichever path was chosen when its client was built, so the + // existing one is now on the wrong side of this switch. Nothing else re-establishes it: + // every automatic reconnect is gated on the console being disconnected, and a console + // parked in awaiting-agent stays parked. Not awaited - a reconnect can take seconds and + // this runs from a checkbox. + var connection = _siteConnections.GetFor(siteSlug); + _ = Task.Run(async () => + { + try + { + await connection.ReconnectAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not reconnect the console for site {Slug} after its agent coverage changed", siteSlug); + } + }); } /// diff --git a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs index 7e5a58d487..5c2959d6ba 100644 --- a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs +++ b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs @@ -214,6 +214,14 @@ private void PublishConsoleAlert(string eventType, AlertSeverity severity, strin /// Per-site setting key: reach this site's console through its agent tunnel. public const string ConsoleViaAgentKey = "console.via_agent"; + // How the CURRENT client was built, not how the site is configured now. The teardown hooks + // below used to re-read the setting, which answers a different question: whether the console is + // meant to route through the agent from here on. Those diverge the moment coverage is switched + // off with a tunnel-routed console still connected - the hooks then declined to tear anything + // down, and the client sat "connected" against a loopback proxy whose tunnel had died, with no + // path back (every automatic reconnect is gated on !IsConnected). + private bool _clientViaAgent; + /// Shown while a site's agent-tunneled console waits for the agent to come online. private const string AwaitingAgentMessage = "This site's console connects through its on-site agent, which isn't online yet. It'll connect automatically as soon as the agent comes online."; @@ -339,7 +347,7 @@ public async Task OnAgentTunnelDroppedAsync() try { if (!_isConnected && _client == null) return; - if (!await IsConsoleViaAgentAsync()) return; + if (!_clientViaAgent) return; // Re-check after the await: a fast agent bounce can reconnect (and the // connected hook re-establish the console) while the DB read above was in @@ -385,7 +393,7 @@ public async Task NoteTunnelUnreachableAsync() try { if (!_isConnected && _client == null) return; // already down / awaiting - idempotent - if (!await IsConsoleViaAgentAsync()) return; // only agent-routed consoles ride the tunnel + if (!_clientViaAgent) return; // only agent-routed consoles ride the tunnel _logger.LogInformation( "Site {Slug}'s agent tunnel is unreachable; flipping its console to awaiting-agent ahead of the watchdog", SiteSlug); _client?.Dispose(); @@ -417,7 +425,7 @@ private async Task PreferAwaitingAgentOnDeadTunnelAsync() { try { - if (!await IsConsoleViaAgentAsync()) return; + if (!_clientViaAgent) return; var proxy = _serviceProvider.GetService(); if (proxy == null || !proxy.IsTunnelSuspect(SiteSlug)) return; _awaitingAgent = true; @@ -711,6 +719,7 @@ public async Task ConnectAsync(UniFiConnectionConfig config) } var consoleEndpoint = ResolveControllerEndpoint(config.ControllerUrl, viaAgent); var clientLogger = _loggerFactory.CreateLogger(); + _clientViaAgent = viaAgent; _client = new UniFiApiClient( clientLogger, consoleEndpoint.Url, @@ -852,6 +861,7 @@ private async Task ConnectWithSettingsAsync(UniFiConnectionSettings settin } var consoleEndpoint = ResolveControllerEndpoint(config.ControllerUrl, viaAgent); var clientLogger = _loggerFactory.CreateLogger(); + _clientViaAgent = viaAgent; _client = new UniFiApiClient( clientLogger, consoleEndpoint.Url, From 89d13653a77d9591f18c760539aa8fe59a07c412 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:00:42 -0500 Subject: [PATCH 05/63] A main site handed to its agent stands down while that agent is offline The default site was skipped when refreshing whether it has an enrolled agent, so its enrolled flag stayed false forever and only a LIVE tunnel counted as an agent being present. A main site handed to its agent therefore kept collecting whenever that agent was merely offline - and with its devices routed through the tunnel, every one of those polls dialed a loopback proxy with nothing behind it and came back as SSH.NET's "no identification string". A secondary site counts an enrolled agent whether or not it is connected and stands down cleanly, which is why an agent-down secondary site has always looked right and this did not. With coverage off the answer is false either way, so an install that has not opted in takes an unchanged path. Ticking "console via agent" now reconnects the console. That checkbox only appears once coverage is on, so coverage is necessarily switched first, and reconnecting there alone always ran against the console's old routing - the choice went unapplied until something else happened to reconnect, which is how a console configured for the tunnel stayed on the direct path with no error shown. The devices flag drops its cache when it changes, and when coverage changes, since coverage gates the answer without the flag itself being touched. It is consulted per SSH command and per modem poll, so the switch appeared to do nothing for up to a minute. Gateway SSH's awaiting-agent message no longer says SQM will connect once the agent is online. It is returned for every gateway SSH use, so naming one feature read as a non-sequitur in Test SSH Connection. --- .../Services/ISiteConfigurationService.cs | 71 ++++++++++++------- .../Services/MonitoringCollectionAgent.cs | 13 +++- .../Services/Ssh/GatewaySshService.cs | 2 +- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs index d86dcb85ec..c3ee38f0eb 100644 --- a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs +++ b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs @@ -58,17 +58,44 @@ public sealed class SiteConfigurationService : ISiteConfigurationService private readonly SiteDbContextFactory _siteDb; private readonly SiteAgentCoverage _agentCoverage; private readonly SiteConnectionRegistry _siteConnections; + private readonly SiteTunnelRouting _tunnelRouting; private readonly ILogger _logger; public SiteConfigurationService(SiteDbContextFactory siteDb, SiteAgentCoverage agentCoverage, - SiteConnectionRegistry siteConnections, ILogger logger) + SiteConnectionRegistry siteConnections, SiteTunnelRouting tunnelRouting, + ILogger logger) { _siteDb = siteDb; _agentCoverage = agentCoverage; _siteConnections = siteConnections; + _tunnelRouting = tunnelRouting; _logger = logger; } + /// + /// Rebuilds the site's console on whichever path it should now take. The client records how it + /// was built, so a setting that changes the path leaves the existing connection on the old one + /// until something reconnects it - and nothing else does, because every automatic reconnect is + /// gated on the console being disconnected. Not awaited: a reconnect takes seconds and every + /// caller here is a checkbox. + /// + private void ReconnectConsole(string siteSlug, string because) + { + var connection = _siteConnections.GetFor(siteSlug); + _ = Task.Run(async () => + { + try + { + await connection.ReconnectAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not reconnect the console for site {Slug} after {Because}", + siteSlug, because); + } + }); + } + /// public async Task GetAsync(string siteSlug) { @@ -88,12 +115,23 @@ public async Task GetAsync(string siteSlug) } /// - public Task SetConsoleViaAgentAsync(string siteSlug, bool enabled) - => WriteAsync(siteSlug, UniFiConnectionService.ConsoleViaAgentKey, enabled.ToString()); + public async Task SetConsoleViaAgentAsync(string siteSlug, bool enabled) + { + await WriteAsync(siteSlug, UniFiConnectionService.ConsoleViaAgentKey, enabled.ToString()); + // This checkbox only appears once coverage is on, so coverage is necessarily switched + // first: reconnecting there alone always ran against the console's OLD routing and left + // this choice unapplied until something else happened to reconnect. + ReconnectConsole(siteSlug, "its console routing changed"); + } /// - public Task SetDevicesViaAgentAsync(string siteSlug, bool enabled) - => WriteAsync(siteSlug, SiteTunnelRouting.DevicesViaAgentKey, enabled.ToString()); + public async Task SetDevicesViaAgentAsync(string siteSlug, bool enabled) + { + await WriteAsync(siteSlug, SiteTunnelRouting.DevicesViaAgentKey, enabled.ToString()); + // Consulted per SSH command and per modem poll through a one-minute cache, so without this + // the switch appears to do nothing for up to a minute. + _tunnelRouting.Invalidate(siteSlug); + } /// public async Task SetAgentCoversSiteAsync(string siteSlug, bool enabled) @@ -102,25 +140,10 @@ public async Task SetAgentCoversSiteAsync(string siteSlug, bool enabled) // The collection paths read this through a one-minute cache; a setting that decides whether // the server collects at all should not wait that long to take effect. _agentCoverage.Invalidate(siteSlug); - - // The console is reached by whichever path was chosen when its client was built, so the - // existing one is now on the wrong side of this switch. Nothing else re-establishes it: - // every automatic reconnect is gated on the console being disconnected, and a console - // parked in awaiting-agent stays parked. Not awaited - a reconnect can take seconds and - // this runs from a checkbox. - var connection = _siteConnections.GetFor(siteSlug); - _ = Task.Run(async () => - { - try - { - await connection.ReconnectAsync(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Could not reconnect the console for site {Slug} after its agent coverage changed", siteSlug); - } - }); + // Also drops the devices cache: that flag is gated on coverage for the default site, so + // coverage changing changes the answer without the flag itself being touched. + _tunnelRouting.Invalidate(siteSlug); + ReconnectConsole(siteSlug, "its agent coverage changed"); } /// diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index 27a5035c60..bc1a64d4f6 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -225,7 +225,14 @@ private bool AgentCoversCollection() private async Task RefreshAgentCoverageAsync(CancellationToken ct) { - if (_isDefault) return; + // The default site is refreshed too. It used to be skipped, which left its enrolled flag + // permanently false, so only a LIVE tunnel counted as an agent being present there. A main + // site handed to its agent then kept collecting whenever that agent was merely offline - + // and with its devices routed through the tunnel, every one of those polls dialed a + // loopback proxy with nothing behind it. A secondary site counts an enrolled agent whether + // or not it is connected, and stands down; this makes the main site behave the same once + // it has been handed over. With coverage off the answer is false either way, so an install + // that has not opted in is unaffected. if (DateTime.UtcNow - _agentCoverageCheckedAt < AgentCoverageTtl) return; try { @@ -239,7 +246,9 @@ private async Task RefreshAgentCoverageAsync(CancellationToken ct) // The moment an external site first gains an agent, activate the default internet // targets that were seeded disabled while it had none - the agent can now probe them // from inside the site (AgentProbeResultSink only pushes enabled targets). - if (!wasEnrolled && _siteAgentEnrolled) + // Secondary sites only: the main site's targets are never seeded disabled, because it + // has always had a collector. + if (!_isDefault && !wasEnrolled && _siteAgentEnrolled) await EnableSeededDefaultTargetsAsync(ct); } catch (Exception ex) diff --git a/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs b/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs index fefb72ba6d..f838b82848 100644 --- a/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs +++ b/src/NetworkOptimizer.Web/Services/Ssh/GatewaySshService.cs @@ -32,7 +32,7 @@ public class GatewaySshService : IGatewaySshService /// protocol error. Mirrors the console's awaiting-agent message. /// public const string AwaitingAgentMessage = - "Waiting for the on-site agent to connect. This site's gateway is reached through its agent; SQM will connect automatically once the agent is online."; + "Waiting for the on-site agent to connect. This site's gateway is reached through its agent, and will connect automatically once the agent is online."; public GatewaySshService( ILogger logger, From 5d3d98e0ad1a85237d1ec1c45197afce1584e24c Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:01:31 -0500 Subject: [PATCH 06/63] Keep the server collecting for a main site whose agent is offline Reverts the collection half of 89d13653. Letting the default site count an enrolled-but-offline agent made it stand down like a secondary site, which took the site dark until the agent returned. The fallback is the better behavior here: a server that can still reach the network it monitors should keep collecting rather than go quiet because an agent it was handed to is down. The other three fixes in that commit stand - they were about settings taking effect, not about who collects. --- .../Services/MonitoringCollectionAgent.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index bc1a64d4f6..27a5035c60 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -225,14 +225,7 @@ private bool AgentCoversCollection() private async Task RefreshAgentCoverageAsync(CancellationToken ct) { - // The default site is refreshed too. It used to be skipped, which left its enrolled flag - // permanently false, so only a LIVE tunnel counted as an agent being present there. A main - // site handed to its agent then kept collecting whenever that agent was merely offline - - // and with its devices routed through the tunnel, every one of those polls dialed a - // loopback proxy with nothing behind it. A secondary site counts an enrolled agent whether - // or not it is connected, and stands down; this makes the main site behave the same once - // it has been handed over. With coverage off the answer is false either way, so an install - // that has not opted in is unaffected. + if (_isDefault) return; if (DateTime.UtcNow - _agentCoverageCheckedAt < AgentCoverageTtl) return; try { @@ -246,9 +239,7 @@ private async Task RefreshAgentCoverageAsync(CancellationToken ct) // The moment an external site first gains an agent, activate the default internet // targets that were seeded disabled while it had none - the agent can now probe them // from inside the site (AgentProbeResultSink only pushes enabled targets). - // Secondary sites only: the main site's targets are never seeded disabled, because it - // has always had a collector. - if (!_isDefault && !wasEnrolled && _siteAgentEnrolled) + if (!wasEnrolled && _siteAgentEnrolled) await EnableSeededDefaultTargetsAsync(ct); } catch (Exception ex) From 21e6d6f0117a356a1603ba027c86da7f3ed076a7 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:26:23 -0500 Subject: [PATCH 07/63] Revert "Keep the server collecting for a main site whose agent is offline" This reverts commit 5d3d98e0ad1a85237d1ec1c45197afce1584e24c. --- .../Services/MonitoringCollectionAgent.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index 27a5035c60..bc1a64d4f6 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -225,7 +225,14 @@ private bool AgentCoversCollection() private async Task RefreshAgentCoverageAsync(CancellationToken ct) { - if (_isDefault) return; + // The default site is refreshed too. It used to be skipped, which left its enrolled flag + // permanently false, so only a LIVE tunnel counted as an agent being present there. A main + // site handed to its agent then kept collecting whenever that agent was merely offline - + // and with its devices routed through the tunnel, every one of those polls dialed a + // loopback proxy with nothing behind it. A secondary site counts an enrolled agent whether + // or not it is connected, and stands down; this makes the main site behave the same once + // it has been handed over. With coverage off the answer is false either way, so an install + // that has not opted in is unaffected. if (DateTime.UtcNow - _agentCoverageCheckedAt < AgentCoverageTtl) return; try { @@ -239,7 +246,9 @@ private async Task RefreshAgentCoverageAsync(CancellationToken ct) // The moment an external site first gains an agent, activate the default internet // targets that were seeded disabled while it had none - the agent can now probe them // from inside the site (AgentProbeResultSink only pushes enabled targets). - if (!wasEnrolled && _siteAgentEnrolled) + // Secondary sites only: the main site's targets are never seeded disabled, because it + // has always had a collector. + if (!_isDefault && !wasEnrolled && _siteAgentEnrolled) await EnableSeededDefaultTargetsAsync(ct); } catch (Exception ex) From 6f35091992967111c4f339364e4f78b5eda30c34 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:27:19 -0500 Subject: [PATCH 08/63] Reapply "Keep the server collecting for a main site whose agent is offline" This reverts commit 21e6d6f0117a356a1603ba027c86da7f3ed076a7. --- .../Services/MonitoringCollectionAgent.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index bc1a64d4f6..27a5035c60 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -225,14 +225,7 @@ private bool AgentCoversCollection() private async Task RefreshAgentCoverageAsync(CancellationToken ct) { - // The default site is refreshed too. It used to be skipped, which left its enrolled flag - // permanently false, so only a LIVE tunnel counted as an agent being present there. A main - // site handed to its agent then kept collecting whenever that agent was merely offline - - // and with its devices routed through the tunnel, every one of those polls dialed a - // loopback proxy with nothing behind it. A secondary site counts an enrolled agent whether - // or not it is connected, and stands down; this makes the main site behave the same once - // it has been handed over. With coverage off the answer is false either way, so an install - // that has not opted in is unaffected. + if (_isDefault) return; if (DateTime.UtcNow - _agentCoverageCheckedAt < AgentCoverageTtl) return; try { @@ -246,9 +239,7 @@ private async Task RefreshAgentCoverageAsync(CancellationToken ct) // The moment an external site first gains an agent, activate the default internet // targets that were seeded disabled while it had none - the agent can now probe them // from inside the site (AgentProbeResultSink only pushes enabled targets). - // Secondary sites only: the main site's targets are never seeded disabled, because it - // has always had a collector. - if (!_isDefault && !wasEnrolled && _siteAgentEnrolled) + if (!wasEnrolled && _siteAgentEnrolled) await EnableSeededDefaultTargetsAsync(ct); } catch (Exception ex) From 6fd9e494b162654d72add7588ca31dfdcc61bb14 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:31:33 -0500 Subject: [PATCH 09/63] Device SSH says it is waiting for the agent, not that the banner was wrong Gateway SSH already answers with the awaiting-agent message when the site's devices are tunnel-routed and no agent is online. The shared device SSH path did not, so it dialed the loopback proxy with nothing behind it and surfaced SSH.NET's 'no identification string' - true of the socket, useless to the reader. It is what qmicli cellular modem polling rides on, which is where it showed up. The cable modem, ONT and Starlink pollers are HTTP or gRPC and never had this shape. --- .../Services/UniFiSshService.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/NetworkOptimizer.Web/Services/UniFiSshService.cs b/src/NetworkOptimizer.Web/Services/UniFiSshService.cs index 95972dd81f..978f6010d9 100644 --- a/src/NetworkOptimizer.Web/Services/UniFiSshService.cs +++ b/src/NetworkOptimizer.Web/Services/UniFiSshService.cs @@ -140,6 +140,22 @@ public async Task SaveSettingsAsync(UniFiSshSettings settings) /// /// Test SSH connection to a specific host using shared credentials /// + /// + /// Shown when this site's devices are reached through its on-site agent and that agent isn't + /// online. Dialing the loopback tunnel proxy now gets a closed socket, which SSH.NET reports as + /// a raw "no identification string" protocol error - true of the socket, useless to the reader. + /// Mirrors the gateway's message. + /// + public const string AwaitingAgentMessage = + "Waiting for the on-site agent to connect. This site's devices are reached through its agent, and will connect automatically once the agent is online."; + + private async Task IsAwaitingAgentAsync() + { + var routing = _serviceProvider.GetService(); + if (routing == null) return false; + return await routing.IsViaAgentAsync(_siteSlug) && !routing.IsAgentOnline(_siteSlug); + } + public async Task<(bool success, string message)> TestConnectionAsync(string host) { var settings = await GetSettingsAsync(); @@ -149,6 +165,11 @@ public async Task SaveSettingsAsync(UniFiSshSettings settings) return (false, "SSH credentials not configured"); } + if (await IsAwaitingAgentAsync()) + { + return (false, AwaitingAgentMessage); + } + try { // Use echo without quotes for cross-platform compatibility (Windows/Linux) @@ -192,6 +213,11 @@ public async Task SaveSettingsAsync(UniFiSshSettings settings) string? privateKeyPathOverride, CancellationToken cancellationToken = default) { + if (await IsAwaitingAgentAsync()) + { + return (false, AwaitingAgentMessage); + } + var settings = await GetSettingsAsync(); // Determine effective credentials (per-device overrides take precedence) From 6676875c9545edf55888626257a17953253577fb Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 00:12:37 -0500 Subject: [PATCH 10/63] Drop probe results from a main site agent that is not covering it The push path refuses to send targets to a main-site agent unless the site has been handed to it. The write path had no such check, so it recorded whatever arrived. Switching coverage off stops the config going out but does not stop an agent that already has targets: it keeps probing and pushing while the server resumes probing the same targets itself. Both then write one series at different cadences - identical means, mismatched sample rates - which renders as a sawtooth rather than as obvious duplicates. Needs an agent mid-probe when coverage flips, so it takes toggling to reach. Not paired with pushing a stop to the agent. That would turn a transiently false coverage read on a healthy site into a cleared target list and a real data gap, which is a worse trade than some wasted pings that are now discarded anyway. --- .../Services/AgentProbeResultSink.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs b/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs index d02951365e..681deff12b 100644 --- a/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs +++ b/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs @@ -1239,6 +1239,15 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult if (batch.Results.Count == 0) return; var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; + + // The push path already refuses to send targets to a main-site agent that is not covering + // the site; results are refused for the same reason. Switching coverage off stops the + // config going out but does not stop an agent that already has targets, so it keeps + // probing and pushing while the server resumes probing the same targets itself. Both write + // the same series at different cadences, which reads as a sawtooth on the charts rather + // than as duplicate points. + if (isDefault && !await _agentCoverage.CoversAsync(connection.SiteSlug)) return; + await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); var ids = batch.Results.Select(r => r.TargetId).Distinct().ToList(); var targets = await db.MonitoringTargets From e21a82812c318cdc5124ce547adf849fc4bb870d Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:21:39 -0500 Subject: [PATCH 11/63] Agent docs: point the reverse proxy section at the companion Traefik repo The Reverse proxy section jumped straight into hand-rolled Traefik/Caddy/nginx config without mentioning NetworkOptimizer-Proxy, which ships the agent tunnel route enabled by default. It also never stated plainly that the proxy is a prerequisite rather than optional polish, so a reader running the app on a bare LAN address had no cue that TLS termination was theirs to stand up. Also preserve the original Host on the Caddy gRPC route. Optional in practice - the tunnel service does no host-based routing and gRPC is exempt from canonical redirect enforcement - but it keeps the example explicit. --- src/NetworkOptimizer.Agent/README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Agent/README.md b/src/NetworkOptimizer.Agent/README.md index 8b0d87120f..98e82334bc 100644 --- a/src/NetworkOptimizer.Agent/README.md +++ b/src/NetworkOptimizer.Agent/README.md @@ -353,6 +353,18 @@ journalctl -u netopt-agent -f ## Reverse proxy +The central server never serves TLS itself - it binds plain HTTP on 8042 by +design, and a reverse proxy in front terminates TLS and manages certificates. +That proxy is a prerequisite for agents, not a finishing touch: the agent speaks +HTTPS only and refuses to start against an `http://` server URL. + +If you don't already run one, +**[NetworkOptimizer-Proxy](https://github.com/Ozark-Connect/NetworkOptimizer-Proxy)** +is a ready-to-use Traefik setup (Let's Encrypt certificates via Cloudflare +DNS-01) that ships the agent tunnel route **enabled by default** - point it at +your hostname and there is nothing else to configure for agents. The rest of +this section is for folding the tunnel into a proxy you already run. + The tunnel listener speaks HTTP/2 over TLS with an ephemeral self-signed certificate: the reverse proxy fronting the central server terminates the agent's public TLS and re-encrypts to the tunnel port, skipping verification on @@ -397,8 +409,11 @@ serversTransports: ```caddyfile optimizer.example.com { @grpc path /networkoptimizer.agent.v1.AgentTunnel/* - reverse_proxy @grpc https://127.0.0.1:8043 { - transport http { tls_insecure_skip_verify } + reverse_proxy @grpc https://localhost:8043 { + transport http { + tls_insecure_skip_verify + } + header_up Host {http.request.host} } reverse_proxy 127.0.0.1:8042 } From 23752b3bb77921a99431a1680e083291d9564025 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:22:14 -0500 Subject: [PATCH 12/63] Agent docs: forward the original Host in the nginx proxy examples Unlike Traefik and Caddy, nginx sends the upstream address as the Host by default on both proxy_pass and grpc_pass, so the examples were the odd ones out. It is cosmetic on the gRPC route - the tunnel does no host-based routing and gRPC is exempt from canonical redirect enforcement - but it matters on the app route, where enforcement is active and keys on Host, and an install following the example verbatim would drive that off 127.0.0.1:8042. --- src/NetworkOptimizer.Agent/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/NetworkOptimizer.Agent/README.md b/src/NetworkOptimizer.Agent/README.md index 98e82334bc..4efb12d7d2 100644 --- a/src/NetworkOptimizer.Agent/README.md +++ b/src/NetworkOptimizer.Agent/README.md @@ -425,9 +425,11 @@ optimizer.example.com { location /networkoptimizer.agent.v1.AgentTunnel/ { grpc_pass grpcs://127.0.0.1:8043; grpc_ssl_verify off; + grpc_set_header Host $host; } location / { proxy_pass http://127.0.0.1:8042; + proxy_set_header Host $host; } ``` From 0c3295c16faacea55fa00fc7f2174af7da594736 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sun, 2 Aug 2026 23:25:14 -0500 Subject: [PATCH 13/63] Agent docs: remind operators to set REVERSE_PROXIED_HOST_NAME after the proxy The variable is named in the opening paragraph as where the agent's server URL comes from, but nothing says to go set it once the proxy exists. That is the gap operators fall into: the proxy comes up, the Agents panel still shows a placeholder because the app has no canonical address, and they substitute the app's own LAN address - which the agent then dials on 443, where nothing is listening. Adds a short step at the end of the Reverse proxy section with the health-check curl to run before enrolling. --- src/NetworkOptimizer.Agent/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/NetworkOptimizer.Agent/README.md b/src/NetworkOptimizer.Agent/README.md index 4efb12d7d2..988e4abd3e 100644 --- a/src/NetworkOptimizer.Agent/README.md +++ b/src/NetworkOptimizer.Agent/README.md @@ -440,6 +440,26 @@ Everything rides that one TLS session: heartbeats, probe and SNMP traffic (including SNMP credentials pushed to the agent), and proxied UniFi Console connections - which are additionally HTTPS end-to-end inside the tunnel. +### After the proxy is up: tell the app its address + +Set **`REVERSE_PROXIED_HOST_NAME`** on the central server to the proxy's +hostname (plus `REVERSE_PROXIED_PORT` if the proxy's front end is not on 443), +then restart it. This is what the agent's server URL is derived from, so until +it is set, **Settings > Multi-Site > (site) > Agents** has no address to put in +the install command and shows a placeholder instead. Substituting the app's own +LAN address there does not work: the agent would dial that host on 443, where +the app does not listen and the proxy is not running. + +Verify before enrolling an agent - from the site, or anywhere outside the +server's own box: + +```bash +curl -sSf https://optimizer.example.com/api/health +``` + +That has to succeed over HTTPS on the hostname you configured. If it does not, +fix the proxy first; the agent has no fallback to plain HTTP by design. + ## Security and hardening The agent dials out only, so the site never exposes an inbound port - a real From 273a61ab1f7e1c85b15bb26b82f05c07f19b51dd Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 00:02:17 -0500 Subject: [PATCH 14/63] MSI build: always publish into an empty folder Publishing incrementally over a warm tree - no compilable change since the last build - recreates package content folders such as LatoFont EMPTY rather than leaving them alone. WiX then harvests the empty folder and packages an MSI that is missing files, with no warning and a build that reports success. The v2.5.3 MSI lost its 19 Lato font files this way, and the only reason it was caught was comparing the artifact size against the published asset. The release flow does not normally hit this, since the MSI is built once right after a merge that changed code. It does hit a rebuild after a failed upload, and a docs-only or template-only release, where nothing needs recompiling. Removing the publish folder first forces the publish target to repopulate it. The build output is untouched, so the cost is a file copy rather than a recompile. Verified against the exact failure condition: a second build on a warm tree now produces a byte-identical file table to a known-good MSI. --- scripts/build-installer.ps1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 3cbb411e97..26198a2e88 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -37,6 +37,20 @@ Write-Host "" # Step 1: Publish self-contained single-file application Write-Host "[1/5] Publishing self-contained single-file application for win-x64..." -ForegroundColor Yellow + +# Always start from an empty publish folder. Publishing incrementally over a warm +# tree - no compilable change since the last build, e.g. a docs-only release or a +# rebuild after a failed upload - recreates package content folders such as +# LatoFont EMPTY. WiX then harvests the empty folder and packages an MSI that is +# missing files, with no warning and a successful build. That silently cost the +# v2.5.3 MSI its 19 Lato font files. Removing the folder forces the publish target +# to repopulate it; the build output is untouched, so this costs a file copy +# rather than a recompile. +if (Test-Path $PublishDir) { + Write-Host " Cleaning previous publish output..." -ForegroundColor DarkGray + Remove-Item -Recurse -Force $PublishDir +} + dotnet publish $WebProject ` -c $Configuration ` -r win-x64 ` From 01aa11845c7199a70cd56fa2f9a177090f6807bf Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 00:10:14 -0500 Subject: [PATCH 15/63] macOS install: stop building the unused cfspeedtest binary Nothing invokes the standalone cfspeedtest binary any more - uwnspeedtest superseded it for gateway WAN tests, and the app resolves that one (UwnClientRunner). There are no references to cfspeedtest in C#, Razor, or the WiX authoring at all, so every macOS install and update was spending a Go cross-compile and ~5.5 MB on a binary that is never run. The src/cfspeedtest module stays: uwnspeedtest imports its speedtest package (src/uwnspeedtest/go.mod), and both Dockerfiles copy it for that reason. Only the standalone binary build is dropped, and it is commented rather than deleted in case it is wanted again. --- scripts/install-macos-native.sh | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/install-macos-native.sh b/scripts/install-macos-native.sh index 57453e795b..465f956e51 100755 --- a/scripts/install-macos-native.sh +++ b/scripts/install-macos-native.sh @@ -276,16 +276,20 @@ if command -v go &> /dev/null; then GO_ARCH="arm64" fi - CFSPEEDTEST_SRC="$REPO_ROOT/src/cfspeedtest" - if [ -d "$CFSPEEDTEST_SRC" ]; then - cd "$CFSPEEDTEST_SRC" - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -trimpath \ - -ldflags "-s -w -X main.version=$GO_VERSION" \ - -o "$INSTALL_DIR/tools/cfspeedtest-linux-arm64" . - echo "Built cfspeedtest for linux/arm64" - else - echo "Warning: cfspeedtest source not found at $CFSPEEDTEST_SRC" - fi + # cfspeedtest is no longer deployed: nothing in the app invokes the standalone + # binary any more, uwnspeedtest below superseded it for gateway WAN tests. The + # src/cfspeedtest module itself stays, since uwnspeedtest imports its speedtest + # package. Left commented rather than deleted in case the binary is wanted again. + # CFSPEEDTEST_SRC="$REPO_ROOT/src/cfspeedtest" + # if [ -d "$CFSPEEDTEST_SRC" ]; then + # cd "$CFSPEEDTEST_SRC" + # CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -trimpath \ + # -ldflags "-s -w -X main.version=$GO_VERSION" \ + # -o "$INSTALL_DIR/tools/cfspeedtest-linux-arm64" . + # echo "Built cfspeedtest for linux/arm64" + # else + # echo "Warning: cfspeedtest source not found at $CFSPEEDTEST_SRC" + # fi UWNSPEEDTEST_SRC="$REPO_ROOT/src/uwnspeedtest" if [ -d "$UWNSPEEDTEST_SRC" ]; then From f3c17f92e8edff67d10b199971e0be00cfb0d78a Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 00:21:32 -0500 Subject: [PATCH 16/63] MSI build: refresh the Traefik config templates on every build The DownloadTraefik target only fired when traefik.exe or a template was missing, and the script then skipped any template that already existed. Both gates were presence checks, never freshness checks, so the first MSI build on a machine staged the templates permanently. This build box shipped five-month-old templates that way, missing the multi-site agent tunnel route the companion repo had added in the meantime, and nothing surfaced it. Templates now re-download every build. They are a few KB and track Ozark-Connect/NetworkOptimizer-Proxy, so they are the part that drifts. The pinned 170 MB binary is skipped when already staged, which it previously was not once the target fired, so a build costs less network than before rather than more. Each template downloads to a temp file and is moved into place only on success, so a partial fetch cannot truncate a good staged copy. A failed fetch with a copy already staged warns and continues, keeping offline builds working; with nothing staged it stays fatal. Verified end to end: the target fires during the WiX build, skips the binary, re-fetches both templates, and the resulting MSI is byte-identical in file table to the published v2.5.3 artifact. --- .../NetworkOptimizer.Installer.wixproj | 6 +- .../Traefik/Download-Traefik.ps1 | 112 +++++++++++------- 2 files changed, 72 insertions(+), 46 deletions(-) diff --git a/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj b/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj index 24b9e3a927..d333e5588b 100644 --- a/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj +++ b/src/NetworkOptimizer.Installer/NetworkOptimizer.Installer.wixproj @@ -56,7 +56,11 @@ - + + diff --git a/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 b/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 index 069790ab97..2cc22319f9 100644 --- a/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 +++ b/src/NetworkOptimizer.Installer/Traefik/Download-Traefik.ps1 @@ -12,53 +12,62 @@ $TraefikZip = "traefik_v${Version}_windows_amd64.zip" $TraefikUrl = "https://github.com/traefik/traefik/releases/download/v${Version}/$TraefikZip" $TempFile = Join-Path $env:TEMP $TraefikZip -Write-Host "Downloading Traefik v$Version for Windows..." +# Ensure output directory exists +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir | Out-Null +} -# Download Traefik -if (-not (Test-Path $TempFile)) { - try { - Invoke-WebRequest -Uri $TraefikUrl -OutFile $TempFile - Write-Host "Downloaded to $TempFile" - } - catch { - Write-Error "Failed to download Traefik from $TraefikUrl. Error: $_" - exit 1 - } +# The binary is version-pinned and 170 MB, so fetch it only when it is missing. +# The templates below are refreshed on every build instead - they track the +# companion repo and are the part that actually drifts. +$TraefikExePath = Join-Path $OutputDir "traefik.exe" +if (Test-Path $TraefikExePath) { + Write-Host "traefik.exe already staged, skipping binary download" } else { - Write-Host "Using cached download at $TempFile" -} + Write-Host "Downloading Traefik v$Version for Windows..." -# Extract to temp directory -$ExtractPath = Join-Path $env:TEMP "traefik-extract" -if (Test-Path $ExtractPath) { - Remove-Item -Recurse -Force $ExtractPath -} + # Download Traefik + if (-not (Test-Path $TempFile)) { + try { + Invoke-WebRequest -Uri $TraefikUrl -OutFile $TempFile + Write-Host "Downloaded to $TempFile" + } + catch { + Write-Error "Failed to download Traefik from $TraefikUrl. Error: $_" + exit 1 + } + } + else { + Write-Host "Using cached download at $TempFile" + } -Write-Host "Extracting..." -Expand-Archive -Path $TempFile -DestinationPath $ExtractPath -Force + # Extract to temp directory + $ExtractPath = Join-Path $env:TEMP "traefik-extract" + if (Test-Path $ExtractPath) { + Remove-Item -Recurse -Force $ExtractPath + } -# Find traefik.exe in the extracted contents -$TraefikExe = Get-ChildItem -Path $ExtractPath -Recurse -Filter "traefik.exe" | Select-Object -First 1 + Write-Host "Extracting..." + Expand-Archive -Path $TempFile -DestinationPath $ExtractPath -Force -if (-not $TraefikExe) { - Write-Error "traefik.exe not found in downloaded archive" - exit 1 -} + # Find traefik.exe in the extracted contents + $TraefikExe = Get-ChildItem -Path $ExtractPath -Recurse -Filter "traefik.exe" | Select-Object -First 1 -# Ensure output directory exists -if (-not (Test-Path $OutputDir)) { - New-Item -ItemType Directory -Path $OutputDir | Out-Null -} + if (-not $TraefikExe) { + Write-Error "traefik.exe not found in downloaded archive" + exit 1 + } -# Copy traefik.exe to output -Copy-Item $TraefikExe.FullName -Destination $OutputDir -Force -Write-Host "Copied traefik.exe to $OutputDir" + # Copy traefik.exe to output + Copy-Item $TraefikExe.FullName -Destination $OutputDir -Force + Write-Host "Copied traefik.exe to $OutputDir" -# Cleanup -Remove-Item -Recurse -Force $ExtractPath + # Cleanup + Remove-Item -Recurse -Force $ExtractPath -Write-Host "Traefik v$Version ready at $OutputDir" + Write-Host "Traefik v$Version ready at $OutputDir" +} # Download config templates from NetworkOptimizer-Proxy repo $TemplatesDir = Join-Path $OutputDir "templates" @@ -69,22 +78,35 @@ if (-not (Test-Path $TemplatesDir)) { $BaseUrl = "https://raw.githubusercontent.com/Ozark-Connect/NetworkOptimizer-Proxy/main/windows" $Templates = @("traefik.yml.template", "config.yml.template") +# Always re-fetch the templates. They previously downloaded only when absent, so +# the first MSI build on a machine froze them forever - a build box shipped +# five-month-old templates that way, missing the multi-site agent tunnel route +# the companion repo had since added. They are a few KB, so refreshing every +# build is free. +# +# Download to a temp file and move into place only on success, so a failed or +# partial fetch can never truncate a good staged template. If the fetch fails and +# a copy is already staged, keep it and warn: that keeps offline builds working. +# With no staged copy there is nothing to fall back to, so that stays fatal. foreach ($Template in $Templates) { $DestPath = Join-Path $TemplatesDir $Template - if (-not (Test-Path $DestPath)) { - Write-Host "Downloading $Template..." - try { - Invoke-WebRequest -Uri "$BaseUrl/$Template" -OutFile $DestPath - Write-Host " Saved to $DestPath" + $TmpPath = "$DestPath.download" + Write-Host "Downloading $Template..." + try { + Invoke-WebRequest -Uri "$BaseUrl/$Template" -OutFile $TmpPath + Move-Item -Path $TmpPath -Destination $DestPath -Force + Write-Host " Saved to $DestPath" + } + catch { + Remove-Item $TmpPath -Force -ErrorAction SilentlyContinue + if (Test-Path $DestPath) { + Write-Warning "Could not refresh $Template ($_). Using the staged copy at $DestPath." } - catch { + else { Write-Error "Failed to download $Template from $BaseUrl/$Template. Error: $_" exit 1 } } - else { - Write-Host "Template already exists: $DestPath" - } } # List contents From 7dbf8e0842b0d15c9dc6a97f1b8534736b3d3011 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 00:32:11 -0500 Subject: [PATCH 17/63] Agent coverage is known before collection starts, not a second later The coverage flag cached with a one-minute expiry, and the synchronous reader answered false on a miss while it refilled. Every expiry therefore opened a window in which a covered site read as uncovered: probes resolved to the local executor, device routing dialed direct instead of through the tunnel, and a console connecting in that window connected direct and stayed there. Harmless on a server sitting on the network it monitors. On the off-site server this feature exists for, it means probing from the wrong network and writing the result as that site's, plus dialing the site's RFC1918 addresses on the hosting provider's network. Every writer already calls Invalidate, so the expiry bought nothing. The cache now holds until invalidated and is warmed for all sites at startup, which closes the window rather than shortening it. What it gives up is noticing a value changed in the database behind the app's back - an operator editing SQLite by hand - and a restart settles that. --- src/NetworkOptimizer.Web/Program.cs | 4 ++ .../Services/SiteAgentCoverage.cs | 56 ++++++++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/NetworkOptimizer.Web/Program.cs b/src/NetworkOptimizer.Web/Program.cs index 6d8fa27eb2..d8004482f6 100644 --- a/src/NetworkOptimizer.Web/Program.cs +++ b/src/NetworkOptimizer.Web/Program.cs @@ -1237,6 +1237,10 @@ ProductVersion TEXT NOT NULL var ieeeOuiDb = app.Services.GetRequiredService(); await ieeeOuiDb.InitializeAsync(); +// Warm the agent-coverage flags before collection starts, so no synchronous gate answers +// "not covered" for a site that is while the cache fills. +await app.Services.GetRequiredService().WarmAsync(); + // Log admin auth startup configuration using (var startupScope = app.Services.CreateScope()) { diff --git a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs index 98c360c284..285dacb4e6 100644 --- a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs +++ b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs @@ -26,12 +26,16 @@ public class SiteAgentCoverage /// Per-site setting key: this site's agent collects, this server stands down. public const string AgentCoversSiteKey = "site.agent_covers_collection"; - // Consulted on collection paths that run every few seconds, so cache it briefly rather than - // hitting SQLite each time - same treatment as the via-agent routing flag. - private static readonly TimeSpan FlagCacheExpiry = TimeSpan.FromMinutes(1); - + // Consulted on collection paths that run every few seconds, so it is cached rather than hitting + // SQLite each time. Deliberately WITHOUT an expiry: every writer calls Invalidate, so a timed + // expiry bought nothing and cost a cold-miss window in which the synchronous reader below + // answers "not covered" for a site that is. On an off-site server that window means probes run + // from the wrong network and device dials go to RFC1918 addresses on the hosting provider's + // network instead of through the tunnel. The cache is warmed at startup for the same reason. + // The one thing this gives up is noticing a value changed in the database behind the app's + // back, which only an operator editing SQLite directly can do, and a restart settles that. private readonly IServiceProvider _serviceProvider; - private readonly ConcurrentDictionary _flags = new(); + private readonly ConcurrentDictionary _flags = new(); public SiteAgentCoverage(IServiceProvider serviceProvider) { @@ -42,21 +46,20 @@ public SiteAgentCoverage(IServiceProvider serviceProvider) public async Task CoversAsync(string slug) { if (string.IsNullOrEmpty(slug)) return false; - if (_flags.TryGetValue(slug, out var cached) && DateTime.UtcNow - cached.At < FlagCacheExpiry) - return cached.Enabled; + if (_flags.TryGetValue(slug, out var cached)) return cached; return await ReadAsync(slug); } /// - /// The cached answer, for the callers that cannot await - the probe executor factory resolves - /// a vantage from a synchronous property. A cache miss reads false and refreshes in the - /// background, so the worst case is one pass of today's behavior before the flag takes hold. + /// The cached answer, for the callers that cannot await - the probe executor factory resolves a + /// vantage from a synchronous property. The cache is warmed at startup and never expires, so a + /// miss here means a site created since startup, which has no flag set anyway. It still kicks a + /// read so the answer is right from the next pass. /// public bool Covers(string slug) { if (string.IsNullOrEmpty(slug)) return false; - if (_flags.TryGetValue(slug, out var cached) && DateTime.UtcNow - cached.At < FlagCacheExpiry) - return cached.Enabled; + if (_flags.TryGetValue(slug, out var cached)) return cached; _ = Task.Run(() => ReadAsync(slug)); return false; } @@ -64,6 +67,33 @@ public bool Covers(string slug) /// Drops the cached answer for a site, so the next read sees a change immediately. public void Invalidate(string slug) => _flags.TryRemove(slug, out _); + /// + /// Reads every site's flag once at startup. Without this the first pass of any synchronous + /// caller answers "not covered" while the cache fills, and on an off-site server that pass + /// probes from the wrong network and dials site addresses directly. + /// + public async Task WarmAsync(CancellationToken ct = default) + { + try + { + List slugs; + using (var scope = _serviceProvider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + slugs = db.Sites.Select(x => x.Slug).ToList(); + } + foreach (var slug in slugs) + { + if (ct.IsCancellationRequested) return; + await ReadAsync(slug); + } + } + catch + { + // Best effort: a failure here leaves the old lazy behavior, not a broken start. + } + } + /// /// The question every gate actually asks: does the agent do this site's work rather than this /// server? A secondary site needs only an agent, which is what having one has always meant @@ -89,7 +119,7 @@ private async Task ReadAsync(string slug) var db = scope.ServiceProvider.GetRequiredService(); var setting = await db.SystemSettings.FindAsync(AgentCoversSiteKey); var enabled = bool.TryParse(setting?.Value, out var value) && value; - _flags[slug] = (enabled, DateTime.UtcNow); + _flags[slug] = enabled; return enabled; } catch From 4ec8d41f124f045a849385f35109315fa57f2c12 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 00:34:22 -0500 Subject: [PATCH 18/63] Sweep the agent coverage cache with the other per-site registries The cached flag no longer expires, so it now outlives the site that set it. A slug deleted and re-created would inherit the previous site's coverage answer until the next restart - the same shape as the per-site registries that already get swept, and previously hidden by the one-minute expiry. SiteAgentCoverage joins that sweep, which runs on removal and on creation. Nothing to tear down, so it evicts and returns null. --- src/NetworkOptimizer.Web/Program.cs | 2 +- .../Services/SiteAgentCoverage.cs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Program.cs b/src/NetworkOptimizer.Web/Program.cs index d8004482f6..a2c7a10474 100644 --- a/src/NetworkOptimizer.Web/Program.cs +++ b/src/NetworkOptimizer.Web/Program.cs @@ -318,7 +318,7 @@ builder.Services.AddSingleton(); // Whether a site's agent collects instead of this server. Singleton: consulted by the // per-site collection loops and the probe executor factory, and it caches per slug. -builder.Services.AddSingleton(); +builder.Services.AddSiteScopedRegistry(); builder.Services.AddSiteScopedRegistry(); builder.Services.AddScoped(sp => sp.GetRequiredService() .GetFor(sp.GetRequiredService().Slug)); diff --git a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs index 285dacb4e6..90a1dec085 100644 --- a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs +++ b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs @@ -21,7 +21,7 @@ namespace NetworkOptimizer.Web.Services; /// enrolled (), so a flag set on a site with no /// agent changes nothing. /// -public class SiteAgentCoverage +public class SiteAgentCoverage : ISiteScopedRegistry { /// Per-site setting key: this site's agent collects, this server stands down. public const string AgentCoversSiteKey = "site.agent_covers_collection"; @@ -67,6 +67,18 @@ public bool Covers(string slug) /// Drops the cached answer for a site, so the next read sees a change immediately. public void Invalidate(string slug) => _flags.TryRemove(slug, out _); + /// + /// Swept with the per-site registries when a site is removed or created. The cached answer now + /// outlives the site that set it - there is no expiry to heal it - so a slug deleted and + /// re-created would otherwise inherit the previous site's coverage until the next restart. + /// Nothing to tear down: the entry is a bool. + /// + public Func? EvictSite(string slug) + { + Invalidate(slug); + return null; + } + /// /// Reads every site's flag once at startup. Without this the first pass of any synchronous /// caller answers "not covered" while the cache fills, and on an off-site server that pass From 69ba51ba4ada6fc015b7951f17c169850387cba4 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 08:36:51 -0500 Subject: [PATCH 19/63] Run Test from Gateway says why it cannot run The handler returned silently when the WAN interface list was empty, so with the console unreachable the button did nothing at all - no error, no state change. The list comes from the console, and this is the one control on the page that depends on it, so it is also the one that has to explain itself. Run Test from Agent does not need the list, which is why it behaved. It distinguishes waiting for the site's agent from the console simply being unreachable, because those need different things from the reader. --- .../Components/Pages/WanSpeedTest.razor | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor b/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor index 67dc73b59c..b897a5e800 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor @@ -1863,7 +1863,19 @@ private async Task RunGatewayTest() { - if (_isRunning || _wanInterfaces.Count == 0) return; + if (_isRunning) return; + + // The WAN list comes from the console, and a gateway test cannot pick an interface without + // it. Returning silently here left the button looking broken - it is the one control on + // this page that depends on the console, so it is also the one that has to say so. + if (_wanInterfaces.Count == 0) + { + _errorMessage = ConnectionService.IsAwaitingAgent + ? "Waiting for the on-site agent to connect. The gateway test needs the WAN interface list from this site's UniFi Console, which is reached through its agent." + : "The UniFi Console is unreachable, so the WAN interface list isn't available for a gateway test."; + StateHasChanged(); + return; + } // Resolve the selected option into interface list and WAN metadata var selected = _wanSelectorOptions.FirstOrDefault(o => o.Value == _selectedWanOption); From f3bcd7637978aae91448ae2dd9f006ebb528a027 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 08:41:34 -0500 Subject: [PATCH 20/63] Probing stands down on configuration, SNMP on the agent actually being there One question gated both, and they are not the same question. SNMP reads the device's own counters, identical whoever asks, so the server carrying on while the agent is offline is a real fallback - the site keeps its device history instead of going dark. A probe measures the path FROM whoever runs it. The server running one for a site its agent covers describes the server's route, not the site's, and stores it under the site's name regardless. On an off-site server that is a different network. The upstream tracer had the same shape, falling back to the local executor and tracing from the wrong place. Probing and tracing now stand down on the configuration alone and let the probe fail while the agent is away: a gap is honest, a number from the wrong vantage is not, and mixing the two is what put the sawtooth in the charts. SNMP call sites are unchanged. --- .../Monitoring/ProbeExecutorFactory.cs | 2 +- .../Monitoring/UpstreamTracerRegistry.cs | 2 +- .../Services/MonitoringCollectionAgent.cs | 12 ++++++++++-- .../Services/SiteAgentCoverage.cs | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs b/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs index c9a7233037..4b4765bd16 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/ProbeExecutorFactory.cs @@ -75,7 +75,7 @@ public IProbeExecutor GetServer() /// Whether the "server" vantage resolves to the on-site agent for the current site. public bool ServerVantageIsAgent => - _agentCoverage.AgentCovers(_siteContext.Slug, _agentProbe.HasAgentForSite(_siteContext.Slug)); + _agentCoverage.AgentOwnsPathMeasurement(_siteContext.Slug); /// /// Build an executor that runs probes from the chosen UniFi device via SSH. Returns diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs index dd6ecf54a6..3da5fe0bdf 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/UpstreamTracerRegistry.cs @@ -72,7 +72,7 @@ public UpstreamTracerService GetFor(string slug) => _instances.GetOrAdd(slug, s // the life of the process, so a flag changed afterwards would otherwise never be seen. var agentExecutor = new AgentProbeExecutor(_agentProbe, s, _loggerFactory.CreateLogger()); Func traceExecutor = () => - !isDefault || _agentCoverage.AgentCovers(s, _agentProbe.HasAgentForSite(s)) + !isDefault || _agentCoverage.AgentOwnsPathMeasurement(s) ? agentExecutor : _localProbe; return new UpstreamTracerService( diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index 27a5035c60..3c104f0434 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -124,7 +124,7 @@ public class MonitoringCollectionAgent : BackgroundService /// from inside instead - and on the default site too once it is configured for its agent to /// cover it. A status display must not claim the server is collecting where it is not. /// - public bool ServerProbesThisSite => _isDefault && !AgentCoversCollection(); + public bool ServerProbesThisSite => _isDefault && !AgentOwnsProbing(); /// /// Lets the Setup page's interactive re-check override the cached self-heal sighting @@ -206,6 +206,14 @@ private async Task CreateSiteDbAsync(CancellationToke /// cover it: a default-site agent is an ADDITIONAL vantage point by default, not a replacement /// for local collection, and that is what installs using one today rely on. /// + /// + /// Whether the agent owns this site's probing. Configuration only - unlike + /// , an agent that is merely offline does NOT hand probing + /// back to this server, because a probe from here measures a different path and would be + /// recorded as this site's. + /// + private bool AgentOwnsProbing() => _agentCoverage.AgentOwnsPathMeasurement(_siteSlug); + private bool AgentCoversCollection() { var agentPresent = _tunnelRegistry.GetForSite(_siteSlug).Count > 0 || _siteAgentEnrolled; @@ -1434,7 +1442,7 @@ private async Task LatencyTierCollectAsync(MonitoringSettings settings, Cancella // log its own anycast RTT as the site's ISP latency. The site's agent probes its enabled // targets from inside once deployed (AgentProbeResultSink). The default site keeps probing // locally unless it too is covered by its agent, which is the off-site-server case. - if (!_isDefault || AgentCoversCollection()) return; + if (!_isDefault || AgentOwnsProbing()) return; await using var db = await CreateSiteDbAsync(ct); var targets = await db.MonitoringTargets diff --git a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs index 90a1dec085..25918b8583 100644 --- a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs +++ b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs @@ -118,6 +118,25 @@ public async Task WarmAsync(CancellationToken ct = default) public bool AgentCovers(string slug, bool agentPresent) => agentPresent && (slug != SiteManagementService.DefaultSiteSlug || Covers(slug)); + /// + /// Whether the site's agent owns PATH measurement for this site - latency and loss probes, and + /// upstream traceroutes. Configuration alone, deliberately without asking whether the agent is + /// connected right now. + /// + /// A probe measures the path FROM whoever runs it. If this server runs one for a site its agent + /// covers, the result describes this server's route rather than the site's, and it is stored + /// under the site's name either way. On an off-site server that is a different network + /// entirely. A probe that does not run leaves a gap; a probe run from the wrong place leaves a + /// wrong number that looks exactly like data - so this stands down on the configuration and + /// lets the probe fail while the agent is away. + /// + /// Contrast , which is the right question for reading device counters: + /// SNMP returns the device's own numbers whoever asks, so the server continuing while the agent + /// is down is a genuine fallback rather than a different measurement. + /// + public bool AgentOwnsPathMeasurement(string slug) + => slug != SiteManagementService.DefaultSiteSlug || Covers(slug); + /// public async Task AgentCoversAsync(string slug, bool agentPresent) => agentPresent && (slug != SiteManagementService.DefaultSiteSlug || await CoversAsync(slug)); From 3cc04543d692efc7ad5a99467fe283659e0b6400 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 08:46:31 -0500 Subject: [PATCH 21/63] A coverage write records the new value instead of clearing the cache Switching agent collection on reconnected the console immediately, and that reconnect read the flag through the synchronous reader - which answers false while an invalidated entry refills. So the console reconnected on the direct path and showed no waiting-for-agent banner, on a site that had just been handed to its agent. Probing was unaffected, which is what made it look inconsistent: the latency tier reads the flag on its own cadence, by which time the background refill had landed, so it stood down correctly. Same flag, different timing, different answer. The writer knows the value it just stored, so it records it. Invalidate stays for callers that only know the value changed - site eviction. --- .../Services/ISiteConfigurationService.cs | 4 +++- .../Services/SiteAgentCoverage.cs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs index c3ee38f0eb..c575afcd20 100644 --- a/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs +++ b/src/NetworkOptimizer.Web/Services/ISiteConfigurationService.cs @@ -139,7 +139,9 @@ public async Task SetAgentCoversSiteAsync(string siteSlug, bool enabled) await WriteAsync(siteSlug, SiteAgentCoverage.AgentCoversSiteKey, enabled.ToString()); // The collection paths read this through a one-minute cache; a setting that decides whether // the server collects at all should not wait that long to take effect. - _agentCoverage.Invalidate(siteSlug); + // Recorded rather than invalidated: the reconnect below reads this immediately, and the + // synchronous reader answers false while an invalidated entry refills. + _agentCoverage.Set(siteSlug, enabled); // Also drops the devices cache: that flag is gated on coverage for the default site, so // coverage changing changes the answer without the flag itself being touched. _tunnelRouting.Invalidate(siteSlug); diff --git a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs index 25918b8583..d0513c7df6 100644 --- a/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs +++ b/src/NetworkOptimizer.Web/Services/SiteAgentCoverage.cs @@ -67,6 +67,19 @@ public bool Covers(string slug) /// Drops the cached answer for a site, so the next read sees a change immediately. public void Invalidate(string slug) => _flags.TryRemove(slug, out _); + /// + /// Records a value the caller already knows, for a writer that has just stored it. + /// + /// Use this rather than whenever the new value is in hand. Invalidate + /// leaves a hole, and the synchronous reader answers "not covered" while it refills - so + /// switching coverage on and immediately reconnecting the console read false and connected on + /// the wrong path, with no banner to say so. There is no window here at all. + /// + public void Set(string slug, bool enabled) + { + if (!string.IsNullOrEmpty(slug)) _flags[slug] = enabled; + } + /// /// Swept with the per-site registries when a site is removed or created. The cached answer now /// outlives the site that set it - there is no expiry to heal it - so a slug deleted and From ea7568871ce4b95f009d040a77c14f623cc65171 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 09:07:45 -0500 Subject: [PATCH 22/63] Only the main site loses its routing flags when its last agent goes Clearing them on every site strands a secondary one. That site is reached ONLY through an agent, so the flags describe its sole access path rather than an option it took - and the replacement agent does not restore them, because the setup wizard writes them only when its proxy checkbox is ticked and that defaults off. Swap a secondary site's agent and it would sit on direct routing it cannot use, with generic connection failures rather than the waiting-for-the -agent messages, which need the flags set in order to fire. The main site keeps the clearing: direct access is a real fallback there, which is what made removing the flags right in the first place. Never released - the clearing landed after the v2.5.3 tag. --- .../Services/AgentEnrollmentService.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs b/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs index cd64aeb092..a75c8f4d7d 100644 --- a/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentEnrollmentService.cs @@ -200,11 +200,21 @@ public async Task DeleteAgentAsync(string siteSlug, int agentId) DropLiveTunnel(agent.Id, agent.Name, "removed"); _logger.LogInformation("Removed agent {Name} (id {Id}) for site {SiteId}", agent.Name, agent.Id, agent.SiteId); - // Removing the last agent leaves nothing to route through, so the console and device - // routing flags are cleared with it. They outlived the agent otherwise, and every console - // read and SSH command went on addressing a tunnel that could never come up again. - if (!await db.SiteAgents.AnyAsync(a => a.SiteId == agent.SiteId)) + // Removing the last agent leaves the main site nothing to route through, so its console and + // device routing flags are cleared with it. They outlived the agent otherwise, and every + // console read and SSH command went on addressing a tunnel that could never come up again. + // + // The main site only. A secondary site is reached ONLY through an agent, so those flags + // describe its sole access path rather than an option it took: clearing them strands the + // site on direct routing it cannot use, and the replacement agent does not restore them - + // the setup wizard writes them only when its proxy checkbox is ticked, and that defaults + // off. It would also silence the waiting-for-the-agent messages, which need the flags set + // to fire, leaving an operator mid-swap with generic connection failures instead. + if (siteSlug == SiteManagementService.DefaultSiteSlug + && !await db.SiteAgents.AnyAsync(a => a.SiteId == agent.SiteId)) + { await ClearAgentRoutingAsync(siteSlug); + } } /// From 65cb68123e281ae567433f935c40eb98acc286e1 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 09:11:15 -0500 Subject: [PATCH 23/63] Record why the no-agent secondary site still offers these controls Both comments described the old behavior, where the gate asked whether an agent was connected. It asks about configuration now, so a secondary site offers Add Target and Discover before any agent exists and the probe fails with a reason. That is the intent rather than an oversight, so it is written down: a control that explains itself beats one that is silently absent, and targets added now are seeded ready for the agent that arrives later. Comment only. --- .../Components/Shared/LatencyTargetsCard.razor | 9 +++++++-- .../Components/Shared/UpstreamTracerPanel.razor | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor b/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor index 3d4c635d35..ae776b5318 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor @@ -262,8 +262,13 @@ public EventCallback OnTargetsChanged { get; set; } // A manually added target is probed from the "server" vantage, which on an external - // (non-default) site is the on-site agent. Without a connected agent there's nothing to probe - // from inside the site, so adding is gated. The default site always probes locally. + // (non-default) site is the on-site agent, which is now a question of configuration rather + // than of an agent being connected this second. + // + // So a secondary site offers this even with no agent enrolled yet, and the probe fails with + // "No on-site agent is online to run the probe". That is deliberate (TJ, 2026-08-03): a control + // that explains itself beats one that silently is not there, and targets added now are seeded + // ready for the agent that arrives later. Do not re-gate this on the agent being live. private bool CanAddTargets => SiteCtx.IsDefault || ExecutorFactory.ServerVantageIsAgent; private bool _collapsed = true; diff --git a/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor index 425399cd56..d5af53167d 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor @@ -612,8 +612,13 @@ [Parameter] public EventCallback OnSaved { get; set; } // Discovery must trace from inside the site's network. On an external (non-default) site - // that requires a connected on-site agent - without one the trace would run from the central - // server and mis-attribute its path as this site's. The default site always traces locally. + // that requires the site's agent to own path measurement - otherwise the trace would run from + // the central server and mis-attribute its path as this site's. That is now decided by + // configuration rather than by an agent being connected this second. + // + // So a secondary site offers Discover even with no agent enrolled yet, and the run fails with + // "No on-site agent is online to run the probe". Deliberate (TJ, 2026-08-03): a button that + // says why beats a button that is missing. Do not re-gate this on the agent being live. private bool CanDiscover => SiteCtx.IsDefault || ExecutorFactory.ServerVantageIsAgent; private UpstreamTracerState _state = new(); From 7bf8953f7440f380925f04a4d7422235f6b6481a Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 12:57:39 -0500 Subject: [PATCH 24/63] Site setup wizard: preview the slug the site will actually get The Site ID hint slugged the typed name inline, which ignores what is already taken - a name colliding with an existing site, or with the reserved 'main' slug, promised an ID the site would not get. PreviewSlugAsync already answers this (it runs the same generator creation does, suffix and all) and had no caller; the hint now asks it. --- .../Components/Shared/SiteSetupWizard.razor | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor b/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor index 261aebe786..4b2d704d9d 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SiteSetupWizard.razor @@ -1,4 +1,3 @@ -@using NetworkOptimizer.Core.Helpers @using NetworkOptimizer.Core.Interfaces @using NetworkOptimizer.Storage.Models @using NetworkOptimizer.Web.Services @@ -75,7 +74,7 @@
- @if (!string.IsNullOrWhiteSpace(_name)) + @if (!string.IsNullOrWhiteSpace(_slugPreview)) { - Site ID: @StringUtilities.ToSlug(_name) - permanent identifier used for the site's database file, InfluxDB buckets, and agent configuration. + Site ID: @_slugPreview - permanent identifier used for the site's database file, InfluxDB buckets, and agent configuration. }
} @@ -322,6 +321,7 @@ private bool _busy; private Site? _site; private string _name = ""; + private string _slugPreview = ""; private string _message = ""; private string _messageClass = ""; @@ -361,6 +361,28 @@ StateHasChanged(); } + /// + /// Asks the service what slug this name would actually get, rather than slugging the name + /// here: the answer depends on what is already taken, so a name that collides with an + /// existing site or with the reserved default slug gets its "-2" suffix shown up front + /// instead of surprising the user after the site is created. + /// + private async Task UpdateSlugPreviewAsync() + { + var name = _name; + if (string.IsNullOrWhiteSpace(name)) + { + _slugPreview = ""; + return; + } + + var preview = await SiteManagement.PreviewSlugAsync(name); + // Keystrokes can resolve out of order; a stale answer would show a slug for a + // name the field no longer holds. + if (name == _name) + _slugPreview = preview; + } + /// /// Enter creates the site, matching the button beside the field. Guarded on the same conditions /// the button is disabled by, so a stray Enter on an empty field or mid-create does nothing. @@ -640,6 +662,7 @@ _step = 1; _site = null; _name = ""; + _slugPreview = ""; _consoleUrl = ""; _username = ""; _password = ""; From 92ad415aff6df7316d2e3f2eacddc9000d7c7282 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:04:03 -0500 Subject: [PATCH 25/63] Gate reading the audit log at the service, not only at the page in front of it (#1096) IAuditQueryService carried no gate at all. The surface was covered in practice - the export endpoints have RequireAuthorization(RequireAdmin) and the Audit Log tab sits behind an AuthorizeView on the main site - but by the endpoint and the page rather than by the service, which is the arrangement [MutatingService] exists to replace. Any new caller reaching this interface, a component on another page or a background job or a future endpoint, would have inherited nothing. That matters more here than for most reads. The log is the record of who did what across the whole install: actors, source addresses, target names, and since the site stamping work, the site each action touched. Reading it is closer to reading a credential store than a status page. Every member gets [RequireRole(Roles.Admin)] and the registration moves to AddMutatingService so the implementation is not resolvable ungated. No [AuditAction]: recording every read would write an entry for each page and each page-turn of the log itself, burying the actions the log is kept for. Both callers keep working. CallerContextMiddleware populates the caller for the export endpoints and CallerContextCircuitHandler does it for the interactive page, so the proxy has a principal on either path. Tests cover Admin allowed, Viewer and Operator refused, both exports refused separately from the page read - they leave as files through their own endpoint, so a gate covering only the interactive read would have missed the larger disclosure - and a reflection check that the attributes stay on the interface. --- .../Services/Auditing/AuditQueryService.cs | 29 ++++- .../Services/Identity/IdentityRegistration.cs | 6 +- .../Identity/AuditQueryGateTests.cs | 110 ++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs diff --git a/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs b/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs index f2e9ed2221..518bc9fd92 100644 --- a/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs +++ b/src/NetworkOptimizer.Web/Services/Auditing/AuditQueryService.cs @@ -1,6 +1,7 @@ -using System.Text; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Web.Services.Gates; using NetworkOptimizer.Storage.Models.Identity; namespace NetworkOptimizer.Web.Services.Auditing; @@ -18,12 +19,36 @@ public sealed record AuditFilter public int Take { get; init; } = 100; } -/// Read-only, filtered access to the audit log plus CSV/JSON export of the current filter. +/// +/// Read-only, filtered access to the audit log plus CSV/JSON export of the current filter. +/// +/// Gated even though every member is a read. The audit log is the record of who did what across the +/// whole install - actors, source addresses, target names, and now the site each action touched - so +/// it is closer to a credential store than to a status page, and reads of it are worth the same +/// service-tier check as writes elsewhere. +/// +/// Until this attribute, nothing here was checked at all. The export endpoints carry +/// RequireAuthorization(RequireAdmin) and the page sits behind an AuthorizeView, so the surface was +/// covered in practice - but by the endpoint and the page rather than by the service, which is the +/// arrangement the gate engine exists to replace. Any new caller reaching this interface (a component +/// on another page, a background job, a future endpoint) would have inherited nothing. +/// +/// No [AuditAction]: recording every read would write an entry for each page and each page-turn of +/// the log itself, which buries the actions the log is kept for. +/// +[MutatingService] public interface IAuditQueryService { + [RequireRole(Roles.Admin)] Task> QueryAsync(AuditFilter filter); + + [RequireRole(Roles.Admin)] Task CountAsync(AuditFilter filter); + + [RequireRole(Roles.Admin)] Task ExportJsonAsync(AuditFilter filter); + + [RequireRole(Roles.Admin)] Task ExportCsvAsync(AuditFilter filter); } diff --git a/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs b/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs index 55d3fc56b0..dfd4dce4fa 100644 --- a/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs +++ b/src/NetworkOptimizer.Web/Services/Identity/IdentityRegistration.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -126,7 +126,9 @@ public static IServiceCollection AddNetOptIdentityCore(this IServiceCollection s services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); - services.AddScoped(); + // Gated, so it goes through the proxy rather than being resolved raw - registering the + // implementation as its own service type would leave an ungated instance in the container. + services.AddMutatingService(); return services; } diff --git a/tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs b/tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs new file mode 100644 index 0000000000..bd875c9fba --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Identity/AuditQueryGateTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetworkOptimizer.Storage.Models.Identity; +using NetworkOptimizer.Web.Services.Auditing; +using NetworkOptimizer.Web.Services.Gates; +using NetworkOptimizer.Web.Services.Identity; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Identity; + +/// +/// The audit log is the record of who did what across the whole install - actors, source addresses, +/// target names, and the site each action touched. Reading it is closer to reading a credential store +/// than a status page, so it earns a service-tier check rather than relying on the page and the export +/// endpoint that happen to sit in front of it today. +/// +public sealed class AuditQueryGateTests +{ + private static ServiceProvider Build() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContextFactory(o => + o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped(); + services.AddSingleton(new NoOpAudit()); + // Non-site-scoped gate, so the interceptor ranks the global role and never asks this - it is + // here because SiteRoleHandler takes it as a dependency. + services.AddScoped(); + services.AddGatePlumbing(); + services.AddMutatingService(); + return services.BuildServiceProvider(); + } + + private sealed class NoOpAudit : IAuditLogger + { + public void Log(AuditEvent auditEvent) { } + } + + private sealed class UnusedResolver : NetworkOptimizer.Web.Services.Authorization.IEffectiveSiteRoleResolver + { + public void Invalidate(string userId) { } + public void InvalidateAll() { } + public Task FirstAdministeredSlugAsync(System.Security.Claims.ClaimsPrincipal user) + => Task.FromResult(null); + public Task GetEffectiveRoleAsync(System.Security.Claims.ClaimsPrincipal user, string slug) + => Task.FromResult(null); + public Task> GetAuthorizedSlugsAsync(System.Security.Claims.ClaimsPrincipal user) + => Task.FromResult>(new HashSet()); + } + + [Fact] + public async Task An_admin_may_read_the_audit_log() + { + await using var provider = Build(); + using var scope = provider.ScopeAs("admin-1", Roles.Admin); + + var act = async () => await scope.ServiceProvider + .GetRequiredService().QueryAsync(new AuditFilter()); + + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(Roles.Viewer)] + [InlineData(Roles.Operator)] + public async Task Anyone_below_Admin_is_refused(string role) + { + await using var provider = Build(); + using var scope = provider.ScopeAs("someone", role); + + var act = async () => await scope.ServiceProvider + .GetRequiredService().QueryAsync(new AuditFilter()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Export_is_gated_the_same_way_as_the_page_read() + { + // The two exports leave the app as files and were reachable through their own endpoint, so a + // gate that covered only the interactive read would have missed the larger disclosure. + await using var provider = Build(); + using var scope = provider.ScopeAs("viewer-1", Roles.Viewer); + var query = scope.ServiceProvider.GetRequiredService(); + + var json = async () => await query.ExportJsonAsync(new AuditFilter()); + var csv = async () => await query.ExportCsvAsync(new AuditFilter()); + + await json.Should().ThrowAsync(); + await csv.Should().ThrowAsync(); + } + + /// + /// The gate has to stay declared on the interface. Losing the attribute puts the reads back + /// behind nothing but the page and the endpoint, which is where they started. + /// + [Fact] + public void Every_member_carries_a_role_gate() + { + typeof(IAuditQueryService).Should().BeDecoratedWith(); + + foreach (var method in typeof(IAuditQueryService).GetMethods()) + { + method.Should().BeDecoratedWith( + $"{method.Name} reads the audit log and must be gated"); + } + } +} From d2672e6a2be2127592e566b23f27e36c5664fa53 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Mon, 3 Aug 2026 13:09:07 -0500 Subject: [PATCH 26/63] Audit Log exports download in place instead of opening a tab Both endpoints answer with Content-Disposition: attachment, so nothing was ever going to render in the new tab - it opened, received a download, and stayed behind empty. A same-tab link with the download attribute gets the file without navigating away from the log. --- .../Components/Shared/Identity/AuditLogPanel.razor | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Shared/Identity/AuditLogPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/Identity/AuditLogPanel.razor index 7d3858e558..e4a04e6544 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/Identity/AuditLogPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/Identity/AuditLogPanel.razor @@ -48,8 +48,11 @@ }
- Export CSV - Export JSON + @* Both endpoints answer with Content-Disposition: attachment, so the browser downloads + without navigating and the page is undisturbed. Opening them in a new tab left an + empty one behind every time, since nothing ever rendered there. *@ + Export CSV + Export JSON
From b55eebf48fa6ddc1bd69f558edef4264365f3769 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:22:54 -0500 Subject: [PATCH 27/63] Multi-WAN monitoring: probe, discover, and grade every WAN (#1098) Monitors every WAN a site has, not just the primary one. 189 commits squashed. A WAN vantage names the WAN it measures and how its probes get there: bound to a source IP the gateway policy-routes out that WAN, or run by an agent sitting behind it. An agent on the gateway can bind probes to the WAN's own interface, so no policy-based route is needed there at all. Latency points carry a wan tag, upstream discovery runs per WAN, and ISP Health grades each WAN against its own counters, plan speeds, targets and hops instead of pairing one WAN's traffic with another's expectations. WHERE IT SHOWS UP Network Performance gains a vantage card and a WAN filter on Latency & Packet Loss: one WAN alone, several compared, or all. Shared hosts probed from every WAN line up side by side, one color per host and one line pattern per WAN. Latency Targets and Flaky Monitoring Targets both name the WAN a target belongs to. Upstream Path Discovery runs per WAN and opens on the one selected. Live View takes its own WAN selector with its own saved selection, so watching one WAN never moves the analysis views. Network Tools can run a probe from any vantage, which is how a policy-based route or interface bind gets confirmed. Live and analysis link both ways, carrying the moment, the WAN and the category, without either view's saved filter being written to. ISP HEALTH SCORING Several changes here are fleet-wide and affect single-WAN sites too. Loaded latency no longer answers "was any sample high", which one ICMP-deprioritized responder answers on its own. Samples are collapsed by instant across every non-LAN target on the WAN, since a queue on the access link sits in front of all of them. Magnitude comes from the targets that saw it and only the credence scales with the cohort, so the figure does not fall as more targets are monitored. A line that stays clean under load now reads as clean rather than as an absence of evidence, and a run of clean load episodes after elevated ones is read as the line having been fixed. Evidence is weighed by recency, by how loaded, and by how sustained. A WAN speed test can raise the figure where it read higher and reached 70% of plan in that direction, since the probes sample on their own cadence and a short event's peak queue can build and drain between two of them unseen. Off-path access hops stop feeding the packet loss pool, unless they are all a site has. Target edits and upstream discovery invalidate every WAN's cached report, not just the primary's. A report computed before the agent's console was up is dropped, since SNMP arrives through that console and is what classifies load. STARLINK Satellite is inferred from the access ASN, satellite hops are kept out of the candidate set, and the dish's fixed LAN-side MAC resolves to Starlink. Latency bands are set from measured dishes rather than estimated. Idle: 23 ms is the best the medium does at all and scores full, 42 ms is where a healthy Backup dish sits and scores 80. Loaded: 3 ms excellent, 12 ms acceptable, replacing a 25 ms ceiling that sat above the 95th percentile of real delta and could not fail anything. Loaded loss is unchanged. A dish reporting a reduced-speed plan tier is graded on whether it carries usable traffic rather than on ratio, whatever plan is configured. Latency is deliberately not tier-aware: a cheaper plan really is worse latency, and hiding that would never tell anyone the tier is the reason. METERED WANS Continuous probing costs about 5.4 GB a month at 25 targets on the 10s default, which is most of a small satellite or cellular plan. Satellite, cellular and fixed wireless each cost a rung, and a configured Data Usage cap costs another. A rung means fewer targets and a slower cadence, never smaller packets. The allowance rotates across access hops, transit and path endpoints so a site is not left with detail on its own first mile and no way to tell whether anything it reaches is up. SINGLE-WAN SITES Nothing changes. With no vantages and one WAN, none of the selectors render, the schema additions stay null, latency points carry no wan tag, and the scoped queries resolve to the same inputs as before, pinned by equivalence tests. AGENTS AgentProtocol gained an optional supports_source_bind capability and traceroute binding compiles into the agent, so test agents need new binaries. Older agents keep working: the capability reads as "did not say" and interface binding is not offered for them. ROLLBACK Additive nullable columns only, and the data migrations are one-way by design. An older build ignores the new columns, reads the normalized keys correctly, and loses multi-WAN behavior rather than data. STILL TO BUILD FOR THIS RELEASE Per-WAN outage alerting. Alerting still fires per target, so a secondary WAN going down announces itself as a handful of "target is down" alerts rather than one outage on that WAN. The design is settled and intended to ship with this work, not after it: without it a multi-WAN site gets more noise from a WAN failure than a single-WAN site did. --- TODO.md | 15 + scripts/proxmox/install-agent.sh | 510 +++ src/NetworkOptimizer.Agent/Program.cs | 13 +- src/NetworkOptimizer.Agent/TunnelClient.cs | 13 +- .../Protos/agent_tunnel.proto | 15 + .../Services/IeeeOuiDatabase.cs | 21 + .../Helpers/NetworkUtilities.cs | 45 + .../Probes/LocalProbeExecutor.cs | 189 +- ..._AddWanContextInterfaceBinding.Designer.cs | 3387 ++++++++++++++++ ...803193154_AddWanContextInterfaceBinding.cs | 40 + ...00_BackfillWanContextTargetWan.Designer.cs | 3387 ++++++++++++++++ ...60803210000_BackfillWanContextTargetWan.cs | 41 + ...4120000_NormalizeLegacyWan1Key.Designer.cs | 3387 ++++++++++++++++ .../20260804120000_NormalizeLegacyWan1Key.cs | 66 + ...40000_AddWanProfileRoleMarkers.Designer.cs | 3393 +++++++++++++++++ ...20260804140000_AddWanProfileRoleMarkers.cs | 34 + .../20260804180000_AddUserUiHints.Designer.cs | 767 ++++ .../Auth/20260804180000_AddUserUiHints.cs | 48 + .../Auth/AuthDbContextModelSnapshot.cs | 30 + .../NetworkOptimizerDbContextModelSnapshot.cs | 100 +- .../Models/Identity/AuthDbContext.cs | 10 + .../Models/Identity/UserUiHint.cs | 42 + .../Models/WanContext.cs | 41 +- .../Models/WanProfile.cs | 26 + .../Services/MonitoringInfluxClient.cs | 105 +- .../GatewayWanHelper.cs | 71 + .../Models/UniFiDeviceResponse.cs | 7 + src/NetworkOptimizer.UniFi/UniFiApiClient.cs | 35 +- src/NetworkOptimizer.UniFi/UniFiDiscovery.cs | 13 +- .../Components/Pages/Alerts.razor | 27 +- .../Components/Pages/Monitoring.razor | 1460 ++++++- .../Components/Pages/MonitoringTools.razor | 225 +- .../Components/Pages/Settings.razor | 28 +- .../Components/Pages/WanSpeedTest.razor | 66 +- .../Shared/AgentInstallInstructions.razor | 34 +- .../Components/Shared/InfluxSetupWizard.razor | 2 +- .../Shared/LatencyTargetsCard.razor | 388 +- .../Components/Shared/LiveViewPanel.razor | 399 +- .../Shared/Monitoring/FlakyTargetsCard.razor | 24 +- .../Shared/Monitoring/IspHealthPanel.razor | 461 ++- .../Monitoring/MonitoringJumpButton.razor | 30 + .../Monitoring/WanFilterResetButton.razor | 14 + .../Components/Shared/SiteSwitcher.razor | 10 +- .../Components/Shared/SpeedTestDetails.razor | 21 +- .../Shared/UpstreamTracerPanel.razor | 252 +- .../Components/Shared/WanContextsCard.razor | 895 ++++- .../Endpoints/IspHealthEndpoints.cs | 14 +- .../Endpoints/MonitoringChartEndpoints.cs | 156 +- src/NetworkOptimizer.Web/Program.cs | 4 + .../Services/AgentOnGatewayDetector.cs | 157 +- .../Services/AgentProbeResultSink.cs | 442 ++- .../Services/AgentProbeService.cs | 34 +- .../Services/AgentTunnelRegistry.cs | 31 + .../Services/AgentTunnelService.cs | 6 + .../Services/AppVersionInfo.cs | 2 +- .../Services/IMonitoringTargetService.cs | 8 + .../Services/IUpstreamDiscoveryService.cs | 15 +- .../Services/Monitoring/AgentProbeExecutor.cs | 45 +- .../Services/Monitoring/FlakyTargetService.cs | 7 +- .../Monitoring/IspHealth/ElevationVerdict.cs | 97 + .../Monitoring/IspHealth/IspHealthOptions.cs | 172 +- .../Monitoring/IspHealth/IspHealthRegistry.cs | 79 +- .../Monitoring/IspHealth/IspHealthScorer.cs | 487 ++- .../Monitoring/IspHealth/IspHealthService.cs | 629 ++- .../IspHealth/PhysicalLinkModels.cs | 8 + .../IspHealth/PhysicalLinkResolver.cs | 7 + .../Monitoring/IspHealth/SeriesStats.cs | 193 + .../Services/Monitoring/LiveWanScope.cs | 345 ++ .../Services/Monitoring/MeteredProbePolicy.cs | 68 + .../Services/Monitoring/MonitoringLinks.cs | 59 + .../Monitoring/MonitoringStatFormat.cs | 20 + .../Monitoring/ProbeExecutorFactory.cs | 11 + .../Services/Monitoring/ProbeVantages.cs | 110 + .../Monitoring/UpstreamRediscoveryService.cs | 135 + .../Monitoring/UpstreamTracerRegistry.cs | 50 + .../Monitoring/UpstreamTracerService.cs | 594 ++- .../Monitoring/WanContextTargetStamping.cs | 53 + .../Services/MonitoringCollectionAgent.cs | 70 +- .../Services/MonitoringLiveStats.cs | 34 +- .../Services/MonitoringTargetService.cs | 33 +- .../Services/UiHintService.cs | 116 + .../Services/UniFiConnectionService.cs | 70 +- .../Services/UpstreamDiscoveryService.cs | 16 +- src/NetworkOptimizer.Web/wwwroot/css/app.css | 182 +- .../wwwroot/js/cellular-charts.js | 2 +- .../wwwroot/js/chart-tooltip.js | 23 +- .../wwwroot/js/cm-charts.js | 2 +- .../wwwroot/js/collapse-reveal.js | 10 + .../wwwroot/js/device-health-charts.js | 2 +- .../wwwroot/js/isp-health-charts.js | 31 +- .../wwwroot/js/lan-flow-map.js | 9 +- .../wwwroot/js/latency-charts.js | 217 +- .../wwwroot/js/ont-charts.js | 2 +- .../wwwroot/js/sfp-charts.js | 2 +- .../wwwroot/js/site-context.js | 10 + .../wwwroot/js/starlink-charts.js | 2 +- .../wwwroot/js/wan-live-chart.js | 570 ++- .../AgentHelloCompatibilityTests.cs | 82 + .../Probes/TcpBindAddressTests.cs | 78 + .../Probes/TracerouteCommandTests.cs | 158 + .../LegacyWan1KeyNormalizationTests.cs | 185 + .../WanContextTargetWanBackfillTests.cs | 142 + .../WanScopeFilterTests.cs | 76 + .../AutoEnableBudgetTests.cs | 112 + .../IspHealth/CrossHopAgreementTests.cs | 136 + .../IspHealth/ElevationVerdictTests.cs | 119 + .../IspHealth/IspHealthScorerTests.cs | 211 +- .../IspHealth/LoadCredibilityTests.cs | 110 + .../IspHealth/LoadEpisodeTests.cs | 65 + .../IspHealth/RecencyWeightedMedianTests.cs | 83 + .../IspHealth/SpeedTestLiftTests.cs | 158 + .../MeteredProbePolicyTests.cs | 69 + .../Monitoring/IspHealthWanDeepLinkTests.cs | 51 + .../Monitoring/IspHealthWanScopingTests.cs | 159 + .../Monitoring/OldAgentCompatibilityTests.cs | 62 + .../Monitoring/PerWanDiscoveryTests.cs | 311 ++ .../Monitoring/ProbeVantagesTests.cs | 125 + .../SiteLoadBalanceDetectionTests.cs | 70 + .../Monitoring/Wan2PrimarySiteTests.cs | 208 + .../Monitoring/WanContextRoutingTests.cs | 416 ++ .../WanContextTargetStampingTests.cs | 259 ++ .../Monitoring/WanDeepLinkTargetTests.cs | 40 + .../UiHintServiceTests.cs | 53 + .../UpstreamTracerServiceTests.cs | 97 +- .../WanContextsCardTests.cs | 236 ++ 125 files changed, 28589 insertions(+), 910 deletions(-) create mode 100644 scripts/proxmox/install-agent.sh create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs create mode 100644 src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs create mode 100644 src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor create mode 100644 src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/MeteredProbePolicy.cs create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/MonitoringLinks.cs create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/MonitoringStatFormat.cs create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/ProbeVantages.cs create mode 100644 src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs create mode 100644 src/NetworkOptimizer.Web/Services/UiHintService.cs create mode 100644 tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs create mode 100644 tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs create mode 100644 tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs create mode 100644 tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs create mode 100644 tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs create mode 100644 tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs diff --git a/TODO.md b/TODO.md index 36bc58975f..6e4ddfc9da 100644 --- a/TODO.md +++ b/TODO.md @@ -147,6 +147,21 @@ feature that would otherwise write to the controller. - Threshold tuning based on real-world data collection - **Consistent wireless bottleneck attribution across test types:** LAN client speed tests show the bottleneck relative to the AP (e.g., "[AP] Back Yard (wireless)") while WAN client speed tests show it relative to the client (e.g., "[Phone] TJ iPhone (wireless)"). This is because WAN client paths reverse hops and swap ingress/egress, which flips the perspective. The wireless link is the same physical connection - both descriptions are technically correct but inconsistent. Investigate unifying to always name the AP side, since that's what users can control. Relevant code: `CalculateWanClientPathAsync` hop reversal/swap and `CalculateBottleneck` wireless link attribution. +## WAN Speed Test + +### Run from an individual site agent +Both the page and its schedule run a WAN speed test from one vantage per site: the server, or the +on-site agent where one owns path measurement. A site with several agents - one behind each WAN - +cannot say which of them runs the test, so a secondary WAN's throughput cannot be measured the way +its latency already is. + +Wanted: choose the agent, and therefore the WAN, from both the WAN Speed Test page and a schedule. +The Network Tools vantage picker already models this - one entry per (agent, vantage) carrying that +vantage's binding - so the shape is settled and this is wiring it into the speed test paths and the +schedule config. + +Not now. The Gateway SSH launched WAN speed test covers the per-WAN case today. + ## Alerts & Scheduling ### DST-Aware Schedule Time Display diff --git a/scripts/proxmox/install-agent.sh b/scripts/proxmox/install-agent.sh new file mode 100644 index 0000000000..7310b667e2 --- /dev/null +++ b/scripts/proxmox/install-agent.sh @@ -0,0 +1,510 @@ +#!/usr/bin/env bash + +# Network Optimizer on-site agent - Proxmox LXC Installation Script +# https://github.com/Ozark-Connect/NetworkOptimizer +# +# Creates a small Debian LXC on this Proxmox host and installs the on-site agent +# inside it. The container is the only thing this script builds - the agent itself +# is installed by the standard installer (scripts/agent/install-native.sh), so +# there is one agent install path however you get there. +# +# Generate the enrollment token in the server's web UI under +# Settings > Multi-Site > (site) > Agents > Set up agent. +# +# Usage: +# bash -c "$(wget -qLO - https://raw.githubusercontent.com/Ozark-Connect/NetworkOptimizer/main/scripts/proxmox/install-agent.sh)" +# +# Every prompt below is also an option. Supplying it skips that question, so +# building one agent per WAN is a flag-driven run each rather than an interview +# each. --unattended takes the default for anything not supplied and asks nothing. +# +# Options: +# --ct-id N Container ID (default: next free) +# --hostname NAME Container hostname (default: netopt-agent) +# --debian-version N Debian major version for the template (default: 13) +# --ram MB / --swap MB / --cores N / --disk GB +# --storage NAME Storage for the container rootfs +# --template-storage NAME Storage holding container templates +# --bridge NAME Network bridge (default: vmbr0) +# --vlan TAG VLAN tag for the container's interface +# --ip ADDR CIDR address, or "dhcp" (default: dhcp) +# --gateway ADDR Gateway, required with a static --ip +# --dns ADDR Nameserver for a static --ip +# --server URL Network Optimizer server this agent reports to +# --token TOKEN One-time enrollment token +# --lan-speed-test Host the LAN speed test page and iperf3 in this container +# --speed-test-port N Serve the speed test page on N instead of 24443 +# --insecure Accept a self-signed cert on the server's reverse proxy +# --unattended Never prompt; take defaults for anything not supplied +# +# Requirements: +# - Proxmox VE 7.0 or later +# - Internet access for the container template and the agent binary + +set -Eeuo pipefail + +# ============================================================================= +# Configuration Defaults +# ============================================================================= +APP_NAME="Network Optimizer agent" +GITHUB_REPO="Ozark-Connect/NetworkOptimizer" +GITHUB_BRANCH="main" + +# The agent is a single self-contained binary with no database and no Docker, so +# it needs a fraction of what the server container does. +DEFAULT_HOSTNAME="netopt-agent" +DEFAULT_DISK_SIZE="4" +DEFAULT_RAM="512" +DEFAULT_SWAP="256" +DEFAULT_CPU="1" +DEFAULT_BRIDGE="vmbr0" +DEFAULT_STORAGE="local-lvm" +DEFAULT_TEMPLATE_STORAGE="local" +DEFAULT_DEBIAN_VERSION="13" +DEFAULT_SPEED_TEST_PORT="24443" + +# ============================================================================= +# Colors and Formatting +# ============================================================================= +readonly RD='\033[0;31m' +readonly GN='\033[0;32m' +readonly YW='\033[0;33m' +readonly BL='\033[0;34m' +readonly CY='\033[0;36m' +readonly BLD='\033[1m' +readonly DIM='\033[2m' +readonly CL='\033[0m' + +# ============================================================================= +# Helper Functions +# ============================================================================= +msg_info() { echo -e "${BL}[INFO]${CL} $1"; } +msg_ok() { echo -e "${GN}[ OK ]${CL} $1"; } +msg_warn() { echo -e "${YW}[WARN]${CL} $1"; } +msg_error() { echo -e "${RD}[FAIL]${CL} $1"; } + +header() { + echo + echo -e "${BLD}${CY}=== $1 ===${CL}" + echo +} + +# Anything created before a failure is removed, so a half-built container is not +# left behind for the next run to trip over. +CT_CREATED=false +cleanup() { + local code=$? + if [[ $code -ne 0 ]] && [[ "$CT_CREATED" == "true" ]] && [[ -n "${CT_ID:-}" ]]; then + msg_warn "Install failed - removing container $CT_ID" + pct stop "$CT_ID" &>/dev/null || true + pct destroy "$CT_ID" &>/dev/null || true + fi + exit $code +} +trap cleanup EXIT + +check_root() { + if [[ $EUID -ne 0 ]]; then + msg_error "This script must be run as root on Proxmox VE." + exit 1 + fi +} + +check_proxmox() { + if ! command -v pveversion &>/dev/null; then + msg_error "This script must be run on Proxmox VE." + echo -e "${DIM}To install the agent on a machine you already have, use scripts/agent/install-native.sh instead.${CL}" + exit 1 + fi + local pve_version + pve_version=$(pveversion --verbose | grep "pve-manager" | awk '{print $2}' | cut -d'/' -f1) + msg_ok "Proxmox VE $pve_version detected" +} + +get_next_ct_id() { + local id=100 + while pct status "$id" &>/dev/null || qm status "$id" &>/dev/null 2>&1; do + ((id++)) + done + echo "$id" +} + +validate_ct_id() { + local id=$1 + if ! [[ "$id" =~ ^[0-9]+$ ]]; then + msg_error "Container ID must be a number." + return 1 + fi + if [[ "$id" -lt 100 ]]; then + msg_error "Container ID must be 100 or greater." + return 1 + fi + if pct status "$id" &>/dev/null || qm status "$id" &>/dev/null 2>&1; then + msg_error "ID $id already exists (VM or container)." + return 1 + fi + return 0 +} + +validate_hostname() { + if ! [[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]]; then + msg_error "Invalid hostname: $1" + return 1 + fi + return 0 +} + +get_storage_list() { pvesm status -content rootdir 2>/dev/null | awk 'NR>1 {print $1}' | tr '\n' ' '; } +get_template_storage_list() { pvesm status -content vztmpl 2>/dev/null | awk 'NR>1 {print $1}' | tr '\n' ' '; } +get_bridge_list() { ip -o link show type bridge 2>/dev/null | awk -F': ' '{print $2}' | tr '\n' ' '; } + +validate_storage() { + pvesm status -content "$2" 2>/dev/null | awk 'NR>1 {print $1}' | grep -qw "$1" +} + +find_debian_template() { + local storage=$1 version=${2:-13} + pveam update &>/dev/null || true + local template + template=$(pveam available --section system 2>/dev/null \ + | awk '{print $2}' | grep "^debian-${version}-standard" | sort -V | tail -n1) + if [[ -z "$template" ]]; then + template=$(pveam list "$storage" 2>/dev/null \ + | awk '{print $1}' | grep "debian-${version}-standard" | sed 's|.*/||' | sort -V | tail -n1) + fi + if [[ -z "$template" ]]; then + msg_error "No Debian ${version} template found." + exit 1 + fi + echo "$template" +} + +# ============================================================================= +# Options +# ============================================================================= +UNATTENDED=false +CT_ID=""; CT_HOSTNAME=""; DEBIAN_VERSION="" +CT_RAM=""; CT_SWAP=""; CT_CPU=""; CT_DISK="" +CT_STORAGE=""; TEMPLATE_STORAGE=""; CT_BRIDGE=""; CT_VLAN_TAG="" +CT_IP=""; CT_GW=""; CT_DNS="" +AGENT_SERVER=""; AGENT_TOKEN="" +AGENT_LAN_SPEED_TEST=""; AGENT_SPEED_TEST_PORT=""; AGENT_INSECURE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --ct-id) CT_ID="$2"; shift 2 ;; + --hostname) CT_HOSTNAME="$2"; shift 2 ;; + --debian-version) DEBIAN_VERSION="$2"; shift 2 ;; + --ram) CT_RAM="$2"; shift 2 ;; + --swap) CT_SWAP="$2"; shift 2 ;; + --cores) CT_CPU="$2"; shift 2 ;; + --disk) CT_DISK="$2"; shift 2 ;; + --storage) CT_STORAGE="$2"; shift 2 ;; + --template-storage) TEMPLATE_STORAGE="$2"; shift 2 ;; + --bridge) CT_BRIDGE="$2"; shift 2 ;; + --vlan) CT_VLAN_TAG="$2"; shift 2 ;; + --ip) CT_IP="$2"; shift 2 ;; + --gateway) CT_GW="$2"; shift 2 ;; + --dns) CT_DNS="$2"; shift 2 ;; + --server) AGENT_SERVER="$2"; shift 2 ;; + --token) AGENT_TOKEN="$2"; shift 2 ;; + --lan-speed-test) AGENT_LAN_SPEED_TEST=true; shift ;; + --speed-test-port) AGENT_SPEED_TEST_PORT="$2"; AGENT_LAN_SPEED_TEST=true; shift 2 ;; + --insecure) AGENT_INSECURE=true; shift ;; + --unattended) UNATTENDED=true; shift ;; + -h|--help) sed -n '3,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) msg_error "Unknown option: $1"; exit 1 ;; + esac +done + +# Ask only for what was not supplied. In unattended mode nothing is asked and the +# default stands, which is what makes this scriptable for several WANs at once. +ask() { + local prompt=$1 default=$2 current=$3 answer + if [[ -n "$current" ]]; then echo "$current"; return; fi + if [[ "$UNATTENDED" == "true" ]]; then echo "$default"; return; fi + read -rp "$(echo -e "${BLD}${prompt}${CL} [${default}]: ")" answer /dev/null || true + echo -e "${CY}${BLD}" + echo " Network Optimizer - on-site agent" + echo " Proxmox LXC installer" + echo -e "${CL}" + echo -e "${DIM} Creates a container and installs the agent inside it.${CL}" + echo +} + +configure_container() { + header "Container Configuration" + + local default_id + default_id=$(get_next_ct_id) + while true; do + CT_ID=$(ask "Container ID" "$default_id" "$CT_ID") + validate_ct_id "$CT_ID" && break + [[ "$UNATTENDED" == "true" ]] && exit 1 + CT_ID="" + done + + while true; do + CT_HOSTNAME=$(ask "Hostname" "$DEFAULT_HOSTNAME" "$CT_HOSTNAME") + validate_hostname "$CT_HOSTNAME" && break + [[ "$UNATTENDED" == "true" ]] && exit 1 + CT_HOSTNAME="" + done + + DEBIAN_VERSION=$(ask "Debian version" "$DEFAULT_DEBIAN_VERSION" "$DEBIAN_VERSION") + CT_RAM=$(ask "RAM in MB" "$DEFAULT_RAM" "$CT_RAM") + CT_SWAP=$(ask "Swap in MB" "$DEFAULT_SWAP" "$CT_SWAP") + CT_CPU=$(ask "CPU cores" "$DEFAULT_CPU" "$CT_CPU") + CT_DISK=$(ask "Disk size in GB" "$DEFAULT_DISK_SIZE" "$CT_DISK") + + if [[ -z "$CT_STORAGE" ]] && [[ "$UNATTENDED" != "true" ]]; then + echo -e "${DIM}Available: $(get_storage_list)${CL}" + fi + CT_STORAGE=$(ask "Storage for container" "$DEFAULT_STORAGE" "$CT_STORAGE") + if ! validate_storage "$CT_STORAGE" rootdir; then + msg_error "Storage '$CT_STORAGE' cannot hold containers." + exit 1 + fi + + if [[ -z "$TEMPLATE_STORAGE" ]] && [[ "$UNATTENDED" != "true" ]]; then + echo -e "${DIM}Available: $(get_template_storage_list)${CL}" + fi + TEMPLATE_STORAGE=$(ask "Storage for templates" "$DEFAULT_TEMPLATE_STORAGE" "$TEMPLATE_STORAGE") + if ! validate_storage "$TEMPLATE_STORAGE" vztmpl; then + msg_error "Storage '$TEMPLATE_STORAGE' cannot hold templates." + exit 1 + fi + + if [[ -z "$CT_BRIDGE" ]] && [[ "$UNATTENDED" != "true" ]]; then + echo -e "${DIM}Available: $(get_bridge_list)${CL}" + fi + CT_BRIDGE=$(ask "Network bridge" "$DEFAULT_BRIDGE" "$CT_BRIDGE") + + # A WAN-context agent is often on its own VLAN, so this is asked rather than + # buried in a flag. + CT_VLAN_TAG=$(ask "VLAN tag (blank for none)" "" "$CT_VLAN_TAG") + + CT_IP=$(ask "IP address (CIDR, or dhcp)" "dhcp" "$CT_IP") + if [[ "$CT_IP" != "dhcp" ]]; then + CT_GW=$(ask "Gateway" "" "$CT_GW") + if [[ -z "$CT_GW" ]]; then + msg_error "A static IP needs a gateway." + exit 1 + fi + CT_DNS=$(ask "DNS server" "$CT_GW" "$CT_DNS") + fi +} + +configure_agent() { + header "Agent Configuration" + echo -e "${DIM}The token comes from the server's web UI: Settings > Multi-Site > (site) > Agents.${CL}" + echo + + AGENT_SERVER=$(ask "Server URL (https://...)" "" "$AGENT_SERVER") + if [[ -z "$AGENT_SERVER" ]]; then + msg_error "The agent needs the server URL to report to." + exit 1 + fi + + AGENT_TOKEN=$(ask "Enrollment token" "" "$AGENT_TOKEN") + if [[ -z "$AGENT_TOKEN" ]]; then + msg_error "The agent needs a one-time enrollment token." + exit 1 + fi + + if [[ -z "$AGENT_LAN_SPEED_TEST" ]]; then + local answer + answer=$(ask "Host the LAN speed test in this container? (y/n)" "n" "") + [[ "$answer" =~ ^[Yy] ]] && AGENT_LAN_SPEED_TEST=true || AGENT_LAN_SPEED_TEST=false + fi + if [[ "$AGENT_LAN_SPEED_TEST" == "true" ]]; then + AGENT_SPEED_TEST_PORT=$(ask "Speed test port" "$DEFAULT_SPEED_TEST_PORT" "$AGENT_SPEED_TEST_PORT") + fi +} + +confirm_settings() { + [[ "$UNATTENDED" == "true" ]] && return 0 + + header "Review" + echo -e " Container: ${CY}${CT_ID}${CL} (${CT_HOSTNAME}), Debian ${DEBIAN_VERSION}" + echo -e " Resources: ${CT_CPU} core(s), ${CT_RAM} MB RAM, ${CT_DISK} GB disk" + echo -e " Storage: ${CT_STORAGE} (templates: ${TEMPLATE_STORAGE})" + echo -e " Network: ${CT_BRIDGE}${CT_VLAN_TAG:+ VLAN ${CT_VLAN_TAG}}, ${CT_IP}" + echo -e " Server: ${CY}${AGENT_SERVER}${CL}" + echo -e " Speed test: $([[ "$AGENT_LAN_SPEED_TEST" == "true" ]] && echo "yes (port ${AGENT_SPEED_TEST_PORT})" || echo "no")" + echo + local answer + read -rp "$(echo -e "${BLD}Create it? (y/n)${CL} [y]: ")" answer /dev/null || echo "") + if [[ -f "$template_path" ]]; then + msg_ok "Already downloaded" + return 0 + fi + + msg_info "Downloading..." + if ! pveam download "$TEMPLATE_STORAGE" "$CT_TEMPLATE_FILE"; then + msg_error "Failed to download the container template." + exit 1 + fi + msg_ok "Downloaded" +} + +create_container() { + header "Creating Container" + msg_info "Creating $CT_ID ($CT_HOSTNAME)..." + + local net_config="name=eth0,bridge=$CT_BRIDGE" + if [[ "$CT_IP" == "dhcp" ]]; then + net_config="${net_config},ip=dhcp" + else + net_config="${net_config},ip=${CT_IP},gw=${CT_GW}" + fi + [[ -n "$CT_VLAN_TAG" ]] && net_config="${net_config},tag=${CT_VLAN_TAG}" + + # Unprivileged, no nesting: the agent is a plain systemd service with no Docker + # under it, so it needs none of the concessions the server container makes. + pct create "$CT_ID" "$TEMPLATE_STORAGE:vztmpl/$CT_TEMPLATE_FILE" \ + --hostname "$CT_HOSTNAME" \ + --memory "$CT_RAM" \ + --swap "$CT_SWAP" \ + --cores "$CT_CPU" \ + --rootfs "$CT_STORAGE:$CT_DISK" \ + --net0 "$net_config" \ + --ostype debian \ + --unprivileged 1 \ + --onboot 1 \ + --start 0 + CT_CREATED=true + + if [[ "$CT_IP" != "dhcp" ]] && [[ -n "$CT_DNS" ]]; then + pct set "$CT_ID" --nameserver "$CT_DNS" + fi + + msg_ok "Container created" +} + +start_container() { + msg_info "Starting container..." + pct start "$CT_ID" + + local max_wait=60 waited=0 + while ! pct exec "$CT_ID" -- test -f /etc/os-release 2>/dev/null; do + sleep 1 + ((waited++)) + if [[ $waited -ge $max_wait ]]; then + msg_error "Container failed to start within ${max_wait}s" + exit 1 + fi + done + sleep 3 + msg_ok "Container started" +} + +install_agent() { + header "Installing the Agent" + + msg_info "Installing prerequisites..." + pct exec "$CT_ID" -- bash -c "apt-get update -qq && apt-get install -y -qq curl ca-certificates iputils-ping traceroute" >/dev/null + msg_ok "Prerequisites installed" + + # The agent's service runs as root, and root inside a container holds CAP_NET_RAW over its own + # user namespace, so ICMP already works. This is for the case where it does not: Debian 13 + # dropped the CAP_NET_RAW file capability from ping entirely and relies on ICMP datagram + # sockets, which are gated by this sysctl - and systemd's stock value (0 2147483647) is + # REJECTED in an unprivileged container because the upper GID falls outside Proxmox's id map, + # leaving the kernel default of "no group may create these sockets". 65534 is the top of the + # mapped range. Costs nothing today and means a hardened or non-root agent still pings. + pct exec "$CT_ID" -- bash -c "echo 'net.ipv4.ping_group_range = 0 65534' > /etc/sysctl.d/99-ping-group-range.conf && sysctl -q -w 'net.ipv4.ping_group_range=0 65534'" >/dev/null 2>&1 || msg_warn "Could not set ping_group_range - ICMP still works for the root-run agent" + + # The standard installer does the actual work, so a container agent and a + # bare-metal agent are the same install with the same layout and the same + # upgrade path. + local args="--server '${AGENT_SERVER}' --token '${AGENT_TOKEN}'" + [[ "$AGENT_LAN_SPEED_TEST" == "true" ]] && args="$args --lan-speed-test" + [[ -n "$AGENT_SPEED_TEST_PORT" ]] && args="$args --speed-test-port '${AGENT_SPEED_TEST_PORT}'" + [[ "$AGENT_INSECURE" == "true" ]] && args="$args --insecure" + + msg_info "Running the agent installer inside the container..." + if ! pct exec "$CT_ID" -- bash -c \ + "curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/scripts/agent/install-native.sh | bash -s -- ${args}"; then + msg_error "The agent installer failed inside the container." + echo -e "${DIM}The container is left in place so you can look: pct enter ${CT_ID}${CL}" + CT_CREATED=false + exit 1 + fi + msg_ok "Agent installed" +} + +get_container_ip() { + pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}' +} + +show_completion() { + header "Done" + + local ip mac + ip=$(get_container_ip) + mac=$(pct config "$CT_ID" | awk -F'hwaddr=' '/^net0:/ {split($2,a,","); print a[1]}') + + echo -e "${GN}${BLD}The agent is installed and enrolled.${CL}\n" + echo -e "${BLD}Container:${CL}" + echo -e " ID / hostname: ${CY}${CT_ID}${CL} (${CT_HOSTNAME})" + echo -e " Address: ${CY}${ip:-pending}${CL}" + echo -e " MAC: ${CY}${mac:-unknown}${CL}" + if [[ "$AGENT_LAN_SPEED_TEST" == "true" ]]; then + echo -e " Speed test: ${CY}https://${ip}:${AGENT_SPEED_TEST_PORT}${CL}" + fi + echo + echo -e "${BLD}Check on it:${CL}" + echo -e " ${DIM}pct exec ${CT_ID} -- systemctl status netopt-agent${CL}" + echo -e " ${DIM}pct exec ${CT_ID} -- journalctl -u netopt-agent -f${CL}" + echo + echo -e "${BLD}Monitoring a second WAN with this agent?${CL}" + echo -e " In UniFi Network, add a Policy-Based Route sending this container out that WAN:" + echo -e " ${DIM}Settings > Policy Table > Policy-Based Route - the WAN as the interface,${CL}" + echo -e " ${DIM}this container's Client Device (MAC ${mac:-above}) as the source, Any as the destination.${CL}" + echo -e " Then give the WAN a context in Monitoring > Setup and assign this agent to it." + echo +} + +main() { + check_root + check_proxmox + show_banner + configure_container + configure_agent + confirm_settings + download_template + create_container + start_container + install_agent + show_completion + trap - EXIT +} + +main "$@" diff --git a/src/NetworkOptimizer.Agent/Program.cs b/src/NetworkOptimizer.Agent/Program.cs index ebb068fdf0..8c56a673ab 100644 --- a/src/NetworkOptimizer.Agent/Program.cs +++ b/src/NetworkOptimizer.Agent/Program.cs @@ -105,6 +105,10 @@ static int SpeedTestPagePort(NetworkOptimizer.Agent.AgentConfig cfg) => var lanIp = !string.IsNullOrWhiteSpace(lanIpOverride) ? lanIpOverride.Trim() : NetworkOptimizer.Core.Helpers.NetworkUtilities.DetectLocalIpFromInterfaces(); +// lanIp is one address chosen for the server to reach this agent back on. The full set goes +// alongside it so the server can recognise the host by an address it already knows, which the +// single choice cannot do on a gateway - see LocalUnicastAddresses. +var localIps = NetworkOptimizer.Core.Helpers.NetworkUtilities.LocalUnicastAddresses(); var handler = new HttpClientHandler(); if (config.IgnoreSslErrors) @@ -311,9 +315,14 @@ void SaveSpool() // Announce the port only when a speed test server is actually up. An agent that // serves none - a gateway install, or one where the server failed to start - has no // port to give, and claiming a port would advertise a listener that is not there. - await tunnel.RunAsync(config.TunnelUrl, config.AgentKey!, version, lanIp, + // Source binding is a platform capability, not a setting: it rides the + // native ping binary, so the server only offers a WAN context an + // interface bind where the agent can actually honor one. + await tunnel.RunAsync(config.TunnelUrl, config.AgentKey!, version, lanIp, localIps, speedTestServer != null ? SpeedTestPagePort(config) : 0, - speedTestServer != null, config.IgnoreSslErrors, cts.Token); + speedTestServer != null, + NetworkOptimizer.Monitoring.Probes.LocalProbeExecutor.SupportsSourceBinding, + config.IgnoreSslErrors, cts.Token); Console.Error.WriteLine("Tunnel closed by server, reconnecting..."); } catch (OperationCanceledException) when (cts.IsCancellationRequested) diff --git a/src/NetworkOptimizer.Agent/TunnelClient.cs b/src/NetworkOptimizer.Agent/TunnelClient.cs index 913c074180..a9524db2f4 100644 --- a/src/NetworkOptimizer.Agent/TunnelClient.cs +++ b/src/NetworkOptimizer.Agent/TunnelClient.cs @@ -116,7 +116,7 @@ public async ValueTask SendAsync(AgentMessage message, CancellationToken c /// Connects and runs the tunnel until it drops or is /// cancelled. Throws on connection failure so the caller can back off and retry. ///
- public async Task RunAsync(string tunnelUrl, string agentKey, string version, string? lanIp, int speedTestPort, bool servesSpeedTest, bool ignoreSslErrors, CancellationToken ct) + public async Task RunAsync(string tunnelUrl, string agentKey, string version, string? lanIp, IReadOnlyList localIps, int speedTestPort, bool servesSpeedTest, bool supportsSourceBind, bool ignoreSslErrors, CancellationToken ct) { // Belt-and-braces with the startup config validation: the tunnel carries // SNMP credentials and proxied console traffic, so cleartext is never OK. @@ -168,7 +168,16 @@ public async Task RunAsync(string tunnelUrl, string agentKey, string version, st await call.RequestStream.WriteAsync(new AgentMessage { - Hello = new AgentHello { AgentKey = agentKey, Version = version, LanIp = lanIp ?? "", SpeedTestPort = speedTestPort, ServesSpeedTest = servesSpeedTest } + Hello = new AgentHello + { + AgentKey = agentKey, + Version = version, + LanIp = lanIp ?? "", + SpeedTestPort = speedTestPort, + ServesSpeedTest = servesSpeedTest, + SupportsSourceBind = supportsSourceBind, + LocalIps = { localIps } + } }, helloCts.Token); if (!await call.ResponseStream.MoveNext(helloCts.Token) || call.ResponseStream.Current.Hello is not { } hello) diff --git a/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto b/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto index 466090896e..5be6c8e908 100644 --- a/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto +++ b/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto @@ -142,6 +142,21 @@ message AgentHello { // absent and the server falls back to deciding for itself, while a gateway // install answers a definite no without being guessed at by its location. optional bool serves_speed_test = 5; + // Whether this agent can bind a probe to a source address or interface, which + // needs the native ping binary (Linux/macOS). Explicitly optional for the same + // reason as above: an agent predating this leaves it absent, and the server + // reads "did not say" as "do not offer interface binding" rather than guessing + // a capability whose absence fails every probe that relies on it. + optional bool supports_source_bind = 6; + // Every unicast address this agent's host holds. lan_ip is one address chosen + // out of these, and on a gateway the choice is arbitrary: an agent on a UniFi + // gateway may report an uplink address the console never lists as the gateway's + // own, so matching that single address against the addresses the console knows + // answers "is this the gateway" with a false no. Sending all of them lets the + // server ask whether ANY of them is one it recognises, without widening what it + // treats as a gateway address. Empty from an agent that predates this, which + // leaves the server on the single-address comparison it has always done. + repeated string local_ips = 7; } message ServerHello { diff --git a/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs b/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs index da820e1664..3a7a9740d3 100644 --- a/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs +++ b/src/NetworkOptimizer.Audit/Services/IeeeOuiDatabase.cs @@ -89,10 +89,31 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) if (string.IsNullOrEmpty(macOrOui)) return null; + // Before the OUI fold: these are matched on the WHOLE address, not a prefix. + if (KnownFixedMacs.TryGetValue(NormalizeMac(macOrOui), out var fixedVendor)) + return fixedVendor; + var oui = NormalizeToOui(macOrOui); return _ouiToVendor.TryGetValue(oui, out var vendor) ? vendor : null; } + /// + /// Vendors for specific locally-administered MACs. Such an address is by definition absent + /// from the IEEE registry, so no amount of registry data will ever name it - but kit that + /// ships with a FIXED one is still identifiable by the whole address. Every Starlink dish of + /// this generation presents 26:12:AC:1A:80:01 on its LAN side, so a gateway neighbor lookup + /// that came back blank was a name we already had and were not using. Keyed on the full MAC + /// deliberately: the prefix is locally administered and says nothing on its own. + /// + private static readonly Dictionary KnownFixedMacs = + new(StringComparer.OrdinalIgnoreCase) { ["2612AC1A8001"] = "Starlink" }; + + private static string NormalizeMac(string input) => input + .Replace(":", "") + .Replace("-", "") + .Replace(".", "") + .ToUpperInvariant(); + /// /// Check if a vendor exists in the database /// diff --git a/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs b/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs index a3a365100c..3e7ca3a69b 100644 --- a/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs +++ b/src/NetworkOptimizer.Core/Helpers/NetworkUtilities.cs @@ -39,6 +39,51 @@ public static class NetworkUtilities return DetectLocalIpFromInterfaces(); } + /// + /// Every IPv4 unicast address this host holds, across all interfaces that are up. + /// + /// Distinct from , which picks the ONE address that + /// best represents the host. That choice is arbitrary when something else has to recognise the + /// host by an address it already knows: on a UniFi gateway the best-looking address can be an + /// uplink the console never lists as the gateway's own, so a single-address comparison answers + /// "is this that machine" with a false no. + /// + /// + /// Bridges and virtual interfaces are INCLUDED here, unlike the single-address detection that + /// skips them: a gateway's LAN address lives on a bridge, and it is one of the addresses a + /// console does report. Loopback and link-local (169.254/16) are excluded - neither identifies + /// a host, and both would be held by every machine, so comparing them could only produce a + /// false match. + /// + /// + public static IReadOnlyList LocalUnicastAddresses() + { + var addresses = new List(); + try + { + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) continue; + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + foreach (var unicast in ni.GetIPProperties().UnicastAddresses) + { + var address = unicast.Address; + if (address.AddressFamily != AddressFamily.InterNetwork) continue; + if (IPAddress.IsLoopback(address)) continue; + var text = address.ToString(); + if (text.StartsWith("169.254.", StringComparison.Ordinal)) continue; + if (!addresses.Contains(text, StringComparer.OrdinalIgnoreCase)) + addresses.Add(text); + } + } + } + catch + { + // Enumeration is best effort: the caller still has its single detected address. + } + return addresses; + } + /// /// Detect local IP address from network interfaces (ignores HOST_IP env var). /// Prioritizes: Physical Ethernet > WiFi > Other. diff --git a/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs b/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs index 6147a768f4..449940cf84 100644 --- a/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs +++ b/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs @@ -26,6 +26,7 @@ public class LocalProbeExecutor : IProbeExecutor private ProbeCapability? _capability; private readonly SemaphoreSlim _capabilityLock = new(1, 1); private bool _tracerouteBinaryAvailable; + private TracerouteBinaryTraits _tracerouteTraits = TracerouteBinaryTraits.FullyBindable; // Throttle native Process.Start. macOS ARM64 has a .NET 10 runtime bug // (dotnet/runtime#112167) where concurrent Process.Start with redirected @@ -43,6 +44,15 @@ public LocalProbeExecutor(ILogger logger) public ProbeVantage Vantage => ProbeVantage.Server; + /// + /// Whether probes on this host can be bound to a source address or interface. + /// Binding rides on the native ping binary's source options, so it is exactly + /// the platforms where is the ping path: .NET's + /// managed Ping cannot bind at all. Agents announce this in their hello so the + /// server only offers a bind mechanism the agent can actually honor. + /// + public static bool SupportsSourceBinding => !OperatingSystem.IsWindows(); + public async Task GetCapabilityAsync(CancellationToken ct = default) { if (_capability != null) return _capability; @@ -64,7 +74,7 @@ public async Task GetCapabilityAsync(CancellationToken ct = def CanUdpTraceroute = _tracerouteBinaryAvailable, // only the native binary does UDP CanTcpProbe = true, // .NET sockets IsBusyBoxPing = false, - IsBusyBoxTraceroute = false + IsBusyBoxTraceroute = _tracerouteBinaryAvailable && _tracerouteTraits.IsBusyBox }; _logger.LogInformation( @@ -94,7 +104,17 @@ private async Task IsTracerouteInstalledAsync(CancellationToken ct) if (probe == null) return false; using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(2)); + // Both streams: GNU traceroute prints its version on stdout, while BusyBox and + // BSD answer an unknown -V with their usage on stderr. That usage text is the + // only evidence available for which source-bind options this build actually has. + // On the same 2s budget as the exit wait, so a binary that says nothing and never + // returns costs the same as it did when nothing read its output at all. + var stdoutTask = probe.StandardOutput.ReadToEndAsync(cts.Token); + var stderrTask = probe.StandardError.ReadToEndAsync(cts.Token); try { await probe.WaitForExitAsync(cts.Token); } catch { } + var banner = (await SafeReadAsync(stdoutTask) ?? string.Empty) + + "\n" + (await SafeReadAsync(stderrTask) ?? string.Empty); + _tracerouteTraits = InterpretTracerouteBanner(banner); return true; } catch (Exception ex) @@ -104,6 +124,47 @@ private async Task IsTracerouteInstalledAsync(CancellationToken ct) } } + /// + /// What the installed traceroute can be told about where a probe leaves from. GNU + /// traceroute and BSD traceroute both take -s (source address) and -i + /// (source interface), so anything that isn't BusyBox is taken as fully bindable. + /// BusyBox's applet is compile-configurable and may carry neither, so its usage text - + /// which it prints in place of a version - is read for the two options before either is + /// offered. A probe that cannot bind must fail rather than leave by the default route: + /// that would record another WAN's latency under this one's name. + /// + internal readonly record struct TracerouteBinaryTraits(bool IsBusyBox, bool CanBindAddress, bool CanBindInterface) + { + /// A GNU/BSD traceroute: both bind options present. Also the assumption before detection runs. + public static TracerouteBinaryTraits FullyBindable => new(false, true, true); + } + + /// Reads a traceroute binary's version/usage output into the bind options it advertises. + /// Combined stdout+stderr from traceroute -V; empty when it could not be read. + internal static TracerouteBinaryTraits InterpretTracerouteBanner(string? banner) + { + if (string.IsNullOrWhiteSpace(banner)) return TracerouteBinaryTraits.FullyBindable; + if (banner.IndexOf("busybox", StringComparison.OrdinalIgnoreCase) < 0) + return TracerouteBinaryTraits.FullyBindable; + + return new TracerouteBinaryTraits( + IsBusyBox: true, + CanBindAddress: MentionsOption(banner, 's'), + CanBindInterface: MentionsOption(banner, 'i')); + } + + /// Whether a usage line lists a single-letter option, either bare or inside a bundled flag group. + private static bool MentionsOption(string banner, char option) + { + for (var i = 0; i < banner.Length - 1; i++) + { + if (banner[i] != '-') continue; + for (var j = i + 1; j < banner.Length && char.IsAsciiLetterOrDigit(banner[j]); j++) + if (banner[j] == option) return true; + } + return false; + } + public async Task PingAsync( ProbeTarget target, int count = 10, @@ -122,7 +183,7 @@ public async Task PingAsync( // ("ping" says 0.2 ms, dashboard says 1.5 ms). Windows ping has different output // and gives less useful data, so the managed Ping + Stopwatch path is the // Windows MSI fallback. - if (!OperatingSystem.IsWindows()) + if (SupportsSourceBinding) { return await NativePingAsync(target, count, perPingTimeout ?? TimeSpan.FromSeconds(2), ct); } @@ -300,16 +361,19 @@ public async Task TcpProbeAsync( using var tcp = new TcpClient(); if (!string.IsNullOrEmpty(target.SourceInterface)) { - // TCP source binding only works with an address (SO_BINDTODEVICE - // for interface names needs CAP_NET_RAW; not worth it here). - if (!System.Net.IPAddress.TryParse(target.SourceInterface, out var sourceIp)) + // TCP source binding takes an address, not a device (SO_BINDTODEVICE + // for interface names needs CAP_NET_RAW), so an interface name is + // resolved to its current address here rather than at push time: a + // DHCP or PPPoE WAN moves, and a stale address binds nothing. + var (sourceIp, error) = ResolveTcpBindAddress(target.SourceInterface, LookupInterfaceIPv4); + if (sourceIp == null) { return new TcpProbeResult { Target = target, Vantage = Vantage, Connected = false, - ErrorMessage = $"TCP probes need an IP address as the probe source, got '{target.SourceInterface}'", + ErrorMessage = error, Timestamp = DateTime.UtcNow }; } @@ -369,12 +433,20 @@ public async Task TracerouteAsync( var deadlineDuration = totalDeadline ?? TimeSpan.FromSeconds(10); if (!_tracerouteBinaryAvailable || OperatingSystem.IsWindows()) { + // The managed Ping-with-TTL traceroute cannot bind a source, exactly as the + // managed ping path cannot. Tracing out the default route would attribute + // another WAN's path to this one, so say so instead of tracing anyway. + if (!string.IsNullOrEmpty(target.SourceInterface)) + return FailTrace(target, "Source-bound traceroute needs the native traceroute binary (Linux/macOS)"); + using var managedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); managedCts.CancelAfter(deadlineDuration); return await _managedTraceroute.RunAsync(target, Vantage, maxHops, perHopTimeout, 3, managedCts.Token); } - var (exe, args) = BuildTracerouteCommand(target, maxHops, perHopTimeout); + var (exe, args, buildError) = BuildTracerouteCommand(target, maxHops, perHopTimeout, _tracerouteTraits); + if (buildError != null) + return FailTrace(target, buildError); // Acquire the throttle FIRST, THEN start the per-trace deadline. The // deadline must bound process execution, not time spent queued behind // the semaphore - otherwise queued traces in a big sweep (18 in the @@ -523,6 +595,52 @@ private static double StdDev(IReadOnlyCollection v) /// The probe source goes into a process argument, so restrict it to the /// characters valid in IPv4/IPv6 addresses and interface names. /// + /// + /// Turns a probe source value into the address a TCP socket can bind to: an IP + /// literal is taken as-is, an interface name is resolved to that interface's + /// current IPv4 address through . + /// + /// An interface with no IPv4 address returns an error rather than a null bind. + /// Probing unbound would leave by the default route and record another WAN's + /// latency under this one's name, which reads as data rather than as a failure. + /// + /// Source IP or interface name from the WAN context. + /// Looks up an interface's addresses by name; empty when it has none or does not exist. + /// The address to bind, or null with the reason the probe cannot run. + internal static (System.Net.IPAddress? Address, string? Error) ResolveTcpBindAddress( + string source, + Func> interfaceAddresses) + { + if (System.Net.IPAddress.TryParse(source, out var literal)) + return (literal, null); + + if (!IsSafeSourceValue(source)) + return (null, $"Invalid probe source '{source}'"); + + var addresses = interfaceAddresses(source); + var ipv4 = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork); + if (ipv4 != null) + return (ipv4, null); + + return (null, $"Interface '{source}' has no IPv4 address to bind the TCP probe to"); + } + + /// Current unicast IPv4/IPv6 addresses of a local interface by name; empty when there is no such interface. + private static IReadOnlyList LookupInterfaceIPv4(string interfaceName) + { + try + { + var nic = NetworkInterface.GetAllNetworkInterfaces() + .FirstOrDefault(n => string.Equals(n.Name, interfaceName, StringComparison.OrdinalIgnoreCase)); + if (nic == null) return Array.Empty(); + return nic.GetIPProperties().UnicastAddresses.Select(a => a.Address).ToList(); + } + catch (NetworkInformationException) + { + return Array.Empty(); + } + } + private static bool IsSafeSourceValue(string value) => value.Length <= 64 && value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or ':' or '-' or '_' or '%'); @@ -536,6 +654,17 @@ private static bool IsSafeSourceValue(string value) => Timestamp = DateTime.UtcNow }; + private TracerouteResult FailTrace(ProbeTarget target, string error) => new() + { + Target = target, + Vantage = Vantage, + ModeUsed = target.Mode, + Hops = Array.Empty(), + Reached = false, + ErrorMessage = error, + Timestamp = DateTime.UtcNow + }; + private static (string exe, string args) ChooseTracerouteBinary() { if (OperatingSystem.IsWindows()) @@ -545,13 +674,49 @@ private static (string exe, string args) ChooseTracerouteBinary() return ("traceroute", "-V"); } - private static (string exe, string args) BuildTracerouteCommand(ProbeTarget target, int maxHops, TimeSpan? perHopTimeout) + /// + /// Builds the traceroute invocation for a target, including the source bind a WAN context + /// asks for: an IP literal becomes -s, an interface name becomes -i, mirroring + /// the ping path's -I/-S/-b handling. Returns an error instead of a + /// command whenever the bind cannot be honored - an unbound trace would map another WAN's + /// upstream onto this one, which reads as a discovery result rather than as a failure. + /// + /// Probe target; its SourceInterface carries the context's bind, if any. + /// TTL ceiling. + /// Per-hop wait; floored at one second, which is the flag's unit. + /// What the installed binary can bind, from . + /// Which platform's traceroute to build for; defaults to this host's. + /// The executable and arguments, or an error explaining why the probe cannot run. + internal static (string Exe, string Args, string? Error) BuildTracerouteCommand( + ProbeTarget target, int maxHops, TimeSpan? perHopTimeout, TracerouteBinaryTraits traits, bool? isWindows = null) { var wait = (int)Math.Max(1, (perHopTimeout ?? TimeSpan.FromSeconds(2)).TotalSeconds); - if (OperatingSystem.IsWindows()) + if (isWindows ?? OperatingSystem.IsWindows()) { + // tracert.exe has no source option at all, so a bound probe cannot run here. + if (!string.IsNullOrEmpty(target.SourceInterface)) + return ("tracert.exe", string.Empty, "Source-bound traceroute needs the native traceroute binary (Linux/macOS)"); // tracert: -h max hops, -w wait ms, -d no DNS resolution to speed up - return ("tracert.exe", $"-h {maxHops} -w {wait * 1000} {target.Address}"); + return ("tracert.exe", $"-h {maxHops} -w {wait * 1000} {target.Address}", null); + } + + var sourceArg = string.Empty; + if (!string.IsNullOrEmpty(target.SourceInterface)) + { + if (!IsSafeSourceValue(target.SourceInterface)) + return ("traceroute", string.Empty, $"Invalid probe source '{target.SourceInterface}'"); + + var isAddress = System.Net.IPAddress.TryParse(target.SourceInterface, out _); + if (isAddress && !traits.CanBindAddress) + return ("traceroute", string.Empty, + "This host's traceroute takes no source address, so the probe would go out the default route"); + if (!isAddress && !traits.CanBindInterface) + return ("traceroute", string.Empty, + $"This host's traceroute takes no source interface, so the probe would not go out '{target.SourceInterface}'"); + + sourceArg = isAddress + ? $"-s {target.SourceInterface} " + : $"-i {target.SourceInterface} "; } var protoFlag = target.Mode switch @@ -563,7 +728,7 @@ private static (string exe, string args) BuildTracerouteCommand(ProbeTarget targ // PTR resolution stays ON — hostnames like "cr1.stl1.example.net" are gold for the // wizard's hop-labelling logic (spec 5.5). Linux's resolver times out fast, so the // cost is bounded by the per-probe deadline anyway. - var args = $"-m {maxHops} -q 2 -w {wait} {protoFlag} {target.Address}".Trim(); - return ("traceroute", args); + var args = $"-m {maxHops} -q 2 -w {wait} {protoFlag} {sourceArg}{target.Address}".Trim(); + return ("traceroute", args, null); } } diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs new file mode 100644 index 0000000000..3275340c37 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.Designer.cs @@ -0,0 +1,3387 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260803193154_AddWanContextInterfaceBinding")] + partial class AddWanContextInterfaceBinding + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs new file mode 100644 index 0000000000..f4e5bfa083 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803193154_AddWanContextInterfaceBinding.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + public partial class AddWanContextInterfaceBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InterfaceName", + table: "WanContexts", + type: "TEXT", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "WanInterface", + table: "WanContexts", + type: "TEXT", + maxLength: 50, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InterfaceName", + table: "WanContexts"); + + migrationBuilder.DropColumn( + name: "WanInterface", + table: "WanContexts"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs new file mode 100644 index 0000000000..7f91e9d553 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.Designer.cs @@ -0,0 +1,3387 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260803210000_BackfillWanContextTargetWan")] + partial class BackfillWanContextTargetWan + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs new file mode 100644 index 0000000000..882ef0be8e --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260803210000_BackfillWanContextTargetWan.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Reconciles the two WAN keys a target can carry. MonitoringTarget.WanContextId says who + /// probes a target (the routing key, set by hand in the per-target WAN dropdown), while + /// MonitoringTarget.WanInterface says which WAN its data describes (the reading key, written + /// by upstream discovery). Contexts predate the WanInterface column on WanContext, so a + /// target assigned to a secondary WAN's context has been carrying no WAN at all, or the + /// primary's - and no per-WAN reader could find it under the WAN it actually measures. + /// + /// Data-only, so there is no schema change and no model change: it copies each context's WAN + /// onto the targets assigned to that context. The context assignment is always the user's own + /// statement about a target (discovery never set it before this release), so it is the + /// authority here and overwrites a WanInterface left over from an earlier primary-WAN + /// discovery. Targets with no context - every target on a single-WAN install - are untouched. + /// + public partial class BackfillWanContextTargetWan : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +UPDATE MonitoringTargets +SET WanInterface = (SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId) +WHERE WanContextId IS NOT NULL + AND (SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId) IS NOT NULL + AND (SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId) <> '' + AND IFNULL(WanInterface, '') <> IFNULL((SELECT c.WanInterface FROM WanContexts c WHERE c.Id = MonitoringTargets.WanContextId), '');"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // The prior WanInterface values were unrecoverable guesses (null, or the primary's + // key from a discovery that never knew about this WAN), so there is nothing truthful + // to restore. Leaving the corrected values in place is the honest no-op. + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs new file mode 100644 index 0000000000..436f513a02 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.Designer.cs @@ -0,0 +1,3387 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260804120000_NormalizeLegacyWan1Key")] + partial class NormalizeLegacyWan1Key + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs new file mode 100644 index 0000000000..5cd7f00ea9 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804120000_NormalizeLegacyWan1Key.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Folds the legacy 'wan1' WAN key into 'wan'. Migration 20260521500000 stamped existing rows + /// 'wan1' when per-WAN discovery contexts arrived; everything written since uses 'wan', which + /// is what GatewayWanHelper produces for the first WAN group. The two spellings named the same + /// WAN and nothing minded while only one WAN was ever read. + /// + /// Per-WAN reading makes them disagree. A discovery run committing 'wan' does not recognize a + /// 'wan1' row as its own, so it creates a second, WAN-qualified target beside it - a legacy + /// single-WAN install would quietly double its access and transit targets on the next run. The + /// per-WAN scorer likewise excludes 'wan1' rows from the 'wan' report, taking their upstream + /// hops and their access technology with them. + /// + /// The runtime paths normalize both spellings, so this migration is about the stored data: + /// one key per WAN, so a row means what it says. Data-only - no schema or model change. + /// + /// WanDiscoveryContexts is keyed by WanInterface, so a site holding both spellings cannot + /// simply have its 'wan1' row renamed. The newer row wins (it describes the more recent + /// discovery) and the stale one is dropped. + /// + public partial class NormalizeLegacyWan1Key : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // Discovery contexts: drop the older spelling where both exist, then rename what is left. + migrationBuilder.Sql(@" +DELETE FROM WanDiscoveryContexts +WHERE WanInterface = 'wan1' + AND EXISTS (SELECT 1 FROM WanDiscoveryContexts w WHERE w.WanInterface = 'wan') + AND IFNULL(LastDiscoveryAt, '') <= IFNULL( + (SELECT w.LastDiscoveryAt FROM WanDiscoveryContexts w WHERE w.WanInterface = 'wan'), '');"); + + migrationBuilder.Sql(@" +DELETE FROM WanDiscoveryContexts +WHERE WanInterface = 'wan' + AND EXISTS (SELECT 1 FROM WanDiscoveryContexts w WHERE w.WanInterface = 'wan1');"); + + migrationBuilder.Sql(@" +UPDATE WanDiscoveryContexts SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + + // Targets and discoveries carry no uniqueness on the WAN key, so a plain rename is safe. + migrationBuilder.Sql(@" +UPDATE MonitoringTargets SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + + migrationBuilder.Sql(@" +UPDATE UpstreamDiscoveries SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + + // A context created against the legacy spelling reads under it too. + migrationBuilder.Sql(@" +UPDATE WanContexts SET WanInterface = 'wan' WHERE WanInterface = 'wan1';"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // 'wan' is the spelling every writer has used since 20260521500000, so the rows this + // migration touched are indistinguishable from the ones it did not. Restoring 'wan1' + // would rename both, which is worse than leaving the normalized key in place - and an + // older build reads 'wan' correctly anyway. + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs new file mode 100644 index 0000000000..2779c18e73 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.Designer.cs @@ -0,0 +1,3393 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260804140000_AddWanProfileRoleMarkers")] + partial class AddWanProfileRoleMarkers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.HasIndex("TaskType"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastSeenAppVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SfpOntHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ChangedAtUtc") + .HasColumnType("TEXT"); + + b.Property("NewChannel") + .HasColumnType("INTEGER"); + + b.Property("NewWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("PreviousChannel") + .HasColumnType("INTEGER"); + + b.Property("PreviousWidthMhz") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAtUtc"); + + b.HasIndex("ApMac", "Band", "ChangedAtUtc"); + + b.ToTable("ApChannelChanges", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApChannelOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("InterferenceSum") + .HasColumnType("REAL"); + + b.Property("LastSampleUtc") + .HasColumnType("TEXT"); + + b.Property("SampleCount") + .HasColumnType("INTEGER"); + + b.Property("TxRetrySum") + .HasColumnType("REAL"); + + b.Property("UtilizationSum") + .HasColumnType("REAL"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BucketDate"); + + b.HasIndex("ApMac", "Band", "Channel", "WidthMhz", "BucketDate") + .IsUnique(); + + b.ToTable("ApChannelOutcomes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("HeightM") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApNeighborSighting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Band") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Bssid") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("SightingCount") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Ssid") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("WidthMhz") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenUtc"); + + b.HasIndex("ApMac", "Band", "Bssid", "Channel") + .IsUnique(); + + b.ToTable("ApNeighborSightings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CmConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StatusPagePath") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("CmConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.CustomOidConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Oid") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "Oid") + .IsUnique(); + + b.ToTable("CustomOidConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsSfp") + .HasColumnType("INTEGER"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("IsActive"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseKeyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntitlementJson") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckAt") + .HasColumnType("TEXT"); + + b.Property("LastCheckError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NextCheckAt") + .HasColumnType("TEXT"); + + b.Property("Org") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PaidThrough") + .HasColumnType("TEXT"); + + b.Property("PerpetualConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SiteAllowance") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.HasIndex("NextCheckAt"); + + b.HasIndex("Status"); + + b.ToTable("LicenseKeyRecords", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("LinkSpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AliasIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("GatewayLocalIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("SnatEnabled") + .HasColumnType("INTEGER"); + + b.Property("SubnetPrefix") + .HasColumnType("INTEGER"); + + b.Property("TargetIp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanIfName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanKey") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WanVlanId") + .HasColumnType("INTEGER"); + + b.Property("WatchdogIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AliasIp") + .IsUnique(); + + b.HasIndex("GatewayLocalIp") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("TargetIp"); + + b.ToTable("MonitoringInterfaces", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("AeRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("AeTempHighC") + .HasColumnType("REAL"); + + b.Property("AeTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Flex25GLatencyMigrated") + .HasColumnType("INTEGER"); + + b.Property("GatewayTempHighC") + .HasColumnType("REAL"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IspHealthScoreWindowHours") + .HasColumnType("INTEGER"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("PhysicalLinkSourceKey") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PonRxPowerLowDbm") + .HasColumnType("REAL"); + + b.Property("PonTempHighC") + .HasColumnType("REAL"); + + b.Property("PonTxPowerHighDbm") + .HasColumnType("REAL"); + + b.Property("SfpTempHighGenericC") + .HasColumnType("REAL"); + + b.Property("ShowCellularTab") + .HasColumnType("INTEGER"); + + b.Property("ShowCmTab") + .HasColumnType("INTEGER"); + + b.Property("ShowOntTab") + .HasColumnType("INTEGER"); + + b.Property("ShowStarlinkTab") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("SwitchTempHighC") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LanFlakyHintDismissedAt") + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanContextId") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OntConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedSfpId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("OntConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OutageAcknowledgement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("OutageStartUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OutageStartUtc"); + + b.ToTable("OutageAcknowledgements", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Sites", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteAgent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentKeyHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EnrolledAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LanIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastVersion") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("TokenCreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentKeyHash"); + + b.HasIndex("EnrollmentTokenHash"); + + b.HasIndex("SiteId"); + + b.ToTable("SiteAgents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LicenseKeyRecordId") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenseKeyRecordId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("SiteLicenseAssignments", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RateProportionalDownloadBurst") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SshKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KeyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PassphraseProtected") + .HasColumnType("TEXT"); + + b.Property("PrivateKeyProtected") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SshKeys"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("Host"); + + b.ToTable("StarlinkConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.TourState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DismissedTours") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeenTourSteps") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TourOffers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToursDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("TourStates", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AncestorHopIps") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProbeSourceIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WanContexts"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastResetAt") + .HasColumnType("TEXT"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ResetMode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapGb") + .HasColumnType("REAL"); + + b.Property("CycleEnd") + .HasColumnType("TEXT"); + + b.Property("CycleStart") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("UsedGb") + .HasColumnType("REAL"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "CycleStart") + .IsUnique(); + + b.ToTable("WanDataUsageHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborIp") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SiteLoadBalances") + .HasColumnType("INTEGER"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("KillChainStage"); + + b.HasIndex("PatternId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SiteLicenseAssignment", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.LicenseKeyRecord", null) + .WithMany() + .HasForeignKey("LicenseKeyRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs new file mode 100644 index 0000000000..d9131d5a73 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260804140000_AddWanProfileRoleMarkers.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Records which WAN holds the primary role, and whether the site load balances, so the answer + /// survives away from a console. Primary is a role rather than a name - any WAN group can hold + /// it - and the paths that need it most cannot ask: the probe-push path runs on the tunnel's + /// background thread with no console call available, and the offline scoring fallbacks would + /// otherwise guess at the conventional first group and be wrong on a WAN2-primary site. + /// + /// Both are nullable on purpose: null means no connected compute has resolved the role yet, and + /// readers must treat that as unknown - falling back to their documented guess - rather than as + /// a negative answer. + /// + public partial class AddWanProfileRoleMarkers : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPrimary", table: "WanProfiles", type: "INTEGER", nullable: true); + migrationBuilder.AddColumn( + name: "SiteLoadBalances", table: "WanProfiles", type: "INTEGER", nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "IsPrimary", table: "WanProfiles"); + migrationBuilder.DropColumn(name: "SiteLoadBalances", table: "WanProfiles"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs new file mode 100644 index 0000000000..3fd1cb3d60 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.Designer.cs @@ -0,0 +1,767 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models.Identity; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations.Auth +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20260804180000_AddUserUiHints")] + partial class AddUserUiHints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasColumnType("BLOB"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RequireMfa") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastLoginMethod") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MembershipVersion") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordIsTemporary") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ActorAuthMethod") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ActorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ActorUserId") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SiteSlug") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("Category"); + + b.HasIndex("SiteSlug"); + + b.HasIndex("TimestampUtc"); + + b.ToTable("AuditEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcrValues") + .HasColumnType("TEXT"); + + b.Property("AllowIdpInitiated") + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("TEXT"); + + b.Property("ButtonLabel") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientSecretProtected") + .HasColumnType("TEXT"); + + b.Property("ClockSkewSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DisplayNameClaim") + .HasColumnType("TEXT"); + + b.Property("EmailClaim") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EndSessionSupport") + .HasColumnType("INTEGER"); + + b.Property("GetClaimsFromUserInfo") + .HasColumnType("INTEGER"); + + b.Property("GroupsClaim") + .HasColumnType("TEXT"); + + b.Property("IdpMetadataUrl") + .HasColumnType("TEXT"); + + b.Property("IdpMetadataXml") + .HasColumnType("TEXT"); + + b.Property("JitProvisioning") + .HasColumnType("INTEGER"); + + b.Property("ManagedByConfigFile") + .HasColumnType("INTEGER"); + + b.Property("ResponseType") + .HasColumnType("TEXT"); + + b.Property("RoleMappingMode") + .HasColumnType("INTEGER"); + + b.Property("SamlDecryptionCertProtected") + .HasColumnType("TEXT"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SpEntityId") + .HasColumnType("TEXT"); + + b.Property("SubjectClaim") + .HasColumnType("TEXT"); + + b.Property("TrustIdpMfa") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UsePkce") + .HasColumnType("INTEGER"); + + b.Property("UsernameClaim") + .HasColumnType("TEXT"); + + b.Property("WantAssertionsEncrypted") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Scheme") + .IsUnique(); + + b.ToTable("FederationProviders", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GroupOrClaimValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("FederationRoleMappings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationSiteMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupOrClaimValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("INTEGER"); + + b.Property("SiteRole") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("TargetValue") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("FederationSiteMappings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SiteGroups", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteGroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("SiteSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteSlug"); + + b.HasIndex("GroupId", "SiteSlug") + .IsUnique(); + + b.ToTable("SiteGroupMembers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.UserUiHint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HintKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TimesShown") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "HintKey") + .IsUnique(); + + b.ToTable("UserUiHints"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SiteRole") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "TargetType", "TargetId") + .IsUnique(); + + b.ToTable("SiteMemberships", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("AspNetUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("TEXT"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationRoleMapping", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.FederationProvider", null) + .WithMany("RoleMappings") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationSiteMapping", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.FederationProvider", null) + .WithMany("SiteMappings") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteGroupMember", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.SiteGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteMembership", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.FederationProvider", b => + { + b.Navigation("RoleMappings"); + + b.Navigation("SiteMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs new file mode 100644 index 0000000000..b93f8465e8 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/Auth/20260804180000_AddUserUiHints.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations.Auth +{ + /// + /// Per-user counts of teaching hints shown, so a hint that exists to reveal a non-obvious + /// gesture can stop repeating once the user has plainly seen it. Per user rather than per site + /// or per install: what someone has learned travels with them, and one operator learning a + /// gesture says nothing about their colleagues. + /// + public partial class AddUserUiHints : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserUiHints", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", maxLength: 450, nullable: false), + HintKey = table.Column(type: "TEXT", maxLength: 100, nullable: false), + TimesShown = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserUiHints", x => x.Id); + }); + + // One row per user per hint - the upsert relies on it, and a duplicate would let a + // hint count twice as slowly and outstay its welcome. + migrationBuilder.CreateIndex( + name: "IX_UserUiHints_UserId_HintKey", + table: "UserUiHints", + columns: new[] { "UserId", "HintKey" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "UserUiHints"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs index 0b7b121582..2617588c7c 100644 --- a/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/Auth/AuthDbContextModelSnapshot.cs @@ -557,6 +557,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SiteGroupMembers", (string)null); }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.UserUiHint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HintKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TimesShown") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "HintKey") + .IsUnique(); + + b.ToTable("UserUiHints"); + }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Identity.SiteMembership", b => { b.Property("Id") diff --git a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs index 30cc360c87..07dd4e1adb 100644 --- a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs @@ -2497,7 +2497,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("SshKeys", (string)null); + b.ToTable("SshKeys"); }); modelBuilder.Entity("NetworkOptimizer.Storage.Models.StarlinkConfiguration", b => @@ -2820,62 +2820,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("TEXT"); - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("ProbeSourceIp") + b.Property("InterfaceName") .HasMaxLength(50) .HasColumnType("TEXT"); - b.HasKey("Id"); - - b.ToTable("WanContexts"); - }); - - modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DownloadMbps") - .HasColumnType("REAL"); - - b.Property("CounterInterface") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("DataPathInterface") + b.Property("Name") + .IsRequired() .HasMaxLength(100) .HasColumnType("TEXT"); - b.Property("GatewayMac") + b.Property("ProbeSourceIp") .HasMaxLength(50) .HasColumnType("TEXT"); - b.Property("Name") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UploadMbps") - .HasColumnType("REAL"); - - b.Property("WanNetworkgroup") - .IsRequired() + b.Property("WanInterface") .HasMaxLength(50) .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("WanNetworkgroup") - .IsUnique(); - - b.ToTable("WanProfiles"); + b.ToTable("WanContexts"); }); modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => @@ -3042,6 +3006,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("WanDiscoveryContexts", (string)null); }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DataPathInterface") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SiteLoadBalances") + .HasColumnType("INTEGER"); + + b.Property("DownloadMbps") + .HasColumnType("REAL"); + + b.Property("GatewayMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UploadMbps") + .HasColumnType("REAL"); + + b.Property("WanNetworkgroup") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanNetworkgroup") + .IsUnique(); + + b.ToTable("WanProfiles"); + }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => { b.Property("Id") diff --git a/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs b/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs index 6818c89009..090c9ac70c 100644 --- a/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs +++ b/src/NetworkOptimizer.Storage/Models/Identity/AuthDbContext.cs @@ -46,6 +46,9 @@ public AuthDbContext(DbContextOptions options) /// WebAuthn passkey credentials (.NET 10 Identity passkey store; design doc 02). public DbSet> Passkeys { get; set; } + /// Per-user counts of teaching hints shown, so a hint can retire once it is learned. + public DbSet UserUiHints { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -61,6 +64,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.OwnsOne(p => p.Data, d => d.ToJson()); }); + // One row per user per hint - the upsert relies on it, and a duplicate would let a hint + // count twice as slowly and outstay its welcome. + modelBuilder.Entity(entity => + { + entity.HasIndex(h => new { h.UserId, h.HintKey }).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.Property(e => e.DisplayName).HasMaxLength(200); diff --git a/src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs b/src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs new file mode 100644 index 0000000000..6d624ecbe2 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Models/Identity/UserUiHint.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace NetworkOptimizer.Storage.Models.Identity; + +/// +/// How many times one user has been shown a particular teaching hint, so a hint that exists only +/// to reveal a non-obvious gesture can stop repeating once they plainly know it. +/// +/// Per USER rather than per site or per install: what someone has learned travels with them across +/// every site they can see, and one operator learning a gesture says nothing about their +/// colleagues. Site-scoped state lives in SystemSettings and install-wide state in AdminSettings; +/// neither can answer "has this person seen it". +/// +/// +/// The pattern is deliberately general - key it, count it, stop at the threshold - so the next +/// hint that wears out its welcome does not need its own table or its own flag. Nothing here is +/// security-relevant: losing a row costs the user one extra tooltip. +/// +/// +public class UserUiHint +{ + public int Id { get; set; } + + /// The Identity user this count belongs to (). + [Required] + [MaxLength(450)] + public string UserId { get; set; } = string.Empty; + + /// + /// Stable identifier for the hint, e.g. wan-filter-compare. Chosen by the caller and + /// never parsed - renaming one simply starts its count over, which is the harmless outcome. + /// + [Required] + [MaxLength(100)] + public string HintKey { get; set; } = string.Empty; + + /// How many times the hint has been shown to this user. + public int TimesShown { get; set; } + + /// When the count last moved, for diagnosing a hint that will not settle. + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/NetworkOptimizer.Storage/Models/WanContext.cs b/src/NetworkOptimizer.Storage/Models/WanContext.cs index fce4bdddf1..e507ceb821 100644 --- a/src/NetworkOptimizer.Storage/Models/WanContext.cs +++ b/src/NetworkOptimizer.Storage/Models/WanContext.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace NetworkOptimizer.Storage.Models; @@ -9,9 +10,9 @@ namespace NetworkOptimizer.Storage.Models; /// existing installs unchanged. Additional contexts describe a secondary WAN: /// probes for targets in the context either bind to /// locally (the gateway policy-routes that source IP out the WAN) or run on the -/// assigned probe-only agent. Lives in each site's own database; the context -/// name becomes the `wan` tag on latency points, emitted only for non-default -/// contexts so the Influx schema stays additive-only. +/// assigned probe-only agent. Lives in each site's own database; +/// becomes the `wan` tag on latency points, emitted +/// only for non-default contexts so the Influx schema stays additive-only. ///
public class WanContext { @@ -41,5 +42,39 @@ public class WanContext ///
public int? AgentId { get; set; } + /// + /// Exact interface the assigned agent binds its probes to (eth8, + /// ppp0), for an agent running on the gateway itself: the probe + /// leaves by that WAN's own data path rather than by whatever the routing + /// table prefers. Only meaningful alongside - the + /// server does not sit on the gateway, so an interface name it cannot see + /// binds nothing. Null for source-IP contexts and for agents that probe on + /// their own default route. + /// + [MaxLength(50)] + public string? InterfaceName { get; set; } + + /// + /// The UniFi WAN key this context measures (wan, wan2), picked + /// from the site's real WANs rather than typed. It is what says where the + /// context's data belongs: the Influx wan tag, the ISP Health report + /// it associates with, and the scope of its upstream discovery. Required on + /// every new context regardless of bind mechanism; nullable only because + /// contexts created before this column existed have no value to backfill + /// from. + /// + [MaxLength(50)] + public string? WanInterface { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Value written to the Influx wan tag for this context's points: the + /// stable UniFi WAN key when the context has one, falling back to the + /// display name for contexts predating . The key + /// survives a rename, which the name does not - a renamed context used to + /// orphan its own history under the old tag value. + /// + [NotMapped] + public string InfluxWanTag => string.IsNullOrEmpty(WanInterface) ? Name : WanInterface!; } diff --git a/src/NetworkOptimizer.Storage/Models/WanProfile.cs b/src/NetworkOptimizer.Storage/Models/WanProfile.cs index 068dc80d8d..dee976e7de 100644 --- a/src/NetworkOptimizer.Storage/Models/WanProfile.cs +++ b/src/NetworkOptimizer.Storage/Models/WanProfile.cs @@ -64,6 +64,32 @@ public class WanProfile /// Expected upload in Mbps, null when the console reported none. public double? UploadMbps { get; set; } + /// + /// Whether this WAN held the primary role when the console last said so. + /// + /// Primary is a ROLE - failover priority and load-balance weight decide it, and any group can + /// hold it - so it cannot be read off the name. Everything that needs the answer away from a + /// console reads it here: the probe-push path (which has no console call available at all) and + /// the offline fallbacks that would otherwise guess at the conventional first group and be + /// wrong on a WAN2-primary site. Exactly one row should carry true; the writer clears the + /// others as it sets one. + /// + /// + /// Null means no connected compute has ever resolved the role for this site - readers must + /// treat that as "unknown" and fall back to their documented guess, not as "not primary". + /// + /// + public bool? IsPrimary { get; set; } + + /// + /// Whether the site load balances across WANs rather than running one primary with failover. + /// Recorded per WAN because it is read per WAN, and because it changes what unpinned probing + /// means: on a failover-only site every unpinned probe leaves by the primary, so it measures + /// the primary honestly; under load balancing it is spread across WANs and attributable to + /// none of them. Null when no connected compute has said. + /// + public bool? SiteLoadBalances { get; set; } + /// When the console last confirmed these figures. public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs b/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs index bdb1af5ec8..9913ea84af 100644 --- a/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs +++ b/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs @@ -534,7 +534,9 @@ public Task WriteLatencyAsync( if (rttMaxMs.HasValue) point = point.Field("rtt_max_ms", rttMaxMs.Value); if (jitterMs.HasValue) point = point.Field("jitter_ms", jitterMs.Value); // Multi-WAN context tag, emitted only for non-default contexts so the - // schema stays additive-only: single-WAN installs never see it. + // schema stays additive-only: single-WAN installs never see it. The value + // is the context's UniFi WAN key where it has one (WanContext.InfluxWanTag), + // so renaming a context does not orphan its own history under the old tag. if (!string.IsNullOrEmpty(wanContext)) point = point.Tag("wan", wanContext); Enqueue(point, longterm: false); @@ -1522,6 +1524,21 @@ public int GetHashCode((string DeviceMac, string IfName) obj) => HashCode.Combine(obj.DeviceMac.ToLowerInvariant(), obj.IfName); } + /// + /// Gateway WAN throughput from the SNMP interface counters, for the interface(s) named. + /// + /// CONTRACT: passing more than one interface SUMS them into a single combined series + /// (grouped per (_time, _field)). That is correct for exactly ONE caller class - the + /// all-WAN usage fingerprint, which asks "was the user doing anything on any WAN" - and + /// wrong for every load/utilization computation, because a summed multi-WAN series divided + /// by one WAN's plan speeds silently understates or overstates load (and ISP Health's + /// packet-loss ceiling scales with load QUADRATICALLY, so the damage compounds). Per-WAN + /// load callers must resolve the one counter interface of the WAN they are pairing with + /// plan speeds and pass exactly that. Callers that intend the sum must say so via + /// ; a multi-interface call without it asserts in + /// debug builds and logs a warning in release (behavior is unchanged so an existing + /// caller cannot break, but the mispairing is named at the choke point). + /// public async Task> QueryGatewayWanRatesAsync( string deviceMac, IReadOnlyList wanIfNames, @@ -1529,10 +1546,22 @@ public async Task> QueryGatewayWanRatesAsync( DateTime to, TimeSpan? aggregateWindow = null, int sampleIntervalSeconds = 5, + bool sumAcrossInterfaces = false, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); if (!IsConfigured || wanIfNames.Count == 0) return Array.Empty(); + if (wanIfNames.Count > 1 && !sumAcrossInterfaces) + { + System.Diagnostics.Debug.Assert(false, + "QueryGatewayWanRatesAsync sums multiple interfaces into one series; that is only " + + "valid for the all-WAN usage fingerprint. Pass sumAcrossInterfaces: true if the sum " + + "is intended, or resolve the single counter interface of the WAN being measured."); + _logger.LogWarning( + "QueryGatewayWanRatesAsync called with {Count} interfaces without sumAcrossInterfaces; " + + "the result is a summed multi-interface series ({IfNames})", + wanIfNames.Count, string.Join(",", wanIfNames)); + } var window = aggregateWindow ?? PickAggregateWindow(to - from, sampleIntervalSeconds); var mac = NormalizeMac(deviceMac); var ifFilter = string.Join(" or ", wanIfNames.Select(n => @@ -1752,11 +1781,13 @@ public async Task> QueryLatencyByTargetTypeRawAsync( } /// Time-series of RTT and loss for multiple monitoring targets, keyed by target_id. + /// Which WAN's points count; null reads every WAN, as it always did. public async Task>> QueryLatencyByTargetTypeAsync( MonitoringTargetType targetType, DateTime from, DateTime to, TimeSpan? aggregateWindow = null, + LatencyWanScope? wanScope = null, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); @@ -1772,7 +1803,7 @@ public async Task>> QueryLatencyByTargetTy from(bucket: ""{_bucket}"") |> range(start: {ToFluxInstant(from)}, stop: {ToFluxInstant(to)}) |> filter(fn: (r) => r._measurement == ""latency"") - |> filter(fn: (r) => {typeFilter}) + |> filter(fn: (r) => {typeFilter}){BuildWanScopeFilter(wanScope)} |> filter(fn: (r) => r._field == ""rtt_avg_ms"" or r._field == ""loss_percent"") |> aggregateWindow(every: {ToFluxDuration(window)}, fn: mean, createEmpty: false) |> pivot(rowKey:[""_time""], columnKey: [""_field""], valueColumn: ""_value"") @@ -1801,16 +1832,71 @@ public async Task>> QueryLatencyByTargetTy return results; } + /// + /// Which WAN's latency series a group-level (target_type) read should return, expressed + /// against the Influx wan tag. The tag is ABSENT on every point the primary path + /// writes (single-WAN installs never emit it - additive-only schema), and carries + /// WanContext.InfluxWanTag (the UniFi wan key, e.g. "wan2") on points probed + /// through a WAN context. Null scope = no wan filter, today's behavior for every + /// non-ISP-Health caller. + /// + /// Include points with NO wan tag (the primary path's points). + /// Tag values to include (a scoped WAN's key, plus any context display + /// names that tagged its points before the stable-key tagging landed). + public sealed record LatencyWanScope(bool IncludeUntagged, IReadOnlyList WanTags) + { + /// Scope for the primary WAN: untagged points, plus any contexts bound to it. + public static LatencyWanScope Primary(IReadOnlyList? primaryContextTags = null) => + new(true, primaryContextTags ?? Array.Empty()); + + /// Scope for a non-primary WAN: only points tagged with its wan-key/context tags. + public static LatencyWanScope ForWan(IReadOnlyList wanTags) => new(false, wanTags); + } + + /// + /// The Flux filter stage for a , or "" for no filter. + /// + /// Filter shape is deliberate - keep it a plain predicate the storage engine can push down: + /// - Primary with no contexts: not exists r.wan. Tag ABSENCE, not empty-string - a + /// series that never wrote the tag has no "wan" column at all, so r.wan == "" would + /// match nothing (the comparison against the missing column is null and the row is dropped). + /// - Non-primary: plain r.wan == "..." equality chain (indexed tag equality; series + /// without the tag simply never match). No regex, no client-side post-filtering. + /// - Primary with contexts bound to the primary WAN (rare): the OR of both shapes, so a + /// primary probed both untagged (server default route) and through a primary context keeps + /// all its points. + /// Do not "simplify" the absence check into an equality against "" - it changes matches, and + /// the mixed OR shape is only emitted when primary-WAN contexts actually exist. + /// + internal static string BuildWanScopeFilter(LatencyWanScope? scope) + { + if (scope == null) return string.Empty; + var clauses = new List(); + if (scope.IncludeUntagged) clauses.Add("not exists r.wan"); + clauses.AddRange(scope.WanTags + .Where(t => !string.IsNullOrEmpty(t)) + .Distinct(StringComparer.Ordinal) + .Select(t => $@"r.wan == ""{SanitizeFluxString(t)}""")); + if (clauses.Count == 0) + // A tags-only scope with no usable tag values can match nothing; emit an + // always-false predicate rather than silently returning every WAN's data. + return "\n |> filter(fn: (r) => exists r.wan and not exists r.wan)"; + return $"\n |> filter(fn: (r) => {string.Join(" or ", clauses)})"; + } + /// /// Like QueryLatencyByTargetTypeAsync but also pivots max RTT and jitter, which the /// ISP Health scorer and congestion/step detectors need. Kept separate so existing - /// chart callers keep the leaner LatencyPoint shape. + /// chart callers keep the leaner LatencyPoint shape. + /// restricts the read to one WAN's series via the wan tag (see + /// ); null keeps today's unscoped read. /// public async Task>> QueryLatencyDetailByTargetTypeAsync( MonitoringTargetType targetType, DateTime from, DateTime to, TimeSpan? aggregateWindow = null, + LatencyWanScope? wanScope = null, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); @@ -1825,7 +1911,7 @@ public async Task>> QueryLatencyDeta from(bucket: ""{_bucket}"") |> range(start: {ToFluxInstant(from)}, stop: {ToFluxInstant(to)}) |> filter(fn: (r) => r._measurement == ""latency"") - |> filter(fn: (r) => {typeFilter}) + |> filter(fn: (r) => {typeFilter}){BuildWanScopeFilter(wanScope)} |> filter(fn: (r) => r._field == ""rtt_avg_ms"" or r._field == ""rtt_max_ms"" or r._field == ""jitter_ms"" or r._field == ""loss_percent"") |> aggregateWindow(every: {ToFluxDuration(window)}, fn: mean, createEmpty: false) |> pivot(rowKey:[""_time""], columnKey: [""_field""], valueColumn: ""_value"") @@ -1888,11 +1974,18 @@ public record LatencySeriesPoint /// intervals), then averages within each target_type, then averages the two category /// means - the same weighting as /api/monitoring/live-stats, so the WAN live chart /// doesn't jump when its buffer swaps between history and live samples.
+ /// + /// Which WAN's points count. Filtering by target id alone is not enough: a host reachable from + /// two WANs is probed under each, and a row that has changed context keeps its older points + /// under the tag they were written with - so one id can hold more than one WAN's readings, and + /// an unscoped read draws another WAN's loss on this one's chart. + /// public async Task> QueryMeanIspTransitLatencyAsync( DateTime from, DateTime to, IReadOnlyList? enabledTargetIds = null, TimeSpan? aggregateWindow = null, + LatencyWanScope? wanScope = null, CancellationToken ct = default) { if (!IsConfigured) await ReconfigureAsync(ct); @@ -1917,7 +2010,7 @@ public async Task> QueryMeanIspTransitLatencyAsync( base = from(bucket: ""{_bucket}"") |> range(start: {ToFluxInstant(queryFrom)}, stop: {ToFluxInstant(to)}) |> filter(fn: (r) => r._measurement == ""latency"") - |> filter(fn: (r) => r.target_type == ""accessisp"" or r.target_type == ""transit""){targetFilter} + |> filter(fn: (r) => r.target_type == ""accessisp"" or r.target_type == ""transit""){targetFilter}{BuildWanScopeFilter(wanScope)} rtt = base |> filter(fn: (r) => r._field == ""rtt_avg_ms"") @@ -2667,7 +2760,7 @@ private static string ToFluxDuration(TimeSpan window) => $"{Math.Max(1, (long)Math.Round(window.TotalSeconds))}s"; private static string SanitizeFluxString(string value) => - value.Replace("\"", "").Replace("\\", "").Replace(")", "").Replace("|>", ""); + value.Replace("\"", "").Replace("\\", "").Replace(")", "").Replace("|>", "").Replace("${", ""); private static DateTime ToUtc(DateTime t) => t.Kind == DateTimeKind.Utc ? t : DateTime.SpecifyKind(t, DateTimeKind.Utc); diff --git a/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs b/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs index bbe7b7770b..053b1bf0c6 100644 --- a/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs +++ b/src/NetworkOptimizer.UniFi/GatewayWanHelper.cs @@ -13,6 +13,61 @@ namespace NetworkOptimizer.UniFi; /// public static class GatewayWanHelper { + /// + /// UniFi's interface key for the first WAN group, and the conventional stand-in for "the WAN" + /// on a site that has only ever had one. + /// + /// This is UniFi's key space, not ours - it belongs here with the rest of the console's + /// conventions. Our own WAN-keyed columns (MonitoringTarget.WanInterface, + /// WanDiscoveryContext.WanInterface, WanContext.WanInterface) deliberately STORE that key + /// rather than inventing a parallel one, which is why storage-side fallbacks may reference + /// this constant. Normalize anything read from storage through + /// first: rows written before that normalization + /// existed can still say "wan1". + /// + /// + /// NOT a synonym for the primary WAN. Group names are arbitrary in UniFi Network and any + /// group can hold the primary role, so this is only ever a last-resort guess for when the + /// console cannot say which one does - it is wrong on a site whose primary is WAN2. Ask + /// UniFiConnectionService.ResolvePrimaryWanNetwork first, and where this value is used as a + /// fallback, say in a comment that it is a guess and what it costs when it misses. + /// + /// + public const string DefaultWanKey = "wan"; + + /// + /// Splits a label produced by back into the connection's name and + /// its WAN token ("Acme Fiber WAN2" -> "Acme Fiber", "WAN2"), so a caller can style the two + /// differently. Name is null when the label carries no name to separate. + /// + /// Exact rather than heuristic for the labels this codebase builds for WAN pickers, which pass + /// no interface or port and therefore have no suffix. A label with a suffix, or one that does + /// not end in its own WAN token, comes back whole as the name so nothing is silently trimmed. + /// + /// + public static (string? Name, string? WanToken) SplitWanLabel(string? label, int wanIndex) + { + if (string.IsNullOrWhiteSpace(label)) return (null, null); + var token = wanIndex >= 1 ? $"WAN{wanIndex}" : null; + if (token == null || !label.EndsWith(token, StringComparison.OrdinalIgnoreCase)) + return (label.Trim(), null); + var name = label[..^token.Length].Trim(); + return (string.IsNullOrEmpty(name) ? null : name, token); + } + + /// + /// A WAN label for running prose, with the WAN token in parentheses after the connection's + /// name ("Acme Fiber (WAN2)"). The pill form runs them together because the pill is a label; + /// a sentence needs the qualifier set apart or it reads as part of the name. Falls back to + /// whatever there is when a label carries no name or no token. + /// + public static string FormatWanLabelInProse(string? label, int wanIndex) + { + var (name, token) = SplitWanLabel(label, wanIndex); + if (string.IsNullOrEmpty(name)) return token ?? label ?? ""; + return string.IsNullOrEmpty(token) ? name! : $"{name} ({token})"; + } + /// /// UniFi network-group convention for a 1-based WAN index: wan1 → "WAN", /// wanN → "WANn". @@ -36,6 +91,22 @@ public static string WanInterfaceKeyFromKey(string wanKey) ? "wan" : wanKey.ToLowerInvariant(); + /// + /// 1-based WAN index from an interface key or wan object key ("wan" and "wan1" → 1, + /// "wan2" → 2). Zero for anything that is not a wan key, which + /// reads as "no WAN label". + /// + public static int WanIndexFromKey(string? wanKey) + { + if (string.IsNullOrWhiteSpace(wanKey)) return 0; + var trimmed = wanKey.Trim(); + if (string.Equals(trimmed, "wan", StringComparison.OrdinalIgnoreCase)) return 1; + return trimmed.StartsWith("wan", StringComparison.OrdinalIgnoreCase) + && int.TryParse(trimmed[3..], out var index) && index >= 1 + ? index + : 0; + } + /// /// Enumerates a gateway's wan1..wan6 objects from raw device JSON as typed /// values (Key set to the source property), diff --git a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs index bc90946528..728590fea6 100644 --- a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs +++ b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs @@ -258,6 +258,13 @@ public class UniFiDeviceResponse [JsonPropertyName("config_network")] public ConfigNetwork? ConfigNetwork { get; set; } + /// + /// The gateway's address on its LAN side. Present on gateways; absent on everything else, and + /// not the same as , which is the WAN address on a gateway. + /// + [JsonPropertyName("lan_ip")] + public string? LanIp { get; set; } + /// /// LAN network configuration - only present on devices acting as the network gateway. /// UDM-family devices (including UX Express) won't have this when operating as APs. diff --git a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs index 6bd17e6764..7ae1f0b04d 100644 --- a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs +++ b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs @@ -1,6 +1,6 @@ using System.Net; -using System.Net.Sockets; using System.Net.Http.Json; +using System.Net.Sockets; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -2182,6 +2182,19 @@ public async Task LogoutAsync(CancellationToken cancellationToken = defaul var body = await response.Content.ReadAsStringAsync(cancellationToken); + // A console mid-reboot or mid-firmware-upgrade serves its web UI - or a proxy's holding + // page - to every request, including API ones. Parsing that raised the JSON reader's + // own words at the user ("'<' is an invalid start of a value. LineNumber: 0"), which + // describes our parser rather than their console and reads like a bug in us. The + // condition is temporary and resolves with no action, so say that. + if (LooksLikeHtml(body)) + { + _logger.LogInformation( + "Site validation got a web page instead of API data - console likely restarting or upgrading"); + return (false, "The UniFi Console returned a web page instead of API data, which usually " + + "means it is restarting or upgrading. This clears on its own once it is back."); + } + // Parse the response to check for API-level errors using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("meta", out var meta)) @@ -2210,6 +2223,14 @@ public async Task LogoutAsync(CancellationToken cancellationToken = defaul _logger.LogDebug("Site '{Site}' validated successfully", _site); return (true, null); } + catch (JsonException ex) + { + // Same situation reached by a shape LooksLikeHtml does not catch - a redirect stub, a + // captive portal, a truncated body. The reader's message is never useful to a user. + _logger.LogInformation(ex, "Site validation could not parse the console's response as JSON"); + return (false, "The UniFi Console did not return valid API data, which usually means it is " + + "restarting or upgrading. This clears on its own once it is back."); + } catch (Exception ex) { _logger.LogError(ex, "Exception during site validation"); @@ -2217,6 +2238,18 @@ public async Task LogoutAsync(CancellationToken cancellationToken = defaul } } + /// + /// Whether a response body is a web page rather than API data. A UniFi Console serves its UI + /// to every request while it reboots or applies a firmware update, so this is the ordinary + /// shape of "come back in a minute", not a malformed reply. + /// + private static bool LooksLikeHtml(string? body) + { + var trimmed = body?.TrimStart(); + return !string.IsNullOrEmpty(trimmed) + && (trimmed[0] == '<' || trimmed.StartsWith(" diff --git a/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs b/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs index 95d6dd0160..8572c0deee 100644 --- a/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs +++ b/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs @@ -819,11 +819,14 @@ public class DiscoveredDevice public int PortCount { get; set; } /// - /// Counter-bearing interface of the PRIMARY WAN only (single entry, by - /// design - do not add secondary/cellular WANs). Feeds the WAN Live View - /// and Monitoring overview throughput, which sit alongside ISP / transit - /// latency cards measured for that one connection; mixing other WANs into - /// the throughput would disagree with them. See + /// Counter-bearing interface of the PRIMARY WAN only (single entry, by design). Feeds the + /// live WAN throughput tiles, which sit alongside ISP / transit latency measured over that + /// one connection, and serves as ISP Health's last-resort counter fallback. Multi-WAN + /// surfaces do NOT widen this list: per-WAN throughput resolves each WAN's own counter + /// interface (UniFiConnectionService.GetWanInterfacesForGroupAsync / the remembered + /// WanProfile row) behind the multi-WAN UI gate, and summing WANs into one series is + /// reserved for the usage fingerprint alone (see + /// MonitoringInfluxClient.QueryGatewayWanRatesAsync's contract). See /// UniFiDiscovery.GetWanInterfaceNames for the selection rules. /// public List WanInterfaceNames { get; set; } = new(); diff --git a/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor b/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor index 02ca4c3bab..b5a659e68c 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Alerts.razor @@ -26,6 +26,7 @@ @inject UniFiConnectionService ConnectionService @inject IGatewaySshService GatewaySshService @inject AgentOnGatewayDetector OnGatewayDetector +@inject NetworkOptimizer.Web.Services.Monitoring.ProbeExecutorFactory ProbeExecutors @inject WanDataUsageService DataUsageService @inject IJSRuntime JS @inject PullToRefreshState PtrState @@ -204,7 +205,7 @@ } - @{ var (wanBaseName, wanDetail) = SplitTaskName(task.Name); } + @{ var (wanBaseName, wanDetail) = SplitTaskName(task.Name); wanDetail = LabelServerVantage(wanDetail); } @wanBaseName @if (wanDetail != null) { @@ -244,7 +245,7 @@
@{ var wanConfig = ParseTargetConfig(task.TargetConfig); } - @(GetConfigValue(wanConfig, "testType") == "server" ? "Server" : "Gateway") + @(GetConfigValue(wanConfig, "testType") == "server" ? ServerVantageLabel : "Gateway") @if (GetConfigValue(wanConfig, "wanName") is string wanName && !string.IsNullOrEmpty(wanName)) { @wanName @@ -1859,7 +1860,7 @@ } @if (!_agentOnGateway || _newWanTestType == "server") { - + }
@@ -3312,6 +3313,26 @@ _ => "status-badge" }; + /// + /// What runs a "server" WAN speed test on THIS site: the server itself, or the on-site agent + /// where one owns path measurement. The stored task name says Server either way, because that + /// is the vantage's name in configuration - but on an agent-covered site the server never + /// touches the WAN, so reading "Server" beside a result the agent produced named the wrong box. + /// + private string ServerVantageLabel => ProbeExecutors.ServerVantageIsAgent ? "Agent" : "Server"; + + /// + /// Renames the stored "Server" detail for display. Applied at render rather than to the task + /// name so schedules made before a site gained its agent read correctly too, with nothing to + /// migrate and the configured vantage unchanged. + /// + private string? LabelServerVantage(string? detail) => + detail is null || !ProbeExecutors.ServerVantageIsAgent + ? detail + : detail == "Server" ? "Agent" + : detail.StartsWith("Server, ", StringComparison.Ordinal) ? $"Agent, {detail["Server, ".Length..]}" + : detail; + private static (string BaseName, string? Detail) SplitTaskName(string name) { // Names are "WAN Speed Test (...)" or "LAN Speed Test (...)" diff --git a/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor b/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor index c6d6a3e3d0..018913049c 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor @@ -34,6 +34,9 @@ @inject NavigationManager NavigationManager @inject PersistentComponentState PersistState @inject NetworkOptimizer.Web.Services.Monitoring.UpstreamTracerService UpstreamTracer +@inject NetworkOptimizer.Web.Services.Monitoring.MonitoringPathView PathView +@inject NetworkOptimizer.Web.Services.Monitoring.LiveWanScope LiveWan +@inject NetworkOptimizer.Web.Services.Monitoring.IspHealth.IspHealthRegistry IspHealthRegistry @inject ISystemSettingsService SystemSettings @inject DashboardLayoutService DashboardLayout @inject CableModemMonitorService CmMonitorService @@ -48,6 +51,7 @@ @inject NetworkOptimizer.Web.Services.Monitoring.FlakyTargetService FlakyTargets @inject NetworkOptimizer.Web.Services.LanFlowMap.LanFlowMapCache LanFlowMapCache @inject ILogger Logger +@inject NetworkOptimizer.Web.Services.UiHintService UiHints @@ -274,29 +278,65 @@
} +
+ @if (LiveWan.HasChoice) + { + @* Separate scope from the analysis selectors on purpose - watching a WAN here must not move them. *@ +
+ @foreach (var w in LiveWan.Options) + { + + } + + @if (LiveWanIsNarrowed) + { + + } +
+ } + +
@{ var wanRates = GetWanRates(); }
@FormatRate(wanRates.download)
-
WAN Download
+
@WanRateLabel("Download")
@FormatRate(wanRates.upload)
-
WAN Upload
+
@WanRateLabel("Upload")
- @{ var ispHealth = IspHealthService.GetCachedScore(); } + @{ var ispHealth = CurrentIspHealth(); }
@ispHealth.TileText
-
ISP Health
+
@IspHealthTileLabel()
@if (ispHealth.Status == IspHealthStatus.Ready && ispHealth.Score.HasValue) {
}
- @{ var ispTarget = GetBestTargetStats(MonitoringTargetType.AccessIsp); } -
- @if (hasIspTargets) + @{ var ispTarget = ScopedTargetStats(MonitoringTargetType.AccessIsp, meanAcrossTargets: false); } +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.AccessIsp, meanAcrossTargets: false)) + { +
@r.Label@FormatRtt(r.Rtt)
+ } +
+ } + else if (hasIspTargets) {
@FormatRtt(ispTarget.rtt)
} @@ -306,8 +346,17 @@ }
ISP RTT
-
- @if (hasIspTargets) +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.AccessIsp, meanAcrossTargets: false)) + { +
@r.Label@FormatLoss(r.Loss)
+ } +
+ } + else if (hasIspTargets) {
@FormatLoss(ispTarget.loss)
} @@ -317,9 +366,18 @@ }
ISP Loss
- @{ var transitTarget = GetMeanTargetStats(MonitoringTargetType.Transit); } -
- @if (hasTransitTargets) + @{ var transitTarget = ScopedTargetStats(MonitoringTargetType.Transit, meanAcrossTargets: true); } +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.Transit, meanAcrossTargets: true)) + { +
@r.Label@FormatRtt(r.Rtt)
+ } +
+ } + else if (hasTransitTargets) {
@FormatRtt(transitTarget.rtt)
} @@ -329,8 +387,17 @@ }
Transit RTT
-
- @if (hasTransitTargets) +
+ @if (LiveWan.IsComparing) + { +
+ @foreach (var r in PerWanTargetStats(MonitoringTargetType.Transit, meanAcrossTargets: true)) + { +
@r.Label@FormatLoss(r.Loss)
+ } +
+ } + else if (hasTransitTargets) {
@FormatLoss(transitTarget.loss)
} @@ -441,16 +508,36 @@
-
+

Latency & Packet Loss

-
- @* Default the category filter to ISP when there are no enabled LAN (Fabric) - targets, so a monitoring-only/agent site opens on a populated chart. mount() - in latency-charts.js seeds currentCategory from whichever button is active. *@ - @{ var hasLanTargets = HasUpstreamTargets(MonitoringTargetType.Fabric); } + @if (_multiWanUiVisible && _wanOptions.Count > 1) + { +
+ @foreach (var w in _wanOptions) + { + + } + + @if (WanFilterIsNarrowed) + { + + } +
+ } +
+ @* No active class here: the chart module owns which category is current, and + Blazor re-rendering this header for any other reason would otherwise assert a + stale one over it. The opening choice is passed to mount() instead. *@
- - + + @@ -488,6 +575,9 @@
+
@@ -500,7 +590,7 @@
@@ -547,23 +637,29 @@
- +
- +
-
- +
+
- @* Multi-WAN contexts management card hidden for now (GA). Re-enable by - uncommenting; the rest of the page already tolerates empty contexts. -
- -
- *@ + @if (_multiWanUiVisible) + { +
+ +
+ } } @* ──────────────── Tab: Device Stats ──────────────── *@ @@ -572,7 +668,7 @@

Device Health

-
+
@@ -703,7 +799,7 @@

SFP Diagnostics

-
+
@@ -910,7 +1006,7 @@

Cable Modem Signal History

-
+
@@ -1041,7 +1137,7 @@

ONT Signal History

-
+
@@ -1196,7 +1292,7 @@

Cellular Signal History

-
+
@@ -1301,7 +1397,7 @@
private bool _canOperate; - protected override async Task OnInitializedAsync() + // Per-WAN report selection: the panel talks to the selected WAN's own IspHealthService + // instance; the injected (primary) service is the default and the only one a single-WAN + // site ever uses. + private IspHealthService? _wanSvc; + private IspHealthService Svc => _wanSvc ?? IspHealth; + private sealed record WanChoice(string Key, string Label, bool IsPrimary); + private List _wanOptions = new(); + // Which WANs a context names. A secondary WAN without one is not "not discovered yet" - it is + // not probed at all, and discovery cannot change that. + private HashSet _wansWithContext = new(StringComparer.OrdinalIgnoreCase); + private string _selectedWanKey = ""; + private string SharedWanScopeKey => SiteContext.ScopeStorageKey("monitoringWanScope"); + + /// + /// WAN choices for the selector: the live WAN list, plus any context-bound WAN the console + /// currently omits (a down WAN's history is still worth reading). One entry = gate closed. + /// + private async Task LoadWanChoicesAsync() { - _canOperate = AuthState is null - || (await Authz.AuthorizeAsync((await AuthState).User, SiteContext.Slug, Policies.SiteOperator)).Succeeded; + var options = new List(); + try + { + foreach (var wan in await PathView.GetWansAsync()) + options.Add(new WanChoice(wan.WanInterface.ToLowerInvariant(), + NetworkOptimizer.UniFi.GatewayWanHelper.FormatWanLabel( + wan.FriendlyName, NetworkOptimizer.UniFi.GatewayWanHelper.WanIndexFromKey(wan.WanInterface), null, null), + wan.IsPrimary)); + } + catch { } + try + { + await using var db = SiteDb.CreateForSite(SiteContext.Slug, SiteContext.IsDefault); + var contexts = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync( + Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.AsNoTracking(db.WanContexts)); + _wansWithContext = contexts + .Where(c => !string.IsNullOrEmpty(c.WanInterface)) + .Select(c => c.WanInterface!.ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var ctx in contexts) + { + if (string.IsNullOrEmpty(ctx.WanInterface)) continue; + var key = ctx.WanInterface!.ToLowerInvariant(); + if (!options.Any(o => o.Key == key)) + options.Add(new WanChoice(key, + NetworkOptimizer.UniFi.GatewayWanHelper.FormatWanLabel( + ctx.Name, NetworkOptimizer.UniFi.GatewayWanHelper.WanIndexFromKey(key), null, null), + IsPrimary: false)); + } + } + catch { } + _wanOptions = options; + _selectedWanKey = options.FirstOrDefault(o => o.IsPrimary)?.Key ?? options.FirstOrDefault()?.Key ?? ""; + } - // Pull-to-refresh on this tab recomputes the scorecard (the Refresh button's action) - // instead of the layout's full-page-reload fallback. - PtrState.RefreshCallback = RefreshAsync; - PtrState.NotifyStateChanged = StateHasChanged; + /// Loads the selected WAN's report, leaving it null on a transient query failure. + /// + /// Waits out the first compute for a WAN the user just switched to, then shows it. Bounded and + /// abandoned if the selection moves on - a compute that outlives the user's interest in it + /// should not redraw the panel underneath whatever they are looking at now. + /// + private async Task PollForFirstReportAsync(string forWanKey) + { + for (var attempt = 0; attempt < 20; attempt++) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + if (!string.Equals(_selectedWanKey, forWanKey, StringComparison.OrdinalIgnoreCase)) return; + try + { + var report = await Svc.GetReportAsync(); + if (report == null) continue; + if (!string.Equals(_selectedWanKey, forWanKey, StringComparison.OrdinalIgnoreCase)) return; + _report = report; + await InvokeAsync(StateHasChanged); + return; + } + catch { return; } + } + } + private async Task LoadReportAsync() + { try { - _report = await IspHealth.GetReportAsync(); + _report = _windowStart.HasValue && _windowEnd.HasValue + ? await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value) + : await Svc.GetReportAsync(); } catch { - // A transient InfluxDB/query failure must not fault the circuit; leave the - // report null and let the status funnels render the fallback message. + // A transient InfluxDB/query failure must not fault the circuit; leave the report null + // and let the status funnels render the fallback message. _report = null; } - finally + } + + private async Task SelectWanAsync(string key, bool persist = true) + { + // Same WAN AND a report already in hand is the no-op this guard is for. Without the + // second half it also swallowed the first selection of the WAN the panel opens on, which + // is the primary: _selectedWanKey is seeded with it, so ?wan= matched and + // returned before fetching anything. The render then found no report, read a status of + // Ready rather than Computing, and fell through to the catch-all telling the operator + // their monitoring was broken - repeatably, and only ever for the primary. + if (string.Equals(key, _selectedWanKey, StringComparison.OrdinalIgnoreCase) && _report != null) return; + var choice = _wanOptions.FirstOrDefault(w => string.Equals(w.Key, key, StringComparison.OrdinalIgnoreCase)); + if (choice == null) return; + _selectedWanKey = choice.Key; + _wanSvc = choice.IsPrimary ? null : IspHealthRegistry.GetFor(SiteContext.Slug, choice.Key); + // Held across the fetch: the moment Svc points at the new WAN, every status funnel below + // is answering for a WAN whose report has not been read yet, and the catch-all among them + // says the site's monitoring is broken. + _loading = true; + // The whole report - the chart element with it - is behind @if (!_loading), so showing the + // spinner tears that div out of the DOM and leaves ApexCharts holding a detached node. The + // mount flag has to come off with it, or the after-render path sees "already mounted", + // skips the re-mount, and the WAN switch lands on an empty chart area. Pushing a new WAN + // to the old instance cannot help: the element it drew into is gone. + await DropChartMountAsync(); + StateHasChanged(); + if (persist) { - _loading = false; + try { await JS.InvokeVoidAsync("localStorage.setItem", SharedWanScopeKey, choice.IsPrimary ? "" : choice.Key); } + catch { } + } + try + { + _report = _windowStart.HasValue && _windowEnd.HasValue + ? await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value) + : await Svc.GetReportAsync(); } + catch { _report = null; } + // The chart fetches its own series and scopes them by WAN, so it has to be told. The + // chart itself is dropped right now (the content render below re-mounts it), but the WAN + // key is module-level JS state that survives the remount - pushing it here means the + // fresh mount's very first fetch is already scoped to the new WAN. + await PushChartWanAsync(); + _loading = false; + // A WAN that has never been scored comes back null with a compute now running behind it. + // Waiting for the 30 s age tick to notice leaves the user on a spinner-less funnel for + // most of a minute, so poll briefly for the result the switch just asked for. + if (_report == null && Svc.Status == IspHealthStatus.Computing) + _ = PollForFirstReportAsync(choice.Key); + StateHasChanged(); + } + + /// Applies the WAN remembered for this site once localStorage is reachable. + + /// + /// The WAN named by a ?wan= query parameter, or null. Unknown values are ignored by the + /// caller rather than erroring: a stale link to a WAN the site no longer has should land on + /// the default report, not on nothing. + /// + /// + /// The WAN Speed Test link for the report on screen, filtered to the same WAN. The speeds in + /// this tile are that WAN's, so the history behind them should be too rather than every WAN's + /// results mixed together. Single-WAN sites get the plain page: the parameter resolves to the + /// one series there and the filter bar does not render at all. + /// + private string WanSpeedTestHref() => + string.IsNullOrEmpty(_selectedWanKey) + ? "/wan-speedtest" + : $"/wan-speedtest?wan={Uri.EscapeDataString(_selectedWanKey)}"; + + private string? LinkedWanKey() + { + try + { + var query = new Uri(NavigationManager.Uri).Query; + var value = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query) + .TryGetValue("wan", out var v) ? v.ToString() : null; + if (string.IsNullOrWhiteSpace(value)) return null; + + var key = value.Trim().ToLowerInvariant(); + // "primary" names the role rather than a WAN group, for links from somewhere that + // shows the primary's figures without knowing which WAN holds that role - it is a + // role in UniFi Network and any group can hold it, so those links cannot spell a key. + return string.Equals(key, Services.Monitoring.LiveWanScope.PrimaryWanToken, StringComparison.Ordinal) + ? _wanOptions.FirstOrDefault(w => w.IsPrimary)?.Key + : key; + } + catch { return null; } + } + + /// + /// A secondary WAN is discovered THROUGH its context - that is what binds a probe to it - so a + /// WAN without one cannot be traced no matter how many times discovery is run. Saying "run + /// discovery" there would send the user somewhere that cannot help them. + /// + private bool NeedsContextFirst + { + get + { + var wan = _wanOptions.FirstOrDefault( + w => string.Equals(w.Key, _selectedWanKey, StringComparison.OrdinalIgnoreCase)); + return wan is { IsPrimary: false } && !_wansWithContext.Contains(wan.Key); + } + } + + /// The selected WAN in prose, for a sentence that names it. + private string SelectedWanLabel + { + get + { + var wan = _wanOptions.FirstOrDefault(w => string.Equals(w.Key, _selectedWanKey, StringComparison.OrdinalIgnoreCase)); + return wan == null + ? "This WAN" + : NetworkOptimizer.UniFi.GatewayWanHelper.FormatWanLabelInProse( + wan.Label, NetworkOptimizer.UniFi.GatewayWanHelper.WanIndexFromKey(wan.Key)); + } + } + + /// Carries the report's WAN into a discovery link so it opens on that WAN's discovery. + private string DiscoveryWanQuery() => + string.IsNullOrEmpty(_selectedWanKey) || _wanOptions.Count <= 1 + ? "" : $"&wan={Uri.EscapeDataString(_selectedWanKey)}"; + + /// True when the selection moved, in which case the report has been loaded with it. + private async Task RestoreWanSelectionAsync() + { + if (_wanOptions.Count <= 1) return false; + + // An explicit ?wan= beats the stored selection for this visit: arriving by a link that + // names a WAN is a statement about which report you want, where the stored value is only + // where you happened to be last. NOT persisted - following a link is not the same as + // choosing a filter, and writing it meant one click from a tile silently became the WAN + // this panel opened on from then on. Coming back without the link reads the stored one + // again, the same rule the Network Performance filter follows. + var linked = LinkedWanKey(); + if (!string.IsNullOrEmpty(linked) + && _wanOptions.Any(w => string.Equals(w.Key, linked, StringComparison.OrdinalIgnoreCase))) + { + await SelectWanAsync(linked!, persist: false); + return true; + } + + try + { + var stored = await JS.InvokeAsync("localStorage.getItem", SharedWanScopeKey); + if (!string.IsNullOrEmpty(stored) + && !string.Equals(stored, _selectedWanKey, StringComparison.OrdinalIgnoreCase) + && _wanOptions.Any(w => string.Equals(w.Key, stored, StringComparison.OrdinalIgnoreCase))) + { + await SelectWanAsync(stored, persist: false); + return true; + } + } + catch { } + return false; + } + + protected override async Task OnInitializedAsync() + { + _canOperate = AuthState is null + || (await Authz.AuthorizeAsync((await AuthState).User, SiteContext.Slug, Policies.SiteOperator)).Succeeded; + + // Pull-to-refresh on this tab recomputes the scorecard (the Refresh button's action) + // instead of the layout's full-page-reload fallback. + PtrState.RefreshCallback = RefreshAsync; + PtrState.NotifyStateChanged = StateHasChanged; + + await LoadWanChoicesAsync(); + + // The report is NOT loaded here. Which WAN it should be for lives in localStorage, which + // needs interop, which is unavailable until after the first render - so loading now would + // compute the primary in the foreground, and on a site where the primary is due a + // recompute that is a long wait for a report the user did not ask for, followed by a + // switch to the one they did. It loads in OnAfterRenderAsync once the WAN is known. // Highlight the button for the effective (possibly auto-reduced) live window. if (_followLive) _selectedPreset = LiveWindowHours; @@ -1045,7 +1341,7 @@ else _autoReloading = true; try { - var latest = await IspHealth.GetReportAsync(); + var latest = await Svc.GetReportAsync(); if (latest != null && (_report == null || latest.ComputedAt != _report.ComputedAt)) { _report = latest; @@ -1099,6 +1395,8 @@ else // cached report, which is what the tab is showing. if (_windowStart.HasValue && _windowEnd.HasValue) url += $"?from={Uri.EscapeDataString(_windowStart.Value.ToString("o"))}&to={Uri.EscapeDataString(_windowEnd.Value.ToString("o"))}"; + if (_wanSvc != null && !string.IsNullOrEmpty(_selectedWanKey)) + url += (url.Contains('?') ? "&" : "?") + $"wan={Uri.EscapeDataString(_selectedWanKey)}"; if (!SiteContext.IsDefault) url = SiteContextService.WithSiteParam(url, SiteContext.Slug); @@ -1147,7 +1445,7 @@ else { if (IsLiveWindow) { - _report = await IspHealth.GetReportAsync(forceRefresh: true); + _report = await Svc.GetReportAsync(forceRefresh: true); // The chart fetches its own series from the freshly cached report. try { await JS.InvokeVoidAsync("eval", "window.__ispHealthCharts?.reload?.();"); } catch { /* chart not mounted yet */ } @@ -1156,7 +1454,7 @@ else { // Recompute the current window, bypassing the custom-window cache; the chart's // follow-up fetch then hits the freshly recomputed result for the same window. - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); await ChartSetWindowAsync(_windowStart, _windowEnd); } } @@ -1174,11 +1472,11 @@ else private async Task OnPhysicalLinkSourceChanged(ChangeEventArgs e) { var key = e.Value?.ToString(); - await IspHealth.SetPhysicalLinkSourceAsync(string.IsNullOrEmpty(key) ? null : key); + await Svc.SetPhysicalLinkSourceAsync(string.IsNullOrEmpty(key) ? null : key); if (IsLiveWindow) - _report = await IspHealth.GetReportAsync(); + _report = await Svc.GetReportAsync(); else if (_windowStart.HasValue && _windowEnd.HasValue) - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); StateHasChanged(); } @@ -1222,9 +1520,9 @@ else try { if (acknowledged) - await IspHealth.AcknowledgeOutageAsync(outageStartUtc); + await Svc.AcknowledgeOutageAsync(outageStartUtc); else - await IspHealth.UnacknowledgeOutageAsync(outageStartUtc); + await Svc.UnacknowledgeOutageAsync(outageStartUtc); await ReloadAfterOutageAckAsync(); } finally @@ -1238,9 +1536,9 @@ else private async Task ReloadAfterOutageAckAsync() { if (IsLiveWindow) - _report = await IspHealth.GetReportAsync(); + _report = await Svc.GetReportAsync(); else if (_windowStart.HasValue && _windowEnd.HasValue) - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); StateHasChanged(); } @@ -1264,11 +1562,11 @@ else StateHasChanged(); try { - await IspHealth.SetAccessTechnologyAsync((AccessTechnology)techValue); + await Svc.SetAccessTechnologyAsync((AccessTechnology)techValue); if (IsLiveWindow) - _report = await IspHealth.GetReportAsync(); + _report = await Svc.GetReportAsync(); else if (_windowStart.HasValue && _windowEnd.HasValue) - _report = await IspHealth.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); + _report = await Svc.GetReportForWindowAsync(_windowStart.Value, _windowEnd.Value, forceRefresh: true); } finally { @@ -1316,6 +1614,17 @@ else protected override async Task OnAfterRenderAsync(bool firstRender) { + // Interop is unavailable during prerender, so the remembered WAN can only be applied here - + // and the report waits for it. Restoring first means one compute, for the WAN the user + // actually left the tab on, and the toolbar stays hidden behind _loading until the right + // pill is the lit one, so the selection never visibly jumps. + if (firstRender) + { + var switched = await RestoreWanSelectionAsync(); + if (!switched) await LoadReportAsync(); + _loading = false; + StateHasChanged(); + } if (_scrollChartAfterRender) { _scrollChartAfterRender = false; @@ -1351,22 +1660,66 @@ else } catch { /* tooltip refresh is best-effort */ } } + // A WAN switch renders the spinner state while the new report is fetched, and the report + // body - the chart's element with it - is behind @if (!_loading), so during that render + // there is nothing to mount into. This after-render still fires (the old report is still + // in hand, and DropChartMountAsync just lowered the flag), and mounting here is exactly + // what broke WAN switching: mount() found no element and returned WITHOUT throwing, the + // optimistic flag below stayed raised, and when the body came back the "already mounted" + // guard skipped the real mount - an empty chart area on every WAN switched to in-page, + // permanent because the unmount had also stopped the poll timer. The element only exists + // when !_loading, so leave the mount to that render's own after-render pass. + if (_loading) return; if (_chartMounted) return; + // Raised before the awaits, not after: renders interleave with them, and a second + // after-render must not start a second mount while this one is in flight. _chartMounted = true; var fromArg = _windowStart.HasValue ? $"'{_windowStart.Value:o}'" : "null"; var toArg = _windowEnd.HasValue ? $"'{_windowEnd.Value:o}'" : "null"; try { _selfRef ??= DotNetObjectReference.Create(this); - await JS.InvokeVoidAsync("eval", + // mount reports whether it found the element and built the chart. A miss returns + // false rather than throwing, so it cannot be left to the catch below: the flag must + // come back down on a miss or no later render will ever retry the mount. + var mounted = await JS.InvokeAsync("eval", $"(async () => {{ const m = await import('{VersionedJs("/js/isp-health-charts.js")}'); " + - $"window.__ispHealthCharts = m; await m.mount('isp-health-asn-chart', {fromArg}, {toArg}, {HiddenChartTypesJson()}); }})();"); + $"window.__ispHealthCharts = m; return await m.mount('isp-health-asn-chart', {fromArg}, {toArg}, {HiddenChartTypesJson()}); }})();"); + if (!mounted) { _chartMounted = false; return; } // Hand the chart a callback so its drag-zoom can filter the events list to the visible window. await JS.InvokeVoidAsync("__ispHealthCharts.setDotNetRef", _selfRef); + await PushChartWanAsync(); } catch { _chartMounted = false; } } + /// + /// Tells the per-network chart which WAN to fetch. Null for the primary, matching how the + /// report itself is sourced - the primary's series are the unscoped ones. + /// + /// + /// Forgets the mounted chart so the next render builds a new one. Called whenever the report + /// content is about to be replaced, since the chart's element goes with it. + /// + private async Task DropChartMountAsync() + { + if (!_chartMounted) return; + _chartMounted = false; + try { await JS.InvokeVoidAsync("eval", "window.__ispHealthCharts?.unmount?.();"); } + catch { /* nothing mounted, or the circuit is going away */ } + } + + private async Task PushChartWanAsync() + { + // Deliberately NOT gated on _chartMounted: the WAN key is module-level JS state that + // survives an unmount, so pushing it while the chart is dropped mid-switch is how the + // upcoming re-mount fetches the right WAN on its first load. setWan itself no-ops the + // fetch while unmounted. + var wan = _wanSvc == null ? "null" : $"'{System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(_selectedWanKey)}'"; + try { await JS.InvokeVoidAsync("eval", $"window.__ispHealthCharts?.setWan({wan});"); } + catch { /* module not imported yet (first page load) - the post-mount push covers that */ } + } + private void ToggleCustomPopover() { _showCustomPopover = !_showCustomPopover; @@ -1401,7 +1754,11 @@ else // datetime-local binds local wall-clock (Kind=Unspecified); treat it as local -> UTC. var startUtc = _customFrom.ToUniversalTime(); var endUtc = _customTo.ToUniversalTime(); - if (endUtc <= startUtc) return; + // A start after the end is a typo, not a request for nothing. Collapsing it onto the end + // lets the minimum-window rule below open it back up, so the user gets the shortest real + // window instead of a popover that closes and leaves the previous one on screen with no + // sign the input was rejected. + if (startUtc > endUtc) startUtc = endUtc; if (endUtc - startUtc < TimeSpan.FromHours(MinRangeHours)) startUtc = endUtc.AddHours(-MinRangeHours); // enforce the minimum window if (endUtc - startUtc > TimeSpan.FromHours(MaxRangeHours)) @@ -1422,8 +1779,8 @@ else // Default (null window) serves the cached 48 h report; an explicit window computes // off-cache and never disturbs it. Stale report stays on screen until the swap. _report = fromUtc.HasValue && toUtc.HasValue - ? await IspHealth.GetReportForWindowAsync(fromUtc.Value, toUtc.Value) - : await IspHealth.GetReportAsync(); + ? await Svc.GetReportForWindowAsync(fromUtc.Value, toUtc.Value) + : await Svc.GetReportAsync(); } catch { _report = null; } finally { _windowComputing = false; } diff --git a/src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor new file mode 100644 index 0000000000..3b8cf0c36b --- /dev/null +++ b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/MonitoringJumpButton.razor @@ -0,0 +1,30 @@ +@* Cross-tab jump between watching and analyzing the same moment: the magnifier opens the + analysis view for what is on screen, the eye plays back the moment the analysis is framing. + One component so the two directions cannot drift apart in glyph, hit area, or tooltip mode. *@ + + +@code { + public enum JumpIcon { Analyze, Watch } + + [Parameter] public JumpIcon Icon { get; set; } + [Parameter] public string Tooltip { get; set; } = ""; + [Parameter] public EventCallback OnClick { get; set; } +} diff --git a/src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor new file mode 100644 index 0000000000..6f40b30b39 --- /dev/null +++ b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/WanFilterResetButton.razor @@ -0,0 +1,14 @@ +@* The same clear-filter control the chart chip rows render from chart-filter.js, for the WAN pill + bars that are built in Razor. One component so the glyph cannot drift between them. *@ + + +@code { + [Parameter] public EventCallback OnReset { get; set; } +} diff --git a/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor b/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor index 44a58961c9..27ba7509a3 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SiteSwitcher.razor @@ -152,12 +152,18 @@ || string.Equals(pathOnly, "denied", StringComparison.OrdinalIgnoreCase)) return NavigationManager.BaseUri; + // A ?wan= names one site's WAN, and every reader of it matches by key or index - so + // carrying one across a switch either filters the new site to a WAN it does not have or, + // worse, quietly matches a different connection that happens to share the number. Dropped + // on every page; the rest of the query and the #fragment carry as before. + var target = SiteContextService.RemoveQueryParam(NavigationManager.Uri, "wan"); + // Client Performance pins a specific client via ?ip= - that address // belongs to the site being left, so a switch drops it and lands on the // new site's own client view (tab/range params and the #fragment carry). if (string.Equals(pathOnly, "client-dashboard", StringComparison.OrdinalIgnoreCase)) - return SiteContextService.RemoveQueryParam(NavigationManager.Uri, "ip"); + return SiteContextService.RemoveQueryParam(target, "ip"); - return NavigationManager.Uri; + return target; } } diff --git a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor index 93d84facbc..b83199037b 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor @@ -122,7 +122,7 @@
@if (ShowLiveViewLink) { - @TestTime.ToLocalTime().ToString("g") @TimeFormatHelper.FormatRelativeTimeShort(TestTime) @@ -507,6 +507,25 @@ return stampMs - 2 * DurationSeconds * 1000L; } } + /// Live View at the moment of this test, on the WAN that ran it. + private string LiveViewHref => $"/monitoring?tab=live&at={LiveViewAtMs}{LiveViewWanQuery}"; + + /// + /// The WAN the result was measured on, as the live filter's interface key ("&wan=wan2"). + /// A timestamp alone lands on whichever WAN the filter was left on, which for a multi-WAN site + /// is usually not the one the result is describing - the spike the link exists to show is on + /// the WAN that ran the test. Empty for a result with no WAN (LAN and client tests), and + /// harmless on a single-WAN site: the key matches no option there, so the filter stands. + /// + private string LiveViewWanQuery + { + get + { + var wanIndex = GatewayWanHelper.WanIndexFromKey(Result?.WanNetworkGroup); + return wanIndex > 0 ? $"&wan={GatewayWanHelper.WanInterfaceKey(wanIndex)}" : ""; + } + } + [Parameter] public double? PingMs { get; set; } [Parameter] public double? JitterMs { get; set; } [Parameter] public double? DownloadLatencyMs { get; set; } diff --git a/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor index d5af53167d..7ae78c3e46 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/UpstreamTracerPanel.razor @@ -3,6 +3,8 @@ @using NetworkOptimizer.Web.Services.Monitoring @implements IDisposable @inject UpstreamTracerService Tracer +@inject NetworkOptimizer.Web.Services.Monitoring.UpstreamTracerRegistry TracerRegistry +@inject NetworkOptimizer.Web.Services.Monitoring.MonitoringPathView PathView @inject NetworkOptimizer.Web.Services.IUpstreamDiscoveryService Discovery @inject ILogger Logger @inject NetworkOptimizer.Storage.Services.SiteDbContextFactory SiteDb @@ -16,7 +18,25 @@
-

Upstream path discovery

+

Upstream Path Discovery

+ @if (_wanChoices.Count > 1) + { +
+ @foreach (var w in _wanChoices) + { + + } +
+ }
@StateLabel(_state.Step) @(_collapsed ? "▼" : "▲") @@ -154,7 +174,7 @@ -
-
- - +
-
- - +
+ +
-
- - @foreach (var agent in _agents) { @@ -91,20 +183,88 @@ }
-
- -
+
+ + - - + + +
+ @if (SelectedAgentOutdated) + { +
+ This agent is on an older release. Update it from Settings - Multi-Site - + some of the options here only appear once it has.
+ } +
+

Help

+ @if (!SelectedAgentCanBindSource) + { +

+ Give a source IP for local probing (the gateway must policy-route it out this + WAN), or assign a probe-only agent bound behind the WAN - not both. Assigned + targets are probed only by that agent. +

+ } + else + { +

+ This agent binds this address for the WAN's probes. The address needs its own + interface and MAC - UniFi matches the policy-based route by Client Device - so + a second address on an existing NIC won't route differently. +

+ } + @if (SelectedAgentCanBindInterface) + { +

+ This agent runs on the gateway, so its probes can go out the WAN's own + interface. Selecting a WAN fills this in. A gateway agent has to bind it: + routing policy does not govern the gateway's own traffic. +

+ } + @if ((!string.IsNullOrWhiteSpace(_newSourceIp) && !SelectedAgentCanBindSource) || SelectedAgentNeedsPolicyRoute) + { +

+ This agent reaches @WanLabelFor(_newWanInterface) only if the gateway routes it there. In UniFi + Network, go to Settings - Policy Table and add a Policy-Based Route with + this WAN as the interface, this agent's Client Device as the source, and + Any as the destination. The source is matched by MAC, so the agent needs an + interface and MAC of its own - an LXC has one already, a VM or Docker + container can be given one - not a second address on a host that's already + on the network. +

+ }
-

- Give a source IP for local probing (the gateway must policy-route it out this - WAN), or assign a probe-only agent bound behind the WAN - not both. Assigned - targets are probed only by that agent. -

@if (!string.IsNullOrEmpty(_addError)) {

@_addError

@@ -115,7 +275,7 @@ { } @@ -124,6 +284,85 @@
+ + @code { [Parameter, EditorRequired] public List WanContexts { get; set; } = new(); @@ -131,23 +370,162 @@ [Parameter] public EventCallback OnChanged { get; set; } + /// + /// Raised to open the Latency Targets card. Not currently wired to a control - Assign targets + /// sends a vantage with no targets to discovery instead - but kept for the case where a + /// vantage that already HAS targets wants to jump to its list. + /// + [Parameter] + public EventCallback OnAssignTargets { get; set; } + + /// Raised by Assign targets on a vantage with no targets: run discovery for its WAN. + [Parameter] + public EventCallback OnDiscoverTargets { get; set; } + + /// + /// Opens the card when a link sent the user here. A WAN with no context cannot be discovered + /// at all, so ISP Health points at this card - and arriving to find it collapsed would hide + /// the very thing the link was about. + /// + [Parameter] + public bool ForceExpand { get; set; } + private bool _collapsed = true; - private bool _showAdd; - private bool _adding; + private bool _showForm; + private bool _revealForm; + private bool _saving; + private int? _editingId; private string _newName = ""; private string _newDescription = ""; private string _newSourceIp = ""; private string _newAgentId = ""; + private string _newWanInterface = ""; + private string _newInterfaceName = ""; + private bool _agentTouched; private string? _addError; private List _agents = new(); + private IReadOnlyList _wans = Array.Empty(); + // Null until a connected compute has recorded the site's WAN roles: neither sentence below is + // shown on a guess, because they prescribe opposite things. + private bool? _siteLoadBalances; + private string? _primaryWanLabel; + // Agents that both run on the gateway and told us they can bind a probe source. Interface + // binding is only offered for those: an agent elsewhere on the network has no WAN interface to + // bind, and one that cannot bind at all would fail every probe in the context. + private HashSet _bindCapableAgents = new(); + private HashSet _sourceBindAgents = new(); + private HashSet _connectedAgents = new(); private Dictionary _targetCounts = new(); + private bool SelectedAgentCanBindInterface => + int.TryParse(_newAgentId, out var agentId) && _bindCapableAgents.Contains(agentId); + + /// + /// Whether the selected agent binds probes to an ADDRESS rather than an interface: it reports + /// the capability but does not run on the gateway, so it has no WAN interface of its own to + /// leave by. One such agent with an interface per WAN covers several WANs on its own. + /// + private bool SelectedAgentCanBindSource => + int.TryParse(_newAgentId, out var agentId) && _sourceBindAgents.Contains(agentId); + + /// + /// Whether the selected agent binds nothing of its own, so the WHOLE box has to be routed out + /// the WAN for its probes to go anywhere near it. True for an agent that cannot bind at all - + /// an older binary - which is the setup that silently measures the primary WAN and files the + /// results under another one if the route is never built. + /// + /// + /// Whether THIS server still probes this site's paths, which is what a vantage with no agent + /// depends on: the server is the thing binding the address. False for every secondary site, and + /// false for the main site once its agent owns path measurement - the off-site-server case, + /// where a local address the gateway is meant to policy-route could not be bound anyway. + /// + private bool ServerProbesThisSite => + SiteCtx.IsDefault && !AgentCoverage.AgentOwnsPathMeasurement(SiteCtx.Slug); + + /// + /// A saved vantage that nothing probes: it has no agent, and this server has stood down from + /// probing this site. It keeps its targets and collects nothing, which looks identical to a + /// WAN that is simply quiet. + /// + private bool IsOrphaned(WanContext context) => context.AgentId == null && !ServerProbesThisSite; + + /// + /// A vantage whose agent CAN bind but which has nothing to bind to, so its probes leave by the + /// agent's default route and are filed under this WAN regardless. Reachable without anything + /// going wrong: save the vantage while the agent is too old to offer a binding, then update the + /// agent - the capability arrives, the empty configuration does not change, and nothing says so. + /// + private bool BindsNothing(WanContext context) => + context.AgentId is int id + && (_bindCapableAgents.Contains(id) || _sourceBindAgents.Contains(id)) + && string.IsNullOrEmpty(context.InterfaceName) + && string.IsNullOrEmpty(context.ProbeSourceIp); + + /// + /// Whether the selected agent reports a version older than the release currently expected of + /// agents. Separate from what it can BIND - an agent can be current and still not bind on a + /// platform that cannot - so this says only what it is: out of date, and worth updating before + /// concluding an option is missing. + /// + private bool SelectedAgentOutdated => + int.TryParse(_newAgentId, out var outdatedAgentId) + && _agents.FirstOrDefault(a => a.Id == outdatedAgentId) is SiteAgent selected + && NetworkOptimizer.Core.Helpers.VersionUtilities.IsOlderThan( + selected.LastVersion, NetworkOptimizer.Web.Services.AppVersionInfo.LatestAgentVersion); + + private bool SelectedAgentNeedsPolicyRoute => + !string.IsNullOrEmpty(_newAgentId) && !SelectedAgentCanBindInterface && !SelectedAgentCanBindSource; + // WAN contexts are per-site data: route through the current site's database. private NetworkOptimizerDbContext CreateDb() => SiteDb.CreateForSite(SiteCtx.Slug, SiteCtx.IsDefault); private void ToggleCollapse() => _collapsed = !_collapsed; - protected override async Task OnInitializedAsync() => await LoadAgentsAsync(); + protected override void OnParametersSet() + { + if (ForceExpand) _collapsed = false; + } + + protected override async Task OnInitializedAsync() + { + // Agents and WANs name the rows in the table, not just the form's pickers, so both load up + // front. Both are cached upstream, and the card only renders on a multi-WAN site at all. + await LoadAgentsAsync(); + await LoadWansAsync(); + LoadConnectedAgents(); + await LoadBindCapableAgentsAsync(); + } + + private void LoadConnectedAgents() + { + try { _connectedAgents = TunnelRegistry.GetForSite(SiteCtx.Slug).Select(c => c.AgentId).ToHashSet(); } + catch { _connectedAgents = new(); } + } + + /// + /// A vantage with no targets is at its starting state, and discovery is how that state is + /// left: sending someone to an empty target list asks them to hand-enter what a trace would + /// find. The WAN goes with it so discovery opens on the one they clicked. + /// + private async Task JumpToDiscoveryAsync(WanContext context) + { + if (OnDiscoverTargets.HasDelegate) + await OnDiscoverTargets.InvokeAsync(context.WanInterface); + } + + private async Task JumpToLatencyTargetsAsync() + { + // The page owns both cards, so it is the one that can open the other. Falls back to + // scrolling on its own if nothing is listening. + if (OnAssignTargets.HasDelegate) + { + await OnAssignTargets.InvokeAsync(); + return; + } + try { await JS.InvokeVoidAsync("noHighlightTarget", "latency-targets"); } + catch { } + } protected override async Task OnParametersSetAsync() { @@ -161,12 +539,73 @@ .ToDictionaryAsync(g => g.Key, g => g.Count); } catch { _targetCounts = new(); } + LoadConnectedAgents(); } + /// + /// Opens the add form and scrolls it into view. The card sits at the bottom of the page, so + /// the form can otherwise open entirely below the fold and the button look like it did nothing. + /// private async Task ShowAddAsync() + { + ResetForm(); + await LoadPickerDataAsync(); + _showForm = true; + _revealForm = true; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // After the render that created it, not before - there is no element to scroll to until + // the form is actually in the DOM. Scroll only, no ring: the user pressed the button that + // opened this, so nothing needs pointing out to them. + if (!_revealForm) return; + _revealForm = false; + await JS.InvokeVoidAsync("noScrollTo", "wan-vantage-form", "center"); + } + + private async Task EditContextAsync(WanContext context) + { + ResetForm(); + _editingId = context.Id; + _newName = context.Name; + _newDescription = context.Description ?? ""; + _newSourceIp = context.ProbeSourceIp ?? ""; + _newAgentId = context.AgentId?.ToString() ?? ""; + _newWanInterface = context.WanInterface ?? ""; + _newInterfaceName = context.InterfaceName ?? ""; + await LoadPickerDataAsync(); + // A vantage saved before its agent could bind has no interface, and the agent gaining the + // capability does not give it one - it would go on probing the default route forever. Offer + // the WAN's own interface; saving is still the user's move. + if (string.IsNullOrEmpty(_newInterfaceName)) FillInterfaceFromWan(); + _showForm = true; + } + + private void CancelForm() + { + _showForm = false; + ResetForm(); + } + + private void ResetForm() + { + _editingId = null; + _agentTouched = false; + _newName = ""; + _newDescription = ""; + _newSourceIp = ""; + _newAgentId = ""; + _newWanInterface = ""; + _newInterfaceName = ""; + _addError = null; + } + + private async Task LoadPickerDataAsync() { await LoadAgentsAsync(); - _showAdd = true; + await LoadWansAsync(); + await LoadBindCapableAgentsAsync(); } /// @@ -190,6 +629,151 @@ catch { _agents = new(); } } + /// + /// The site's real WANs, so the WAN a context measures is picked rather than typed. Empty when + /// the console is unreachable; an existing context keeps showing its stored WAN either way. + /// + private async Task LoadWansAsync() + { + try { _wans = await PathView.GetWansAsync(); } + catch { _wans = Array.Empty(); } + + // Which WAN holds the primary role, and whether the site load balances, decide which + // guidance applies - unpinned probing measures the primary honestly under failover, and + // nothing at all under load balancing. Prefer the live answer; fall back to what the last + // connected compute recorded, and say nothing when neither can answer. + var live = _wans.FirstOrDefault(w => w.IsPrimary); + _primaryWanLabel = live != null + ? GatewayWanHelper.FormatWanLabelInProse( + WanLabelFor(live.WanInterface), GatewayWanHelper.WanIndexFromKey(live.WanInterface)) + : null; + try + { + await using var db = SiteDb.CreateForSite(SiteCtx.Slug, SiteCtx.IsDefault); + var primary = await db.WanProfiles.AsNoTracking().FirstOrDefaultAsync(w => w.IsPrimary == true); + _siteLoadBalances = primary?.SiteLoadBalances; + if (_primaryWanLabel == null && primary != null) + { + var key = GatewayWanHelper.WanInterfaceKeyFromKey(primary.WanNetworkgroup); + _primaryWanLabel = GatewayWanHelper.FormatWanLabelInProse( + WanLabelFor(key), GatewayWanHelper.WanIndexFromKey(key)); + } + } + catch { _siteLoadBalances = null; } + } + + /// + /// Sorts the connected agents into the two ways of binding a probe. Both need the agent to say + /// in its hello that it can bind at all - an agent too old to say counts as no - and what + /// separates them is where it runs. On the gateway it has the WAN's own interface to leave by + /// (asked per agent address, since a site can have several agents). Anywhere else it has no WAN + /// interface, so it binds one of its own addresses instead and the gateway policy-routes that + /// address out the WAN. + /// + private async Task LoadBindCapableAgentsAsync() + { + var byInterface = new HashSet(); + var byAddress = new HashSet(); + try + { + foreach (var connection in TunnelRegistry.GetForSite(SiteCtx.Slug)) + { + if (connection.SupportsSourceBind != true) continue; + if (await OnGatewayDetector.MatchGatewayAddressAsync(SiteCtx.Slug, connection.HostAddresses) != null) + byInterface.Add(connection.AgentId); + else + byAddress.Add(connection.AgentId); + } + } + catch { } + _bindCapableAgents = byInterface; + _sourceBindAgents = byAddress; + } + + /// + /// Assigning an agent that cannot bind an address settles where the probe leaves from, so the + /// source IP field goes away rather than sitting there as a second answer the save would + /// reject. For an agent that CAN bind one, the address is the agent's own binding and stays. + /// + private void OnAgentSelected(ChangeEventArgs e) + { + _agentTouched = true; + _newAgentId = e.Value?.ToString() ?? ""; + // Clearing the address is right only where the agent cannot bind one - there it is the + // server's mechanism and the agent has just replaced it. An agent that binds addresses + // keeps what was typed, since that is now the agent's own binding. + if (!string.IsNullOrEmpty(_newAgentId) && !SelectedAgentCanBindSource) + _newSourceIp = ""; + if (!SelectedAgentCanBindInterface) + _newInterfaceName = ""; + else + FillInterfaceFromWan(); + } + + private void OnWanSelected(ChangeEventArgs e) + { + _newWanInterface = e.Value?.ToString() ?? ""; + // Probing a secondary WAN from the server is the exception, not the default: it needs a + // policy route built by hand, while an agent binds for itself. So picking a WAN offers the + // first agent rather than None. Only on a new vantage, and only until the agent field is + // touched - after that the choice is the user's, including a deliberate None. + if (!_agentTouched && _editingId == null && string.IsNullOrEmpty(_newAgentId) && _agents.Count > 0) + { + _newAgentId = _agents[0].Id.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!SelectedAgentCanBindSource) _newSourceIp = ""; + } + FillInterfaceFromWan(); + } + + /// + /// Fills the bind interface from the selected WAN's data path (its uplink interface, falling + /// back to the physical port). Deliberately not the counter interface: throughput is read from + /// the physical port because VLAN sub-interface counters double, while a probe has to leave by + /// the logical uplink - a PPPoE WAN's traffic goes out ppp0, not eth6. + /// + private void FillInterfaceFromWan() + { + if (!SelectedAgentCanBindInterface) return; + if (KnownWanInterface is { Length: > 0 } dataPath) + _newInterfaceName = dataPath; + } + + /// + /// The selected WAN's data-path interface as the console reports it, or null when the console + /// has nothing to say about that WAN - a WAN that is down, or one the site has only through a + /// vantage. Not a guess when it has a value, which is why the field showing it is read-only: + /// an agent on the gateway leaves by that interface or it does not leave by the WAN at all. + /// + private string? KnownWanInterface + { + get + { + var wan = _wans.FirstOrDefault(w => + string.Equals(w.WanInterface, _newWanInterface, StringComparison.OrdinalIgnoreCase)); + return wan?.UplinkIfName ?? wan?.PhysicalIfName; + } + } + + /// + /// What this context's probes leave from - the bound interface for an on-gateway agent, the + /// policy-routed source IP otherwise. Null when it binds neither, which leaves probes on + /// whatever route the prober already has. + /// + private static string? ProbeSourceOf(WanContext context) + => !string.IsNullOrEmpty(context.InterfaceName) ? context.InterfaceName + : !string.IsNullOrEmpty(context.ProbeSourceIp) ? context.ProbeSourceIp + : null; + + /// + /// Network Tools, pointed at whatever probes this vantage - its agent, or this server when the + /// vantage is an address the server binds. The point of going there is the first-hop check: + /// probes that leave by the wrong WAN look identical here and only differ there. + /// + private static string VerifyUrl(WanContext context) => + context.AgentId is int id + ? $"/network-tools?from=agent:{id}" + : $"/network-tools?from={NetworkOptimizer.Web.Services.Monitoring.ProbeVantages.ServerKey}"; + private string AgentLabel(int? agentId) { if (agentId == null) return "-"; @@ -197,61 +781,186 @@ return agent?.Name ?? $"agent {agentId}"; } - private async Task AddContextAsync() + /// + /// WAN label with the group set apart from the name ("My ISP (WAN2)") rather than run together + /// as the pill form does. Everything in this card is plain text in a list or a cell, where the + /// qualifier reads as part of the name unless it is bracketed off. + /// + private static string WanOptionLabel(WanSummary wan) + => WanProseLabel(wan.FriendlyName, GatewayWanHelper.WanIndexFromKey(wan.WanInterface)); + + private static string WanProseLabel(string? friendlyName, int wanIndex) + => GatewayWanHelper.FormatWanLabelInProse( + GatewayWanHelper.FormatWanLabel(friendlyName, wanIndex, null, null), wanIndex); + + /// + /// The name to use when the user leaves the field empty: the WAN itself, in the parenthetical + /// form used everywhere else ("Acme Fiber (WAN2)"), or the bare token for a WAN with no name of + /// its own. Naming a context is a chore with one sensible answer nearly every time - a WAN is + /// what a context is for - so the field asks rather than demands. + /// + /// Empty until a WAN is chosen, so nothing is suggested before there is anything to suggest. + /// A suggestion that collides with an existing context fails the same duplicate-name check a + /// typed one would, which is the honest outcome: two contexts on one WAN need telling apart. + /// + /// + private string SuggestedName() + { + if (string.IsNullOrWhiteSpace(_newWanInterface)) return ""; + return WanLabelFor(_newWanInterface); + } + + private string NamePlaceholder() + { + var suggested = SuggestedName(); + return string.IsNullOrEmpty(suggested) ? "backup-wan" : suggested; + } + + /// + /// Label for a stored WAN key, preferring the live WAN's friendly name and falling back to the + /// group alone so a vantage whose WAN is down (or predates the column) still reads sensibly. + /// + private string WanLabelFor(string? wanInterface) + { + if (string.IsNullOrEmpty(wanInterface)) return "-"; + var wan = _wans.FirstOrDefault(w => + string.Equals(w.WanInterface, wanInterface, StringComparison.OrdinalIgnoreCase)); + return wan != null + ? WanOptionLabel(wan) + : WanProseLabel(null, GatewayWanHelper.WanIndexFromKey(wanInterface)); + } + + /// + /// The rules a context has to satisfy, in the order the user meets them. Returns the message to + /// show, or null when the context is valid. + /// + /// A context needs a WAN: without one there is nothing to say which WAN its measurements + /// describe, which is what the tag on its points and the report they belong to are keyed on. It + /// needs at most one bind mechanism, since a source IP and an agent are two different answers to + /// "where does the probe leave from" - UNLESS the agent is the thing doing the binding, which + /// is the multi-homed agent case. And an interface bind needs the agent: this server does not + /// sit on the gateway, so a name only it could resolve binds nothing here. + /// + /// Context name as typed. + /// Selected UniFi WAN key, empty when none was chosen. + /// Probe source IP as typed, empty when none. + /// Selected agent, null for none. + /// Bind interface as typed, empty when none. + /// Names of the site's OTHER contexts (excluding the one being edited). + /// Whether this server probes this site itself (the main site). + /// + /// Whether the selected agent binds probes to one of its own addresses. When it does, an + /// address alongside the agent is not two competing answers - it IS the agent's binding, and + /// it is what lets one multi-homed agent cover several WANs. + /// + internal static string? ValidateContext( + string name, + string? wanInterface, + string? sourceIp, + int? agentId, + string? interfaceName, + IEnumerable otherNames, + bool serverProbesThisSite = true, + bool agentCanBindSource = false) + { + if (string.IsNullOrEmpty(name) || name.Length > 100) + return "A name up to 100 characters is required."; + if (otherNames.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase))) + return "A vantage with that name already exists."; + if (string.IsNullOrWhiteSpace(wanInterface)) + return "Choose the WAN this vantage measures."; + // The context name is written as the Influx wan tag alongside the stable wan key, so a + // name that IS a wan key ("wan2") would file this context's points under another WAN. + // Allowed only when it names the context's own WAN. + if (System.Text.RegularExpressions.Regex.IsMatch(name, @"^wan\d*$", System.Text.RegularExpressions.RegexOptions.IgnoreCase) + && !string.Equals(NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(name), + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface!), StringComparison.OrdinalIgnoreCase)) + return "A name that looks like a WAN key must match the vantage's own WAN."; + if (!string.IsNullOrEmpty(sourceIp) && !System.Net.IPAddress.TryParse(sourceIp, out _)) + return "Probe source IP must be a valid IP address."; + if (agentId != null && !string.IsNullOrEmpty(sourceIp) && !agentCanBindSource) + return "Use either a probe source IP or an assigned agent, not both."; + if (!string.IsNullOrWhiteSpace(interfaceName) && agentId == null) + return "Interface binding runs on an agent - assign one first."; + // A source-IP context is probed by the SERVER binding that address, and the server only + // probes the main site. On any other site nothing would ever run these probes, so the + // context would sit there looking configured and collect nothing. + if (!serverProbesThisSite && agentId == null) + return "This site is probed by its agent, so assign one to this WAN."; + return null; + } + + private async Task SaveContextAsync() { _addError = null; + // An empty field takes the suggestion the placeholder was showing, so what the user saw + // before saving is what gets saved. var name = _newName.Trim(); - if (string.IsNullOrEmpty(name) || name.Length > 100) - { - _addError = "A name up to 100 characters is required."; - return; - } - if (WanContexts.Any(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase))) - { - _addError = "A context with that name already exists."; - return; - } + if (name.Length == 0) name = SuggestedName(); var sourceIp = _newSourceIp.Trim(); - if (!string.IsNullOrEmpty(sourceIp) && !System.Net.IPAddress.TryParse(sourceIp, out _)) - { - _addError = "Probe source IP must be a valid IP address."; - return; - } + var wanInterface = _newWanInterface.Trim(); + var interfaceName = _newInterfaceName.Trim(); int? agentId = int.TryParse(_newAgentId, out var parsedAgent) ? parsedAgent : null; - if (agentId != null && !string.IsNullOrEmpty(sourceIp)) - { - _addError = "Use either a probe source IP or an assigned agent, not both."; - return; - } - _adding = true; + _addError = ValidateContext(name, wanInterface, sourceIp, agentId, interfaceName, + WanContexts.Where(c => c.Id != _editingId).Select(c => c.Name), + serverProbesThisSite: ServerProbesThisSite, + agentCanBindSource: SelectedAgentCanBindSource); + if (_addError != null) return; + + _saving = true; try { await using var db = CreateDb(); - db.WanContexts.Add(new WanContext + if (_editingId is int editingId) + { + var row = await db.WanContexts.FindAsync(editingId); + if (row == null) + { + _addError = "That vantage no longer exists."; + return; + } + var wanChanged = !string.Equals(row.WanInterface, wanInterface, StringComparison.OrdinalIgnoreCase); + row.Name = name; + row.Description = string.IsNullOrWhiteSpace(_newDescription) ? null : _newDescription.Trim(); + row.ProbeSourceIp = string.IsNullOrEmpty(sourceIp) ? null : sourceIp; + row.AgentId = agentId; + row.WanInterface = wanInterface; + row.InterfaceName = string.IsNullOrEmpty(interfaceName) ? null : interfaceName; + // The context's targets say which WAN their data describes; a context re-pointed + // at another WAN takes its targets' stamp with it, or the per-WAN readers would + // keep attributing their data to the old WAN. + if (wanChanged) + await NetworkOptimizer.Web.Services.Monitoring.WanContextTargetStamping + .RestampContextTargetsAsync(db, editingId, wanInterface); + } + else { - Name = name, - Description = string.IsNullOrWhiteSpace(_newDescription) ? null : _newDescription.Trim(), - ProbeSourceIp = string.IsNullOrEmpty(sourceIp) ? null : sourceIp, - AgentId = agentId, - CreatedAt = DateTime.UtcNow, - }); + db.WanContexts.Add(new WanContext + { + Name = name, + Description = string.IsNullOrWhiteSpace(_newDescription) ? null : _newDescription.Trim(), + ProbeSourceIp = string.IsNullOrEmpty(sourceIp) ? null : sourceIp, + AgentId = agentId, + WanInterface = wanInterface, + InterfaceName = string.IsNullOrEmpty(interfaceName) ? null : interfaceName, + CreatedAt = DateTime.UtcNow, + }); + } await db.SaveChangesAsync(); - _showAdd = false; - _newName = ""; - _newDescription = ""; - _newSourceIp = ""; - _newAgentId = ""; + await RepushProbeConfigAsync(); + _showForm = false; + ResetForm(); await OnChanged.InvokeAsync(); } catch (Exception ex) { - _addError = $"Failed to add context: {ex.Message}"; + _addError = $"Failed to save vantage: {ex.Message}"; } finally { - _adding = false; + _saving = false; } } @@ -262,17 +971,27 @@ await using var db = CreateDb(); var row = await db.WanContexts.FindAsync(contextId); if (row == null) return; - // The reference is loose (no FK): move the context's targets back to - // the primary WAN before removing it. - var assigned = await db.MonitoringTargets - .Where(t => t.WanContextId == contextId) - .ToListAsync(); - foreach (var target in assigned) - target.WanContextId = null; + // The reference is loose (no FK): move the context's targets back to the primary + // WAN before removing it - BOTH keys, so no row stays stamped with a WAN nothing + // probes for it any more (see WanContextTargetStamping). + await NetworkOptimizer.Web.Services.Monitoring.WanContextTargetStamping + .ReleaseContextTargetsAsync(db, contextId); db.WanContexts.Remove(row); await db.SaveChangesAsync(); + await RepushProbeConfigAsync(); await OnChanged.InvokeAsync(); } catch { } } + + /// + /// Tells every connected agent on this site what it should be probing now. Both ends of a + /// reassignment need it: the agent that lost the context keeps probing targets it no longer + /// owns until it hears otherwise, and the one that gained it does not start until it does. + /// + private async Task RepushProbeConfigAsync() + { + try { await ProbeSink.PushProbeConfigToSiteAsync(SiteCtx.Slug); } + catch { } + } } diff --git a/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs b/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs index ba4a15de4c..c3377095b7 100644 --- a/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs +++ b/src/NetworkOptimizer.Web/Endpoints/IspHealthEndpoints.cs @@ -26,11 +26,15 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/isp-health/pdf", async ( DateTime? from, DateTime? to, - IspHealthService ispHealth, + string? wan, + IspHealthRegistry ispHealthRegistry, SiteContextService siteContext, SiteManagementService siteManagement, CancellationToken ct) => { + // wan (a UniFi wan key) exports a non-primary WAN's report; absent = primary, + // exactly as before. + var ispHealth = ispHealthRegistry.GetFor(siteContext.Slug, wan); var report = from.HasValue && to.HasValue ? await ispHealth.GetReportForWindowAsync(from.Value, to.Value, ct: ct) : await ispHealth.GetReportAsync(ct: ct); @@ -56,11 +60,15 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/isp-health/asn-series", async ( DateTime? from, DateTime? to, - IspHealthService ispHealth, + string? wan, + IspHealthRegistry ispHealthRegistry, + SiteContextService siteContext, CancellationToken ct) => { // from/to (the tab's date/time filter) make the chart follow a custom window off - // the 48 h cache; absent, it serves the cached 48 h report. + // the 48 h cache; absent, it serves the cached 48 h report. wan (a UniFi wan key) + // serves a non-primary WAN's instance; absent = primary, exactly as before. + var ispHealth = ispHealthRegistry.GetFor(siteContext.Slug, wan); var (series, report) = await ispHealth.GetAsnChartDataAsync(from, to, ct); // Cap the chart payload only for long windows: bucket toward a target point count, diff --git a/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs b/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs index 4a0dd894d1..84c049387f 100644 --- a/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs +++ b/src/NetworkOptimizer.Web/Endpoints/MonitoringChartEndpoints.cs @@ -3,8 +3,8 @@ using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; using NetworkOptimizer.Web.Services; -using NetworkOptimizer.Web.Services.Monitoring; using NetworkOptimizer.Web.Services.Authorization; +using NetworkOptimizer.Web.Services.Monitoring; namespace NetworkOptimizer.Web.Endpoints; @@ -20,6 +20,9 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/live-stats", async ( MonitoringLiveStats liveStats, UniFiConnectionService connectionService, + NetworkOptimizer.Storage.Services.SiteDbContextFactory siteDb, + SiteContextService siteContext, + string? wan, CancellationToken ct) => { string? gatewayMac = null; @@ -34,6 +37,39 @@ public static void Map(WebApplication app) } catch { } + // The live tick has to answer for the same WAN the caller is charting. Without this it + // served the primary's counters to every caller, so a chart backfilled with one WAN's + // history then grew a live edge of the primary's traffic - the two halves of the same + // line describing different connections. Absent means the primary, exactly as before. + if (!string.IsNullOrEmpty(wan)) + { + var group2 = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wan); + string? scopedCounter = null; + try + { + scopedCounter = (await connectionService.GetWanInterfacesForGroupAsync(group2, ct))?.CounterIfName; + } + catch { } + if (string.IsNullOrEmpty(scopedCounter)) + { + try + { + await using var db = siteDb.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var profile = await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.WanNetworkgroup == group2, ct); + scopedCounter = profile?.CounterInterface; + if (string.IsNullOrEmpty(gatewayMac) && profile?.GatewayMac != null) + gatewayMac = profile.GatewayMac.Replace("-", ":").ToLowerInvariant(); + } + catch { } + } + // Empty rather than the primary's: a WAN with no recorded counter has no live + // answer, and borrowing one would draw another WAN's traffic under its name. + wanIfNames = string.IsNullOrEmpty(scopedCounter) + ? new List() + : new List { scopedCounter! }; + } + double wanDown = 0, wanUp = 0; DateTime? sampleTime = null; if (gatewayMac != null && wanIfNames != null) @@ -49,7 +85,13 @@ public static void Map(WebApplication app) } } - var (meanRtt, meanLoss) = await liveStats.GetMeanIspTransitLiveAsync(ct); + // Scoped to the same WAN as the rates above, or the chart's RTT and loss lines would + // be the site's while its throughput was one WAN's - and a WAN with no targets of its + // own would show the primary's latency as if it were its own. + var isPrimaryWan = string.IsNullOrEmpty(wan) + || string.Equals(NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wan!), + NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey, StringComparison.OrdinalIgnoreCase); + var (meanRtt, meanLoss) = await liveStats.GetMeanIspTransitLiveAsync(ct, wan, isPrimaryWan); return Results.Ok(new { @@ -72,6 +114,7 @@ public static void Map(WebApplication app) ILoggerFactory loggerFactory, DateTime? from, DateTime? to, + string? wan, CancellationToken ct) => { DateTime queryFrom, queryTo; @@ -107,7 +150,35 @@ public static void Map(WebApplication app) // console that returns no gateway still yields an empty series as before, and eth0, // eth6.100 and ppp0 keep resolving live. CounterInterface, not the data path - a VLAN // sub-interface's counters double, which is why the two are stored apart. - if ((string.IsNullOrEmpty(gatewayMac) || wanIfNames is not { Count: > 0 }) + // A named WAN replaces the primary-only list with THAT WAN's counter interface: live + // from the console, else the WAN's own remembered profile. Never a fallback to another + // WAN - an empty series is the honest answer for a WAN nothing has recorded, where + // borrowing the primary's would draw someone else's traffic under this WAN's name. + if (!string.IsNullOrEmpty(wan)) + { + var group = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wan); + string? scopedCounter = null; + try + { + var ifaces = await connectionService.GetWanInterfacesForGroupAsync(group, ct); + scopedCounter = ifaces?.CounterIfName; + } + catch { } + try + { + await using var db = siteDb.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var profile = await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.WanNetworkgroup == group, ct); + scopedCounter ??= profile?.CounterInterface; + if (string.IsNullOrEmpty(gatewayMac) && profile?.GatewayMac != null) + gatewayMac = profile.GatewayMac.Replace("-", ":").ToLowerInvariant(); + } + catch { } + wanIfNames = string.IsNullOrEmpty(scopedCounter) + ? new List() + : new List { scopedCounter! }; + } + else if ((string.IsNullOrEmpty(gatewayMac) || wanIfNames is not { Count: > 0 }) && !connectionService.IsConnected) { try @@ -153,9 +224,47 @@ public static void Map(WebApplication app) ? influx.QueryGatewayWanRatesAsync(gatewayMac, wanIfNames, queryFrom, queryTo, sampleIntervalSeconds: sampleIntervalSeconds, ct: ct) : Task.FromResult>(Array.Empty()); + // Scoped like the rates above: the backfilled RTT and loss have to belong to the WAN + // being charted, or a secondary WAN's history is drawn with the primary's latency - + // the same borrowing the live tick did, just arriving as history instead. var targets = await liveStats.GetIspTransitTargetsAsync(ct); + // Points are scoped as well as targets. Selecting the right target ids is not enough on + // its own: one host reachable from two WANs is probed under each, and a row that has + // moved between contexts keeps its older points under the tag they were written with - + // so a read by id alone returns another WAN's readings too, which is a speed test on one + // WAN showing up as a latency spike on another's chart. Same scope the ISP Health + // reports use, built by the same helper. + // Same rule as the live tick: no WAN named means the primary, never every WAN. + MonitoringInfluxClient.LatencyWanScope? latencyScope = null; + { + var wanKey = string.IsNullOrEmpty(wan) + ? NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey + : NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wan!); + var wanIsPrimary = string.Equals(wanKey, + NetworkOptimizer.UniFi.GatewayWanHelper.DefaultWanKey, StringComparison.OrdinalIgnoreCase); + targets = targets.Where(t => string.IsNullOrEmpty(t.WanInterface) + ? wanIsPrimary + : string.Equals(NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(t.WanInterface!), + wanKey, StringComparison.OrdinalIgnoreCase)) + .ToList(); + try + { + await using var scopeDb = siteDb.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var contexts = await scopeDb.WanContexts.AsNoTracking().ToListAsync(ct); + latencyScope = NetworkOptimizer.Web.Services.Monitoring.IspHealth.IspHealthService + .BuildWanScope(contexts, wanKey, wanIsPrimary); + } + catch + { + // Unreadable contexts: fall back to the id-only read rather than an empty chart. + } + } var targetIds = targets.Select(t => t.TargetId).ToList(); - var rttTask = influx.QueryMeanIspTransitLatencyAsync(queryFrom, queryTo, targetIds, ct: ct); + // No targets on this WAN means no latency history for it - an empty query would read + // as the site's, so it is skipped and the series stays empty. + var rttTask = targetIds.Count > 0 + ? influx.QueryMeanIspTransitLatencyAsync(queryFrom, queryTo, targetIds, wanScope: latencyScope, ct: ct) + : Task.FromResult>(Array.Empty()); await Task.WhenAll(wanTask, rttTask); @@ -230,7 +339,7 @@ public static void Map(WebApplication app) .Where(t => t.TargetType == targetType && t.Enabled && (t.AsnNumber == null || !WellKnownAsns.NonTransitInfrastructure.Contains(t.AsnNumber.Value))) .OrderBy(t => t.Name) - .Select(t => new { t.TargetId, t.Name, t.AutoLabel }) + .Select(t => new { t.TargetId, t.Name, t.AutoLabel, t.WanInterface, t.Address }) .ToListAsync(ct); if (targets.Count == 0) @@ -249,6 +358,10 @@ public static void Map(WebApplication app) // Role label ("gateway"/"switch"/"ap"/...) so the LAN flaky detector can // identify the gateway target and mask out gateway-outage windows. autoLabel = t.AutoLabel, + // WAN ownership (null = unstamped = primary) and address, so the chart's WAN + // filter can scope series client-side and pair the same host's per-WAN twins. + wanInterface = t.WanInterface, + address = t.Address, rtt = pts.Select(p => new { time = p.Time.ToString("o"), value = p.RttAvgMs }), loss = pts.Select(p => new { time = p.Time.ToString("o"), value = p.LossPercent }), }; @@ -260,9 +373,12 @@ public static void Map(WebApplication app) group.MapGet("/api/monitoring/wan-rate-chart", async ( MonitoringInfluxClient influx, UniFiConnectionService connectionService, + SiteDbContextFactory siteDbFactory, + SiteContextService siteContext, int? rangeHours, DateTime? from, DateTime? to, + string? wan, CancellationToken ct) => { DateTime queryFrom, queryTo; @@ -291,6 +407,36 @@ public static void Map(WebApplication app) } catch { } + // Explicit WAN (a UniFi wan key like "wan2", from the chart's WAN filter): that WAN's + // own counter interface - live, then its remembered profile row - replaces the default + // primary/active-uplink resolution above. Never a cross-WAN fallback: an unresolvable + // WAN returns an empty series rather than another WAN's throughput. + if (!string.IsNullOrWhiteSpace(wan)) + { + var wanGroup = NetworkOptimizer.UniFi.GatewayWanHelper.WanNetworkGroupFromKey(wan.Trim()); + string? counter = null; + try + { + counter = (await connectionService.GetWanInterfacesForGroupAsync(wanGroup, ct))?.CounterIfName; + } + catch { } + if (string.IsNullOrEmpty(counter) || string.IsNullOrEmpty(gatewayMac)) + { + try + { + await using var wdb = siteDbFactory.CreateForSite(siteContext.Slug, siteContext.IsDefault); + var profile = await wdb.WanProfiles.AsNoTracking() + .Where(w => w.WanNetworkgroup == wanGroup) + .OrderByDescending(w => w.UpdatedAt) + .FirstOrDefaultAsync(ct); + counter ??= profile?.CounterInterface; + gatewayMac = string.IsNullOrEmpty(gatewayMac) ? profile?.GatewayMac : gatewayMac; + } + catch { } + } + wanIfNames = string.IsNullOrEmpty(counter) ? null : new List { counter! }; + } + if (string.IsNullOrEmpty(gatewayMac) || wanIfNames == null || wanIfNames.Count == 0) return Results.Ok(new { download = Array.Empty(), upload = Array.Empty() }); diff --git a/src/NetworkOptimizer.Web/Program.cs b/src/NetworkOptimizer.Web/Program.cs index a2c7a10474..9e7b7330e5 100644 --- a/src/NetworkOptimizer.Web/Program.cs +++ b/src/NetworkOptimizer.Web/Program.cs @@ -601,6 +601,10 @@ // Scoped - forwards to the current site's Influx client and database. builder.Services.AddScoped(); builder.Services.AddScoped(); +// Transient: every live-tile surface keeps its own selection state and re-render callback. +builder.Services.AddTransient(); +// Per-user teaching hints that retire once seen (UiHintKeys). +builder.Services.AddScoped(); builder.Services.AddSingleton(); // Per-site monitoring alert evaluators (target offline / device health / SFP DDM): // in-memory state machines keyed by target id / MAC, which repeat across sites, so diff --git a/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs b/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs index db4378a741..fa67839540 100644 --- a/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs +++ b/src/NetworkOptimizer.Web/Services/AgentOnGatewayDetector.cs @@ -46,6 +46,12 @@ public class AgentOnGatewayDetector // work without a system scope. private readonly ConcurrentDictionary _agentIp = new(); private readonly ConcurrentDictionary _refreshing = new(); + // The site's gateway addresses from the last resolution, so the per-connection check below can + // answer for an agent the site-level verdict never considered. Cached and refreshed on the same + // TTL as the verdict itself; a site with 2+ agents has one gateway either way. + private readonly ConcurrentDictionary Ips, DateTime At)> _gatewayIps = new(); + private readonly ConcurrentDictionary Ips, DateTime At)> _gatewayHostIps = new(); + private readonly ConcurrentDictionary _gatewayIpRefreshing = new(); public AgentOnGatewayDetector( AgentEnrollmentService enrollment, @@ -115,6 +121,95 @@ public async Task IsAgentOnGatewayAsync(string siteSlug, CancellationToken public string? LastKnownAgentIp(string siteSlug) => _agentIp.TryGetValue(siteSlug, out var ip) ? ip : null; + /// + /// Whether a specific address is one of the site's gateway addresses - the per-connection + /// counterpart to , for the questions that are about ONE + /// agent rather than about the site. A site with several agents has one gateway, but only one + /// of those agents may be sitting on it, and the site-level verdict cannot tell them apart: it + /// correlates against whichever agent the enrollment registry answers with. + /// + /// Deliberately not gated on the site being non-default. The site-level verdict keeps its + /// existing "false for the default site" contract for its existing consumers; this one answers + /// from the gateway addresses alone, so a main-site agent running on the gateway is recognized + /// as such - which is exactly the deployment multi-WAN contexts target. + /// + public async Task IsIpOnGatewayAsync(string siteSlug, string? ip, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(siteSlug) || string.IsNullOrWhiteSpace(ip)) + return false; + + var hasCached = _gatewayIps.TryGetValue(siteSlug, out var cached); + if (!hasCached || DateTime.UtcNow - cached.At >= CacheTtl) + { + var refresh = StartOrJoinGatewayIpRefresh(siteSlug); + if (!hasCached) + { + try + { + await refresh.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Caller gave up - the refresh itself continues and fills the cache. + } + hasCached = _gatewayIps.TryGetValue(siteSlug, out cached); + } + } + + return hasCached && cached.Ips.Contains(ip!.Trim(), StringComparer.OrdinalIgnoreCase); + } + + /// + /// The first of that is one of this site's gateway addresses, or + /// null when none is. + /// + /// The gateway address set is unchanged - this only asks the same question of more candidates. + /// An agent picks ONE address to report itself by, and on a gateway that choice is whichever + /// Ethernet interface the kernel enumerates first, which can easily be an uplink the console + /// never lists as the gateway's own. Comparing every address the host holds answers "is this + /// that machine" instead of "did it happen to name the address we know". + /// + /// + /// Returns the MATCHING address rather than a bool because callers that skip the gateway's own + /// target need the address the site knows it by, not the one the agent named itself with. + /// + /// + public async Task MatchGatewayAddressAsync( + string siteSlug, IEnumerable candidates, CancellationToken ct = default) + { + var addresses = candidates.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).ToList(); + if (addresses.Count == 0) return null; + + // Narrow set first, so a caller using the answer as an ADDRESS gets the one the site knows + // the gateway by rather than some other interface of the same box. + foreach (var candidate in addresses) + if (await IsIpOnGatewayAsync(siteSlug, candidate, ct)) return candidate; + + if (!_gatewayHostIps.TryGetValue(siteSlug, out var host)) return null; + return addresses.FirstOrDefault(c => host.Ips.Contains(c, StringComparer.OrdinalIgnoreCase)); + } + + /// One in-flight gateway-address resolution per site; the result lands in the cache. + private Task StartOrJoinGatewayIpRefresh(string siteSlug) => + _gatewayIpRefreshing.GetOrAdd(siteSlug, slug => Task.Run(async () => + { + try + { + using var cts = new CancellationTokenSource(RefreshTimeout); + var connection = _siteConnections.GetFor(slug); + if (connection.IsConnected && connection.Client != null) + await ResolveGatewayIpsAsync(slug, connection.Client, cts.Token); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Gateway address resolution failed for site {Slug}", slug); + } + finally + { + _gatewayIpRefreshing.TryRemove(slug, out _); + } + })); + /// One in-flight refresh per site; result lands in the cache, and first-time callers await the returned task. private Task StartOrJoinRefresh(string siteSlug) => _refreshing.GetOrAdd(siteSlug, slug => Task.Run(async () => @@ -154,14 +249,46 @@ private async Task RefreshAsync(string siteSlug, CancellationToken ct) return; } - var devices = await connection.Client.GetDevicesAsync(ct) ?? new(); + var gatewayIps = await ResolveGatewayIpsAsync(siteSlug, connection.Client, ct); + + var onGateway = gatewayIps.Contains(agentIp!, StringComparer.OrdinalIgnoreCase); + _cache[siteSlug] = (onGateway, DateTime.UtcNow); + _agentIp[siteSlug] = agentIp!; + await PersistAsync(siteSlug, onGateway); + } + + /// + /// The site's gateway addresses: every gateway device's reported IP (on a gateway agent that is + /// the WAN address) plus the LAN-side gateway IP, in case the agent's own detection landed + /// there instead. Caches what it found so the per-connection check can answer without its own + /// console round trip. + /// + private async Task> ResolveGatewayIpsAsync( + string siteSlug, UniFi.UniFiApiClient client, CancellationToken ct) + { + var devices = await client.GetDevicesAsync(ct) ?? new(); var gatewayIps = devices .Where(d => d.DeviceType == DeviceType.Gateway && !string.IsNullOrEmpty(d.Ip)) .Select(d => d.Ip!) .ToList(); + + // Superset, cached alongside and never mixed into the set above: EVERY address the console + // reports the gateway holding, for the one question that needs it - is an agent running on + // this box. A gateway holds a dozen addresses and an agent that reports only one may name + // any of them, so the narrow set answers that question with a false no. Deliberately built + // from the gateway's own interfaces only; inform_ip and connect_request_ip are the console's + // loopback and would match every host alive, so they are not read at all. + var hostIps = new List(gatewayIps); + foreach (var device in devices.Where(d => d.DeviceType == DeviceType.Gateway)) + { + AddHostIp(hostIps, device.LanIp); + AddHostIp(hostIps, device.ConfigNetwork?.Ip); + foreach (var port in device.PortTable ?? new()) + AddHostIp(hostIps, port.Ip); + } try { - var lanIp = await Monitoring.SnmpDeviceRules.ResolveGatewayLanIpAsync(connection.Client, ct); + var lanIp = await Monitoring.SnmpDeviceRules.ResolveGatewayLanIpAsync(client, ct); if (!string.IsNullOrEmpty(lanIp)) gatewayIps.Add(lanIp!); } @@ -170,10 +297,28 @@ private async Task RefreshAsync(string siteSlug, CancellationToken ct) _logger.LogDebug(ex, "Gateway LAN IP resolution failed for site {Slug} during on-gateway detection", siteSlug); } - var onGateway = gatewayIps.Contains(agentIp!, StringComparer.OrdinalIgnoreCase); - _cache[siteSlug] = (onGateway, DateTime.UtcNow); - _agentIp[siteSlug] = agentIp!; - await PersistAsync(siteSlug, onGateway); + if (gatewayIps.Count > 0) + { + _gatewayIps[siteSlug] = (gatewayIps, DateTime.UtcNow); + foreach (var ip in gatewayIps) AddHostIp(hostIps, ip); + _gatewayHostIps[siteSlug] = (hostIps, DateTime.UtcNow); + } + return gatewayIps; + } + + /// + /// Adds an address to the host set when it can identify a host: not empty, not a duplicate, and + /// neither loopback nor link-local - the two an unrelated machine could hold as readily as this + /// one, where a match would mean nothing. + /// + private static void AddHostIp(List hostIps, string? ip) + { + var value = ip?.Trim(); + if (string.IsNullOrEmpty(value)) return; + if (!System.Net.IPAddress.TryParse(value, out var parsed)) return; + if (System.Net.IPAddress.IsLoopback(parsed)) return; + if (value.StartsWith("169.254.", StringComparison.Ordinal)) return; + if (!hostIps.Contains(value, StringComparer.OrdinalIgnoreCase)) hostIps.Add(value); } /// diff --git a/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs b/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs index 681deff12b..ee913c333b 100644 --- a/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs +++ b/src/NetworkOptimizer.Web/Services/AgentProbeResultSink.cs @@ -30,6 +30,7 @@ public class AgentProbeResultSink private readonly Monitoring.DeviceTransitionTracker _deviceTransitions; private readonly MonitoringAlertRegistry _alertRegistry; private readonly ICredentialProtectionService _credentialProtection; + private readonly Monitoring.IspHealth.IspHealthRegistry _ispHealthRegistry; private readonly ILogger _logger; // Counter delta cache for agent-relayed interface samples. Key = @@ -112,8 +113,12 @@ public AgentProbeResultSink( SiteAgentCoverage agentCoverage, AgentOnGatewayDetector onGatewayDetector, IAgentEnrollmentService enrollment, + AgentTunnelRegistry tunnelRegistry, + Monitoring.IspHealth.IspHealthRegistry ispHealthRegistry, ILogger logger) { + _ispHealthRegistry = ispHealthRegistry; + _tunnelRegistry = tunnelRegistry; _siteDbFactory = siteDbFactory; _influxRegistry = influxRegistry; _liveStatsRegistry = liveStatsRegistry; @@ -133,6 +138,7 @@ public AgentProbeResultSink( private readonly SiteAgentCoverage _agentCoverage; private readonly AgentOnGatewayDetector _onGatewayDetector; private readonly IAgentEnrollmentService _enrollment; + private readonly AgentTunnelRegistry _tunnelRegistry; /// /// Called once per connection after the hello exchange, and again by the periodic refresh. @@ -245,7 +251,20 @@ private async Task ReconnectConsoleIfViaAgentAsync(AgentTunnelConnection connect await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None); if (siteConnection.IsConnected) + { await PushSnmpConfigAsync(connection, CancellationToken.None); + + // Both halves are up now, so anything computed before this point saw a partial + // site. A report produced between server start and this moment is missing whatever + // arrives through the console - SNMP above all, which is what classifies load, so + // an early compute finds no loaded windows and reports a different score for the + // same day. It is then cached and served until something evicts it, which is why a + // cold report and a warm one disagreed with nothing in between to reconcile them. + _ispHealthRegistry.InvalidateSite(connection.SiteSlug); + _logger.LogDebug( + "Agent and console both up for site {Slug}; dropping any ISP Health computed without them", + connection.SiteSlug); + } } catch (Exception ex) { @@ -284,8 +303,54 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell .AsNoTracking() .Where(t => t.Enabled) .ToListAsync(ct); + // Before anything reads a context's binding, give one back to any context that lost the + // chance to have one. Runs here because this is the push that follows an agent's hello, + // which is exactly when an upgraded agent first reports it can bind. + if (await HealUnboundGatewayContextsAsync(db, connection, ct)) + _ = await db.SaveChangesAsync(ct); var contextsById = await db.WanContexts.AsNoTracking().ToDictionaryAsync(c => c.Id, ct); + // An agent that owns a WAN context is there to measure that WAN and nothing else: it + // sits behind a policy-routed source or binds the WAN's own interface, so every probe + // it runs leaves by that WAN. Handing it the site's ordinary targets as well would + // measure the secondary WAN and file the result under the primary. Only true once a + // context names this agent, so a site with no contexts pushes exactly what it always + // has. + // Steered means the agent's OWN default route leaves by a WAN that is not the primary - + // a probe box the gateway policy-routes by MAC, or one running with agent.json's + // probeSourceIp. It is a vantage behind that WAN and nothing else, so it must not + // probe anything the primary owns. + // + // Two ways an agent is NOT steered even while serving a context. It binds per probe + // (its context names an interface - a gateway agent), so its own route is untouched. + // Or its context IS the primary's, which needs no steering to reach: on a failover-only + // site every unpinned box already leaves by the primary. Both keep the agent eligible + // as the site's collector, which on a gateway-only site it has to be. + var primaryWanKey = await ResolvePersistedPrimaryWanKeyAsync(db, ct); + var agentIsSteeredToWan = contextsById.Values.Any(c => + c.AgentId == connection.AgentId + && string.IsNullOrEmpty(c.InterfaceName) + && !IsPrimaryWanContext(c, primaryWanKey)); + + // Exactly one agent probes the unassigned (primary-WAN) targets. Several agents on a + // site used to each get the whole set as extra vantage points, which on a site running + // an agent per WAN means every primary target probed N times for one number. The owner + // is the lowest-id agent that is CONNECTED and not steered: deterministic, so a refresh + // does not move the pool around, and self-healing, because the next agent takes it over + // on the following push if the owner drops. Steered agents are never eligible - their + // probes leave by the wrong WAN. + // Only when an agent collects for this site at all. On the main site with collection + // left to the server, the server probes the unassigned pool itself, and the results of + // an agent probing it too are discarded on arrival by ShouldRecordResult - so pushing + // them means the agent runs a set of probes for nothing. The push has to ask the same + // question the record does, or the two disagree about whose numbers count. + var agentCoversPrimary = !isDefault || await _agentCoverage.CoversAsync(connection.SiteSlug); + var unassignedOwnerId = agentCoversPrimary + ? SelectCollectorAgentId( + _tunnelRegistry.GetForSite(connection.SiteSlug).Select(c => c.AgentId), + contextsById.Values, primaryWanKey, connection.AgentId) + : NoCollectorAgentId; + // An agent running ON the gateway cannot usefully probe it: the target is the box the // probe runs on, so every reply is loopback - 0 ms and no loss - which reads as a // perfectly healthy gateway precisely when it might not be. Skipped for this agent at @@ -296,9 +361,15 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell // this runs on the tunnel's background path with no caller context, and the gate threw - // taking the whole push with it, so the site got no targets at all and its monitoring // read as total loss. - var selfAddress = await _onGatewayDetector.IsAgentOnGatewayAsync(connection.SiteSlug) - ? _onGatewayDetector.LastKnownAgentIp(connection.SiteSlug) - : null; + // Asked per connection rather than per site: with several agents the site-level verdict + // correlates against whichever one the registry answers with, so it would skip the + // gateway target for an agent that is not on the gateway - and miss it for the one that + // is. + // The MATCHED address, not the agent's own reported one: the site's target for the + // gateway carries the address the console knows it by, which is not necessarily the + // address the agent named itself with. + var selfAddress = await _onGatewayDetector.MatchGatewayAddressAsync( + connection.SiteSlug, connection.HostAddresses, ct); var skippedSelf = 0; var config = new ProbeConfig(); @@ -310,13 +381,16 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell skippedSelf++; continue; } - // Targets in an agent-assigned WAN context go only to that agent - // (typically a probe-only instance bound behind the right WAN); - // unassigned targets go to every agent as extra vantage points. - if (target.WanContextId is int contextId - && contextsById.TryGetValue(contextId, out var context) - && context.AgentId is int assignedAgent - && assignedAgent != connection.AgentId) + // Context targets are that context's alone: its assigned agent, or no agent when + // the context is server-probed (the server's own prober binds the source IP). + // Only UNASSIGNED targets fan out to every ordinary agent as extra vantage + // points - except to an agent that owns a context, which measures only that. A + // WanContextId whose row is gone counts as a context with no agent (pushed + // nowhere) rather than as unassigned - conservative until the row is cleaned up. + var context = target.WanContextId is int contextId + && contextsById.TryGetValue(contextId, out var found) ? found : null; + if (!ShouldPushTargetToAgent(target.WanContextId != null, context?.AgentId, connection.AgentId, + agentIsSteeredToWan, unassignedOwnerId, IsFabricTarget(target.TargetType))) continue; config.Targets.Add(new ProbeTargetSpec { @@ -327,6 +401,11 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell PollIntervalSeconds = target.PollIntervalSeconds, PingCount = target.PingCount, TargetType = target.TargetType.ToString().ToLowerInvariant(), + // The context's bind rides the target: an interface name for an + // on-gateway agent, a source IP for a policy-routed one. The agent + // prefers this over its own agent.json default, so one agent can + // still serve a context while probing on its own route elsewhere. + SourceIp = ResolveSpecSourceIp(context, connection.AgentId), }); } @@ -342,6 +421,285 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell } } + /// + /// The one agent that collects for a site: its SNMP, its fabric targets, and the primary WAN's + /// targets. The lowest-id CONNECTED agent that is not steered behind a secondary WAN. + /// + /// Lowest-id makes it deterministic, so a refresh does not move the workload around; taking it + /// from the connected set makes it self-healing, because the next agent picks the work up on + /// the following push if the holder drops. Steered agents are never eligible - everything they + /// send leaves by the wrong WAN. is returned when nothing is + /// eligible, which keeps a lone steered agent collecting rather than leaving a site dark. + /// + /// + /// + /// Stands in for "no agent collects here", where the server does it. Never a real agent id, so + /// every ownership comparison simply fails. + /// + internal const int NoCollectorAgentId = -1; + + internal static int SelectCollectorAgentId( + IEnumerable connectedAgentIds, + IEnumerable contexts, + string? primaryWanKey, + int fallbackAgentId) + { + var contextList = contexts as IReadOnlyCollection ?? contexts.ToList(); + return connectedAgentIds + .Where(id => !contextList.Any(c => + c.AgentId == id + && string.IsNullOrEmpty(c.InterfaceName) + && !IsPrimaryWanContext(c, primaryWanKey))) + .DefaultIfEmpty(fallbackAgentId) + .Min(); + } + + /// + /// Which agent currently collects for a site, for display. Same answer the push path acts on, + /// asked from one place so the page cannot disagree with what is actually happening. Null when + /// no agent is connected. + /// + public async Task GetCollectorAgentIdAsync(string siteSlug, CancellationToken ct = default) + { + var connected = _tunnelRegistry.GetForSite(siteSlug).Select(c => c.AgentId).ToList(); + if (connected.Count == 0) return null; + try + { + var isDefault = siteSlug == SiteManagementService.DefaultSiteSlug; + await using var db = _siteDbFactory.CreateForSite(siteSlug, isDefault); + var contexts = await db.WanContexts.AsNoTracking().ToListAsync(ct); + var primaryWanKey = await ResolvePersistedPrimaryWanKeyAsync(db, ct); + return SelectCollectorAgentId(connected, contexts, primaryWanKey, connected.Min()); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not resolve the collector agent for site {Slug}", siteSlug); + return connected.Min(); + } + } + + /// + /// The primary WAN's key as the last connected compute recorded it, or null when none has. + /// Read from the site's WanProfiles because this path has no console to ask, and a WAN's name + /// says nothing about its role. Null means unknown: callers must not read it as "not primary". + /// + /// + /// Fills the bind interface for this agent's contexts that have none, when the agent runs on the + /// gateway and can bind. + /// + /// The state is reachable without any mistake: save a vantage while the agent is too old to + /// offer a binding, then update the agent. The capability arrives, the empty configuration does + /// not change, and the probes go on leaving by the gateway's default route while their results + /// are filed under the context's WAN - a wrong number that looks exactly like a right one. A + /// policy-based route cannot rescue it either, because routing policy does not govern the + /// gateway's OWN egress; binding the interface is the only mechanism there is. + /// + /// + /// Only ever fills an empty binding, so it cannot overwrite a choice. The interface comes from + /// the WAN's persisted data path - the logical uplink, ppp0 on PPPoE rather than the physical + /// port - so it needs no console call and works while the console is unreachable. + /// + /// + /// Whether anything changed and the caller should save. + private async Task HealUnboundGatewayContextsAsync( + NetworkOptimizerDbContext db, AgentTunnelConnection connection, CancellationToken ct) + { + if (connection.SupportsSourceBind != true) return false; + var unbound = await db.WanContexts + .Where(c => c.AgentId == connection.AgentId + && (c.InterfaceName == null || c.InterfaceName == "") + && (c.ProbeSourceIp == null || c.ProbeSourceIp == "") + && c.WanInterface != null && c.WanInterface != "") + .ToListAsync(ct); + if (unbound.Count == 0) return false; + + // Asked only when there is something to heal: it can await a console round trip. + if (await _onGatewayDetector.MatchGatewayAddressAsync( + connection.SiteSlug, connection.HostAddresses, ct) == null) + return false; + + var profiles = await db.WanProfiles.AsNoTracking().ToListAsync(ct); + var healed = false; + foreach (var context in unbound) + { + var key = GatewayWanHelper.WanInterfaceKeyFromKey(context.WanInterface!); + var dataPath = profiles.FirstOrDefault(p => + !string.IsNullOrEmpty(p.WanNetworkgroup) + && string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(p.WanNetworkgroup), key, + StringComparison.OrdinalIgnoreCase))?.DataPathInterface; + if (string.IsNullOrEmpty(dataPath)) continue; + context.InterfaceName = dataPath; + healed = true; + _logger.LogInformation( + "WAN vantage '{Name}' had no binding; bound it to {Interface} for agent {Id} (site {Slug})", + context.Name, dataPath, connection.AgentId, connection.SiteSlug); + } + return healed; + } + + private static async Task ResolvePersistedPrimaryWanKeyAsync( + NetworkOptimizerDbContext db, CancellationToken ct) + { + var group = (await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.IsPrimary == true, ct))?.WanNetworkgroup; + return string.IsNullOrEmpty(group) ? null : GatewayWanHelper.WanInterfaceKeyFromKey(group); + } + + /// + /// Whether a context measures the primary WAN. False when the primary is unknown: an agent is + /// only excused from being treated as steered on a positive answer, so an unresolved primary + /// leaves the conservative reading in place rather than handing it the site's targets. + /// + internal static bool IsPrimaryWanContext(WanContext context, string? primaryWanKey) => + !string.IsNullOrEmpty(primaryWanKey) + && !string.IsNullOrEmpty(context.WanInterface) + && string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(context.WanInterface!), + primaryWanKey, StringComparison.OrdinalIgnoreCase); + + /// + /// Whether a target belongs in one agent's pushed set. + /// + /// Every target has exactly one prober, and which one depends on what the target measures. + /// + /// FABRIC targets - the gateway, switches, APs, anything inside the LAN - never cross a WAN, so + /// no WAN owns them and a context could not mean anything for one. They go to the site's + /// collector, the same agent that polls SNMP: it is the one inside the network, and pairing the + /// two keeps a device's counters and its reachability measured from the same place. + /// + /// WAN targets belong to the WAN they leave by: a context's targets to that context's agent, + /// and the unassigned ones - the primary's - to ONE agent rather than all of them, so a site + /// running an agent per WAN does not probe every primary target once per agent for one number. + /// + /// A STEERED agent is probe-only for its context: everything it sends leaves by that WAN, so a + /// primary target probed from it would measure the wrong path and be recorded as the primary's. + /// An interface-bound (gateway) agent is not steered - it binds each context probe to that + /// WAN's interface while its own route stays the primary - so it can serve contexts AND be the + /// site's collector, which on a gateway-only site it has to be. + /// + /// Whether the target belongs to ANY WAN context. A context + /// target is that context's alone: its assigned agent when it has one, or - for a source-IP + /// (server-probed) context - NO agent at all, because an ordinary agent would probe it over + /// its own primary route while the result gets tagged with the secondary WAN's key, + /// corrupting that WAN's score now that the tag is read. + /// Agent assigned to the target's WAN context; null when the target has no context, or its context has no agent (server-probed). + /// The agent being pushed to. + /// Whether a context names this agent WITHOUT an interface + /// to bind - i.e. the whole box sits behind one WAN. + /// The one agent that collects for the site: fabric targets + /// and the primary WAN's. + /// Whether the target is inside the LAN, so no WAN owns it. + internal static bool ShouldPushTargetToAgent( + bool targetHasContext, int? contextAgentId, int agentId, bool agentIsSteeredToWan, + int unassignedOwnerId, bool targetIsFabric = false) + => targetIsFabric + ? !agentIsSteeredToWan && unassignedOwnerId == agentId + : targetHasContext + ? contextAgentId == agentId + : !agentIsSteeredToWan && unassignedOwnerId == agentId; + + /// + /// Whether a target sits inside the LAN, where no WAN is involved and a WAN context would mean + /// nothing. Fabric is the type the discovery tier gives the gateway, switches and APs. + /// + internal static bool IsFabricTarget(MonitoringTargetType targetType) => + targetType == MonitoringTargetType.Fabric; + + /// + /// The source an agent binds this target's probes to: the context's interface when it has one, + /// otherwise its source IP, and empty for anything the agent is not running on that context's + /// behalf. Empty leaves the agent on its own configured default, which is what every target + /// carried before contexts existed. + /// + internal static string ResolveSpecSourceIp(WanContext? context, int agentId) + => context != null && context.AgentId == agentId + ? context.InterfaceName ?? context.ProbeSourceIp ?? "" + : ""; + + /// + /// Whether a result an agent sent should be written. + /// + /// Coverage governs primary-path measurement: a main-site agent that is not covering the site + /// is a second prober for targets the server is already probing, and its results are dropped so + /// the two cadences don't saw across the same series. A context's targets are not that - the + /// server never probes them (it cannot reach the secondary WAN), so the assigned agent's + /// results are the only ones there are and coverage has no bearing on them. + /// + internal static bool ShouldRecordResult(bool agentCoversPrimary, int? contextAgentId, int agentId) + => agentCoversPrimary || contextAgentId == agentId; + + /// + /// Whether an agent should be sent the site's SNMP config and speed-test server list. + /// + /// A STEERED agent is a probe vantage behind one WAN, not a second collector: polling SNMP + /// from it would double every counter the site already collects, and it serves no speed tests. + /// An interface-bound (gateway) agent is a collector that also serves contexts, so it keeps + /// both - a site whose only agent is on the gateway must still get its SNMP from somewhere. + /// False only once a steered context names it, so a site with no contexts is unaffected. + /// + internal static bool ShouldPushSiteCollectionConfig(bool agentIsSteeredToWan) => !agentIsSteeredToWan; + + /// + /// Whether this agent sits ENTIRELY behind one WAN: a context names it and gives no interface + /// to bind, so the box itself is policy-routed out that WAN. An agent whose contexts all name + /// an interface binds per probe and still routes normally, so it is not steered. Answers false + /// when the site database cannot be read, which leaves every gate on this at the behavior it + /// has today rather than standing an agent down on a hiccup. + /// + private async Task IsSteeredToWanAgentAsync(AgentTunnelConnection connection, CancellationToken ct) + { + try + { + var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; + await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); + var primaryWanKey = await ResolvePersistedPrimaryWanKeyAsync(db, ct); + var contexts = await db.WanContexts.AsNoTracking() + .Where(c => c.AgentId == connection.AgentId + && (c.InterfaceName == null || c.InterfaceName == "")) + .ToListAsync(ct); + return contexts.Any(c => !IsPrimaryWanContext(c, primaryWanKey)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN contexts for agent {Id} (site {Slug})", + connection.AgentId, connection.SiteSlug); + return false; + } + } + + /// + /// Whether this agent owns any WAN context on its site, however that context binds. The test for + /// "is there anything worth reading this agent's results for" - the per-result check below then + /// decides which of them to keep. + /// + private async Task AgentOwnsAnyContextAsync(AgentTunnelConnection connection, CancellationToken ct) + { + try + { + await using var db = _siteDbFactory.CreateForSite( + connection.SiteSlug, connection.SiteSlug == SiteManagementService.DefaultSiteSlug); + return await db.WanContexts.AsNoTracking().AnyAsync(c => c.AgentId == connection.AgentId, ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN contexts for agent {Id} (site {Slug})", + connection.AgentId, connection.SiteSlug); + return false; + } + } + + /// + /// Re-pushes probe config to every connected agent of a site. Reassigning a WAN context moves + /// targets between agents, and both ends have to hear about it: the agent losing the context + /// keeps probing what it no longer owns until it is told otherwise, and the one gaining it does + /// not start until it is. The periodic refresh would settle both within a minute; this makes + /// the edit take effect when the user makes it. + /// + public async Task PushProbeConfigToSiteAsync(string siteSlug, CancellationToken ct = default) + { + foreach (var connection in _tunnelRegistry.GetForSite(siteSlug)) + await PushProbeConfigAsync(connection, ct); + } + /// /// Pushes the WAN speed-test server list (global, main database) so the /// agent can serve its /wan/ redirect without the external servers needing @@ -351,6 +709,10 @@ public async Task PushProbeConfigAsync(AgentTunnelConnection connection, Cancell /// public async Task PushWanSpeedTestConfigAsync(AgentTunnelConnection connection, CancellationToken ct) { + // A context-assigned agent serves no speed test page, so it has no /wan/ redirect to + // resolve and no reason to hold the server list. + if (!ShouldPushSiteCollectionConfig(await IsSteeredToWanAgentAsync(connection, ct))) + return; try { await using var db = _siteDbFactory.CreateForSite(SiteManagementService.DefaultSiteSlug, isDefault: true); @@ -384,12 +746,23 @@ public async Task PushWanSpeedTestConfigAsync(AgentTunnelConnection connection, /// connection, filtered and addressed by the same SnmpDeviceRules the /// local collection agent uses. A default-site agent gets SNMP config only when the site is /// configured for its agent to cover it - otherwise the server's own collection agent is still - /// polling those devices and pushing a second poller would double every sample. + /// polling those devices and pushing a second poller would double every sample. A + /// context-assigned agent gets an explicitly disabled config for the same reason: it is a probe + /// vantage behind one WAN, and the site already has a collector. /// public async Task PushSnmpConfigAsync(AgentTunnelConnection connection, CancellationToken ct) { var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; if (isDefault && !await _agentCoverage.CoversAsync(connection.SiteSlug)) return; + if (!ShouldPushSiteCollectionConfig(await IsSteeredToWanAgentAsync(connection, ct))) + { + // Disabled rather than absent: an agent that polled before being assigned a context + // keeps polling on its last config until a new one tells it to stop. + connection.TrySend(new ServerMessage { SnmpConfig = new SnmpConfig { Enabled = false } }); + _logger.LogDebug("Agent {Id} (site {Slug}) probes a WAN context; SNMP polling left to the site's collector", + connection.AgentId, connection.SiteSlug); + return; + } try { await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); @@ -1240,13 +1613,29 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult var isDefault = connection.SiteSlug == SiteManagementService.DefaultSiteSlug; - // The push path already refuses to send targets to a main-site agent that is not covering - // the site; results are refused for the same reason. Switching coverage off stops the - // config going out but does not stop an agent that already has targets, so it keeps - // probing and pushing while the server resumes probing the same targets itself. Both write - // the same series at different cadences, which reads as a sawtooth on the charts rather - // than as duplicate points. - if (isDefault && !await _agentCoverage.CoversAsync(connection.SiteSlug)) return; + // A main-site agent that is not covering the site probes targets the server is probing too, + // and both write the same series at different cadences - which reads as a sawtooth on the + // charts rather than as duplicate points. So its results are dropped. (The push path does + // NOT refuse those targets, which an earlier comment here claimed: the agent is sent the + // site's targets as an extra vantage point, probes them, and everything it reports lands + // here to be discarded.) + // + // A WAN context's targets are the exception: the server cannot reach the secondary WAN, so + // it never probes them, and the assigned agent's results are the only measurement there is. + // Below, each result is judged against the target's own context rather than the whole batch + // being refused here. + var agentCoversPrimary = !isDefault || await _agentCoverage.CoversAsync(connection.SiteSlug); + // Nothing this agent sends can be kept, so drop the batch without loading the site's + // targets for it - which is what happened before contexts existed, and still happens on + // every site that has none. + // + // The question is whether the agent owns ANY context, not whether it is steered. Those are + // the same for an agent whose whole box is routed out a WAN, and different for one on the + // gateway that binds each probe: binding leaves its own route alone, so it is not steered, + // yet its context's results are still the only measurement that WAN has. Asking the steering + // question here threw away every result from a gateway vantage the moment it was given an + // interface to bind. + if (!agentCoversPrimary && !await AgentOwnsAnyContextAsync(connection, ct)) return; await using var db = _siteDbFactory.CreateForSite(connection.SiteSlug, isDefault); var ids = batch.Results.Select(r => r.TargetId).Distinct().ToList(); @@ -1264,6 +1653,7 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult var influx = _influxRegistry.GetFor(connection.SiteSlug); if (!influx.IsConfigured) await influx.ReconfigureAsync(ct); var liveStats = _liveStatsRegistry.GetFor(connection.SiteSlug); + var discarded = 0; foreach (var result in batch.Results) { @@ -1273,9 +1663,16 @@ public async Task RecordBatchAsync(AgentTunnelConnection connection, ProbeResult continue; } + var context = target.WanContextId is int contextId && contextsById.TryGetValue(contextId, out var found) + ? found : null; + if (!ShouldRecordResult(agentCoversPrimary, context?.AgentId, connection.AgentId)) + { + discarded++; + continue; + } + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(result.TimestampUnixMs).UtcDateTime; - var wanContext = target.WanContextId is int contextId && contextsById.TryGetValue(contextId, out var context) - ? context.Name : null; + var wanContext = context?.InfluxWanTag; await influx.WriteLatencyAsync( targetId: target.TargetId, @@ -1347,6 +1744,11 @@ await influx.WriteLatencyAsync( target.LastVerified = timestamp; } + if (discarded > 0) + _logger.LogDebug( + "Dropped {Count} result(s) from agent {Id}: the main site is collecting for itself and these targets are not in a WAN context this agent owns", + discarded, connection.AgentId); + await db.SaveChangesAsync(ct); } diff --git a/src/NetworkOptimizer.Web/Services/AgentProbeService.cs b/src/NetworkOptimizer.Web/Services/AgentProbeService.cs index 0f6eeb4cd0..2e5b54d23c 100644 --- a/src/NetworkOptimizer.Web/Services/AgentProbeService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentProbeService.cs @@ -33,11 +33,29 @@ public AgentProbeService(AgentTunnelRegistry registry, ILogger - public async Task RunAsync(string siteSlug, ProbeRequest request, TimeSpan timeout, CancellationToken ct) + /// Site whose agents may run the probe. + /// The probe to run; its SourceIp carries any WAN context bind. + /// How long to wait for the agent's response. + /// Cancellation. + /// + /// Which of the site's agents should run it. Null keeps the original behavior - the site's + /// first connected agent - which is what every caller that has no reason to care wants. A + /// caller that does care is asking for one WAN's vantage, and another agent sits behind a + /// different WAN, so an unavailable one is reported rather than quietly substituted. + /// + public async Task RunAsync( + string siteSlug, ProbeRequest request, TimeSpan timeout, CancellationToken ct, int? agentId = null) { - var agent = _registry.GetForSite(siteSlug).FirstOrDefault(); + var agent = SelectAgent(_registry.GetForSite(siteSlug), agentId); if (agent == null) + { + // No agent at all is null, which callers word as "no on-site agent". A NAMED agent + // that is not connected is a different thing to say, and substituting another one + // would silently measure a different WAN. + if (agentId != null) + return new ProbeResponse { Success = false, Error = "The agent this probe was aimed at isn't connected right now" }; return null; + } var id = Interlocked.Increment(ref _nextRequestId); request.RequestId = id; @@ -66,6 +84,18 @@ public AgentProbeService(AgentTunnelRegistry registry, ILogger + /// Which connected agent runs a probe: the one asked for, or - when nothing asked - the + /// site's first, exactly as before. Never falls back from a named agent to another one: + /// the whole point of naming it is that it sits behind a particular WAN. + /// + /// The site's live tunnel connections. + /// Agent the caller wants, or null for "any". + internal static AgentTunnelConnection? SelectAgent(IReadOnlyList connections, int? agentId) + => agentId is int wanted + ? connections.FirstOrDefault(c => c.AgentId == wanted) + : connections.FirstOrDefault(); + /// Completes the matching pending probe when an agent returns a response. public void OnResult(ProbeResponse response) { diff --git a/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs b/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs index 4502d98932..78b1fc3af1 100644 --- a/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/AgentTunnelRegistry.cs @@ -151,6 +151,37 @@ internal AgentTunnelConnection(int agentId, string siteSlug, string agentName) /// public bool? ServesSpeedTest { get; internal set; } + /// + /// Whether this agent can bind a probe to a source address or interface, as it stated in its + /// hello. Null for an agent old enough not to say, which the interface-bind offer reads as no: + /// a bind an agent cannot honor fails every probe that depends on it. + /// + public bool? SupportsSourceBind { get; internal set; } + + /// + /// Every address this agent's host holds, as the agent reported it. Empty from an agent that + /// predates the field, where alone is all there is. + /// + public IReadOnlyList LocalIps { get; internal set; } = Array.Empty(); + + /// + /// Addresses to recognise this agent's host by: everything it reported, or the single address + /// it chose when it reported nothing. Never empty of meaning - a caller can compare all of + /// these without caring which agent version answered. + /// + public IReadOnlyList HostAddresses => + LocalIps.Count > 0 + ? LocalIps + : string.IsNullOrWhiteSpace(LanIp) ? Array.Empty() : new[] { LanIp! }; + + /// + /// The LAN address this agent announced in its hello. Per connection rather than per site, + /// which is what a multi-agent site needs: the enrollment registry answers with one agent's + /// address for the whole site, so anything deciding about THIS agent - where its probes leave + /// from, whether it is the box a target points at - has to ask the connection. + /// + public string? LanIp { get; internal set; } + public int AgentId { get; } public string SiteSlug { get; } public string AgentName { get; } diff --git a/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs b/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs index 590bc568d0..1cd120e425 100644 --- a/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs +++ b/src/NetworkOptimizer.Web/Services/AgentTunnelService.cs @@ -101,6 +101,12 @@ public override async Task Connect( var connection = _registry.Register(agent.Id, siteSlug, agent.Name); connection.SpeedTestPort = hello.SpeedTestPort; connection.ServesSpeedTest = hello.HasServesSpeedTest ? hello.ServesSpeedTest : null; + connection.SupportsSourceBind = hello.HasSupportsSourceBind ? hello.SupportsSourceBind : null; + connection.LanIp = string.IsNullOrWhiteSpace(hello.LanIp) ? null : hello.LanIp.Trim(); + connection.LocalIps = hello.LocalIps + .Where(ip => !string.IsNullOrWhiteSpace(ip)) + .Select(ip => ip.Trim()) + .ToList(); _logger.LogInformation("Agent {Name} (id {Id}) opened tunnel for site {Slug}", agent.Name, agent.Id, siteSlug); // The pump and refresh loops must stop when the read loop ends for any diff --git a/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs b/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs index 62af561899..546e968c27 100644 --- a/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs +++ b/src/NetworkOptimizer.Web/Services/AppVersionInfo.cs @@ -22,7 +22,7 @@ public static class AppVersionInfo /// "Update agent" callout for enrolled agents reporting an older version /// than this, and over-bumping nags agents into pointless upgrades. /// - public const string LatestAgentVersion = "2.5.3"; + public const string LatestAgentVersion = "2.6.0"; /// Full informational version (e.g. "1.4.2" or "0.0.0-alpha.0.12"). public static string Informational { get; } diff --git a/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs b/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs index dfea1b091f..18d9ca6150 100644 --- a/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs +++ b/src/NetworkOptimizer.Web/Services/IMonitoringTargetService.cs @@ -59,6 +59,14 @@ public sealed record NewMonitoringTarget public ProbeMode ProbeMode { get; init; } = ProbeMode.Icmp; public int Port { get; init; } = 443; public int PollIntervalSeconds { get; init; } = 10; + + /// + /// Which WAN context probes this target, or null for the primary WAN. Set at creation so a + /// target added for a secondary WAN is never briefly probed from the primary - the alternative, + /// add-then-reassign, writes a burst of primary-WAN points that the WAN it was added for then + /// has to be read around. + /// + public int? WanContextId { get; init; } } /// Thrown when a new target fails validation, so the card can show the reason inline. diff --git a/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs b/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs index f486eec159..3ee557f288 100644 --- a/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs +++ b/src/NetworkOptimizer.Web/Services/IUpstreamDiscoveryService.cs @@ -16,13 +16,20 @@ namespace NetworkOptimizer.Web.Services; [MutatingService(SiteScoped = true)] public interface IUpstreamDiscoveryService { - /// Traces the upstream path and proposes targets for review. + /// + /// Traces the upstream path and proposes targets for review. + /// runs a specific tracer instance (a WAN context's own, from the panel's per-WAN view); + /// null runs the site's primary tracer, exactly as before. + /// [RequireRole(Roles.Operator)] [AuditAction(AuditActions.MonitoringSetupChanged, TargetType = "upstream_discovery")] - Task StartAsync(CancellationToken ct = default); + Task StartAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default); - /// Commits the reviewed discovery, writing its hops as monitoring targets. + /// + /// Commits the reviewed discovery, writing its hops as monitoring targets. Same tracer + /// selection rule as . + /// [RequireRole(Roles.Operator)] [AuditAction(AuditActions.MonitoringSetupChanged, TargetType = "upstream_discovery")] - Task CommitAsync(CancellationToken ct = default); + Task CommitAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default); } diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs b/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs index 5d16e4cc5b..6b58d3ba7f 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/AgentProbeExecutor.cs @@ -19,15 +19,29 @@ public sealed class AgentProbeExecutor : IProbeExecutor private readonly AgentProbeService _agentProbe; private readonly string _siteSlug; private readonly ILogger _logger; + private readonly int? _agentId; - public AgentProbeExecutor(AgentProbeService agentProbe, string siteSlug, ILogger logger) + /// + /// Builds an executor for a site's agent vantage. + /// + /// Tunnel probe service. + /// Site whose agent runs the probes. + /// Logger. + /// + /// Which of the site's agents to run on. Null means the site's agent in the singular - the + /// original behavior, and what every path that just wants an on-site origin needs. A named + /// agent is a specific vantage (a WAN context's), so it is never quietly swapped for another. + /// + public AgentProbeExecutor(AgentProbeService agentProbe, string siteSlug, ILogger logger, int? agentId = null) { _agentProbe = agentProbe; _siteSlug = siteSlug; _logger = logger; + _agentId = agentId; + Vantage = agentId is int id ? new($"agent:{id}", VantageKind.Server) : new("agent", VantageKind.Server); } - public ProbeVantage Vantage { get; } = new("agent", VantageKind.Server); + public ProbeVantage Vantage { get; } public Task GetCapabilityAsync(CancellationToken ct = default) => Task.FromResult(new ProbeCapability @@ -43,14 +57,14 @@ public Task GetCapabilityAsync(CancellationToken ct = default) public async Task PingAsync(ProbeTarget target, int count = 10, TimeSpan? perPingTimeout = null, CancellationToken ct = default) { var request = BuildRequest(target, traceroute: false, count: count, maxHops: 0); - var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(count * 3 + 15), ct); + var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(count * 3 + 15), ct, _agentId); if (resp == null) return FailedPing(target, "No on-site agent is online to run the probe"); if (!resp.Success || string.IsNullOrEmpty(resp.ResultJson)) return FailedPing(target, string.IsNullOrEmpty(resp.Error) ? "Agent probe failed" : resp.Error); try { - return JsonSerializer.Deserialize(resp.ResultJson) - ?? FailedPing(target, "Agent returned an unreadable ping result"); + var parsed = JsonSerializer.Deserialize(resp.ResultJson); + return parsed == null ? FailedPing(target, "Agent returned an unreadable ping result") : Attribute(parsed); } catch (Exception ex) { @@ -62,14 +76,14 @@ public async Task PingAsync(ProbeTarget target, int count = 10, public async Task TracerouteAsync(ProbeTarget target, int maxHops = 30, TimeSpan? perHopTimeout = null, TimeSpan? totalDeadline = null, CancellationToken ct = default) { var request = BuildRequest(target, traceroute: true, count: 0, maxHops: maxHops); - var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(30), ct); + var resp = await _agentProbe.RunAsync(_siteSlug, request, TimeSpan.FromSeconds(30), ct, _agentId); if (resp == null) return FailedTrace(target, "No on-site agent is online to run the traceroute"); if (!resp.Success || string.IsNullOrEmpty(resp.ResultJson)) return FailedTrace(target, string.IsNullOrEmpty(resp.Error) ? "Agent traceroute failed" : resp.Error); try { - return JsonSerializer.Deserialize(resp.ResultJson) - ?? FailedTrace(target, "Agent returned an unreadable traceroute result"); + var parsed = JsonSerializer.Deserialize(resp.ResultJson); + return parsed == null ? FailedTrace(target, "Agent returned an unreadable traceroute result") : Attribute(parsed); } catch (Exception ex) { @@ -93,6 +107,21 @@ public async Task TcpProbeAsync(ProbeTarget target, TimeSpan? ti }; } + /// + /// Names the vantage a NAMED agent's result came from. The agent runs the same + /// LocalProbeExecutor the server does, so its result arrives calling itself the "server" + /// vantage - which reads as this server on a site where the server also probes, and a probe + /// picked out by agent has to say which agent ran it. Left untouched for the unnamed + /// executor, where "server" is exactly what the site's single agent vantage has always + /// reported. + /// + private PingProbeResult Attribute(PingProbeResult result) => + _agentId == null ? result : result with { Vantage = Vantage }; + + /// + private TracerouteResult Attribute(TracerouteResult result) => + _agentId == null ? result : result with { Vantage = Vantage }; + private ProbeRequest BuildRequest(ProbeTarget target, bool traceroute, int count, int maxHops) => new() { Address = target.Address, diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs index 243efa126d..6fb5a5b009 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/FlakyTargetService.cs @@ -88,7 +88,8 @@ public record FlakyTarget( double LossPct, double BaselinePct, int OverBins, - int TotalBins) + int TotalBins, + int? WanContextId) { public string Evidence => $"{LossPct:0.0}% loss vs {BaselinePct:0.0}% peer median"; } @@ -129,7 +130,7 @@ public async Task> DetectAsync(CancellationToken ct = Dictionary> series; try { - series = await _influx.QueryLatencyDetailByTargetTypeAsync(type, from, to, binSize, ct); + series = await _influx.QueryLatencyDetailByTargetTypeAsync(type, from, to, binSize, ct: ct); } catch (Exception ex) { @@ -259,7 +260,7 @@ internal static IReadOnlyList Analyze( if (!byId.TryGetValue(targetId, out var t)) continue; var over = survivors.Count(l => l >= threshold); flaky.Add(new FlakyTarget(targetId, t.Id, string.IsNullOrEmpty(t.Name) ? t.Address : t.Name, - t.TargetType, metric, baseline, over, survivors.Count)); + t.TargetType, metric, baseline, over, survivors.Count, t.WanContextId)); } logger?.LogDebug("Flaky-target detect: {Count} flagged, baseline {Base:0.00}%, threshold {Thr:0.00}%, {Bins} surviving bins", diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs new file mode 100644 index 0000000000..08cdf0ad6f --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/ElevationVerdict.cs @@ -0,0 +1,97 @@ +namespace NetworkOptimizer.Web.Services.Monitoring.IspHealth; + +/// +/// Whether a line's elevation under load is OVER - the operator fixed it - or still happening. +/// +/// Asked this way round because of what the noise floor does downstream. Most loaded samples sit +/// near zero even while a line misbehaves, so the floor keeps only the elevated minority and the +/// figure reported is the median OF THE BAD ONES. Comparing medians cannot see a fix there: the +/// median over everything is ~0 before and after. Whether elevation is still HAPPENING can be +/// seen, and that is the question an operator is really asking. +/// +/// +/// Pure and separate from the scorer so the rule can be tested directly. Every branch here was +/// found by being wrong about a real WAN first. +/// +/// +internal static class ElevationVerdict +{ + /// The newest episodes, all below the floor, ending at the first elevated one. + /// Episodes anywhere in the window that were elevated. + /// Whether the clean run covers the hour elevation appeared at. + /// The verdict: everything above agreeing that it stopped. + internal sealed record Verdict( + IReadOnlyList<(DateTime Time, double Value)> CleanRun, + int ElevatedCount, + bool ProblemHourReTested, + bool ElevationIsOver); + + /// One value per load episode, newest first. + /// Added delay below which an episode counts as clean. + /// Clean episodes in a row required to call the elevation over. + /// Whether a cyclical problem must be re-tested at its own hour. + /// How long one episode's window covers, for hour attribution. + /// Below this an older episode counts as clean when deciding + /// whether the history shows hour-dependence at all. + internal static Verdict For( + IReadOnlyList<(DateTime Time, double Value)> episodesNewestFirst, + double noiseFloor, + int staleEpisodes, + bool needsSameHour, + TimeSpan episodeSpan, + double hourDependenceFloor) + { + var cleanRun = episodesNewestFirst.TakeWhile(e => e.Value < noiseFloor).ToList(); + var elevated = episodesNewestFirst.Where(e => e.Value >= noiseFloor).ToList(); + if (elevated.Count == 0) + { + // Nothing was ever elevated, so there is nothing to declare over. A line that has + // always been clean takes the path it always took. + return new Verdict(cleanRun, 0, false, false); + } + + var older = episodesNewestFirst.Skip(cleanRun.Count).ToList(); + var hourReTested = !needsSameHour + || !ShowsHourDependence(older, hourDependenceFloor) + || CoversProblemHour(cleanRun, elevated, episodeSpan); + + var over = cleanRun.Count >= staleEpisodes && hourReTested; + return new Verdict(cleanRun, elevated.Count, hourReTested, over); + } + + /// + /// Whether the history before the clean run varied by hour at all. If EVERY earlier episode was + /// elevated, the line misbehaved whenever it was loaded - the hour was never the variable, so a + /// clean run at any hour disproves it. Requiring the same hour there would hold a fix hostage + /// to whenever the line is next busy, which on a WAN whose only regular load is a scheduled + /// speed test is the following day. + /// + private static bool ShowsHourDependence( + IReadOnlyList<(DateTime Time, double Value)> older, double floor) + => older.Any(e => e.Value < floor); + + /// + /// Whether the clean run covers the hour of day elevation appeared at - the hour with the most + /// elevated episodes. A nightly problem otherwise clears itself: a line that bufferbloats every + /// evening is clean all night, so a run computed at 3 AM finds clean episodes on top of + /// elevated ones and calls it fixed. "It has been fine since" means nothing if the since never + /// covered the hour it went wrong. + /// + private static bool CoversProblemHour( + IReadOnlyList<(DateTime Time, double Value)> cleanRun, + IReadOnlyList<(DateTime Time, double Value)> elevated, + TimeSpan episodeSpan) + { + IEnumerable HoursOf((DateTime Time, double Value) episode) => + UsageWeighting.LocalHoursSpanned(episode.Time, episode.Time + episodeSpan, TimeZoneInfo.Local); + + var problemHour = elevated + .SelectMany(HoursOf) + .GroupBy(h => h) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key) + .First().Key; + + return cleanRun.SelectMany(HoursOf).Contains(problemHour); + } +} diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs index c809ac116c..bcea9262b5 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthOptions.cs @@ -63,6 +63,158 @@ public class IspHealthOptions /// Weight of loaded latency delta within the access dimension. public double LoadedLatencyWeight { get; set; } = 0.14875; + /// + /// Half-life, in hours, for how much a speed test still counts toward the loaded-latency + /// figure. A plain median over the window treats a test from an hour ago exactly like one from + /// six days ago, so a line fixed this afternoon went on reporting bufferbloat until the good + /// tests outnumbered the bad - which on a daily schedule takes a week. Two days of evidence + /// counts half, so three consecutive clean runs outweigh a week of bad ones. + /// + /// Not shorter than that on purpose. On the daily schedule most sites run, a 24-hour half-life + /// gives the newest test more weight than every earlier test combined - which is not a median + /// any more, it is "latest test wins", and one bad run would raise a finding on its own. + /// + /// Zero disables the decay and restores the plain median. + /// + public double LoadedLatencyRecencyHalfLifeHours { get; set; } = 48; + + /// + /// Consecutive newest speed tests that, if all materially better than what came before, are + /// read as the line having been FIXED rather than as it varying - and the older tests are then + /// describing a connection that no longer exists. + /// + /// Weighting by age alone cannot answer this. The window is short enough that a fix this + /// afternoon leaves three clean tests against four bad ones only hours older, where decay + /// barely separates them and the median still sits on the bad cluster. Three in a row is the + /// smallest run that is not a fluke; below that the weighted median decides as before. + /// + /// + public int LoadedLatencyRegimeSamples { get; set; } = 3; + + /// + /// The share of the plan a WAN speed test must have reached IN THAT DIRECTION before its + /// loaded latency may stand in for the measured delta. A test that never filled the pipe did + /// not load the buffers either, so its latency describes something other than this link at + /// saturation - and since the substitution only ever raises the figure, admitting those would + /// bias every matched episode upward with nothing able to correct it. Judged per direction: a + /// test that saturated the downstream and not the upstream still speaks for the downstream. + /// + /// + /// Plan speed at or below which the configured plan is treated as UNSET rather than as a real + /// plan. UniFi Network will not accept anything under 1 Mbps, so a link with no meaningful + /// figure to enter - a metered backup, a standby WAN - ends up pinned at the floor. Grading + /// against it turns an ordinary backup link into a failing one: 0.6 / 0.1 Mbps against a + /// "1 / 1 plan" scored 17. + /// + public double PlanFloorMbps { get; set; } = 1.0; + + public double LoadedLatencySpeedTestMinPlanFraction { get; set; } = 0.7; + + /// + /// How far from a load episode a WAN speed test may sit and still be taken as the measurement + /// OF that episode. Only wide enough to bridge the stored instant of a test and the span of + /// the load it caused - a test runs for tens of seconds, so anything past that is a different + /// event and must not speak for this one. + /// + public double LoadedLatencySpeedTestMatchSeconds { get; set; } = 30; + + /// + /// How close in time two hops' samples must be to count as the same instant for the + /// cross-hop agreement check. One second: close enough that the same queue state is being + /// reported by both, loose enough to catch probes that do not fire in lockstep. + /// + public double LoadedLatencyAgreementToleranceSeconds { get; set; } = 1; + + /// + /// How many distinct hops must report at one instant before their agreement is consulted. + /// Below this there is nothing to corroborate against and the samples pass through as they + /// are - see . + /// + public int LoadedLatencyAgreementMinCohort { get; set; } = 4; + + /// + /// How far below the older tests the recent run has to sit to count as a fix: at 0.5, every one + /// of them must be under half the older median. A line that merely had a good afternoon does + /// not clear this, and a plausible measurement floor is allowed for besides, so a connection + /// whose delta is already small cannot trip it on noise. + /// + public double LoadedLatencyRegimeDropFraction { get; set; } = 0.5; + + /// + /// Consecutive newest load episodes that must show no added delay before the elevation is + /// treated as OVER - the line was fixed, and the elevated episodes behind it describe a + /// connection that no longer exists. + /// + /// Asked this way round because of what the noise floor does downstream. Most loaded samples on + /// a healthy line sit near zero, so the floor keeps only the elevated ones and the figure + /// reported is the median OF THE BAD ONES. Comparing medians cannot see a fix there - the + /// median over everything is ~0 both before and after. Whether elevation is still HAPPENING + /// can be seen, and that is the question. + /// + /// + /// Nothing changes for a line that was not fixed: a still-bad line has elevated episodes among + /// its newest and never qualifies, and a line that was always clean has no elevated episodes to + /// go stale, so it takes the path it always took. + /// + /// + public int LoadedLatencyElevationStaleEpisodes { get; set; } = 3; + + /// + /// Whether the clean run must also cover the hour of day when the elevation used to appear. + /// + /// Without this a nightly problem clears itself: a line that bufferbloats every evening is + /// clean all night, so a run computed at 3 AM sees three clean episodes on top of elevated ones + /// and calls it fixed. Congestion is a time-of-day phenomenon, and "it has been fine since" + /// only means something if the "since" covers the hour it used to go wrong. + /// + /// + /// The cost is honest: a fix is confirmed once the line carries traffic during that hour again, + /// not the moment it stops misbehaving at 3 AM. Until then the figure keeps describing the + /// behavior actually observed at the hour in question, which is all that is known. + /// + /// + /// Only asked when the history shows hour-dependence at all. A line that was elevated in EVERY + /// episode before the clean run was not misbehaving at a time of day - it was misbehaving under + /// load, full stop - so any clean run disproves it. Requiring the same hour there would hold a + /// fix hostage to whenever the line is next busy, which on a WAN whose only regular load is a + /// scheduled speed test is the following day. + /// + /// + public bool LoadedLatencyElevationStaleNeedsSameHour { get; set; } = true; + + /// + /// Utilization band, as a fraction of plan speed, over which a load episode earns credibility: + /// weak at the bottom, full at the top. + /// + /// It starts ABOVE deliberately. Everything reaching this + /// code is already classified loaded at 50%, so a ramp from zero would score almost every + /// episode near the top and separate nothing. Queues do not really build until the pipe is + /// most of the way full, so 60% is where the evidence starts being worth something and 90% is + /// where it is worth all it can be. + /// + /// + public double LoadedCredibilityUtilizationStart { get; set; } = 0.60; + + /// Utilization at which an episode is fully credible. See the start of the band. + public double LoadedCredibilityUtilizationFull { get; set; } = 0.90; + + /// + /// Least weight any load episode keeps, however light. Never zero: a lightly loaded episode is + /// weak evidence, not absent evidence, and a line whose only load is light would otherwise + /// have nothing to score at all. + /// + public double LoadedLatencyMinLoadWeight { get; set; } = 0.15; + + /// + /// Sustained seconds of load after which an episode is fully credible. Duration is not just + /// more samples: a short burst is the case load CLASSIFICATION gets wrong most often, and it is + /// also too brief for buffers to fill, so its latency understates what the line does when the + /// pipe stays full. A long saturation is the best evidence there is - better than a speed test, + /// which is short and synthetic - so it carries full weight while a few seconds of traffic + /// carries a fraction. + /// + public int LoadedLatencyFullCredibilitySustainedSeconds { get; set; } = 60; + /// Weight of loaded packet loss within the access dimension. public double LoadedLossWeight { get; set; } = 0.14875; @@ -881,11 +1033,27 @@ public static class IspHealthProfiles // (Local Priority) ~4.3 ms MAD, degraded backup ~9.2 ms. The several-ms steady-state wander // is inherent LEO (handovers ~every 15 s); MAD is robust to the obstruction tail. AccessTechnology.Satellite => new AccessProfile("Satellite (LEO)", - IdleRttIdealMs: 23.0, IdleRttNormalLowMs: 30.0, IdleRttNormalHighMs: 45.0, IdleRttPoorMs: 80.0, + // Anchored on measured plans rather than estimates. 23 ms is the best the medium does + // at all - months of it on a tier above Local Priority - so it is full marks, and 42 ms + // is the floor of good, which is where a Backup dish sits when nothing is wrong with + // it. Those two points set the rest: with the ladder's 85 at normal-high and 25 at + // poor, 40 and 64 put 42 exactly on 80 and drop 45 to about 72. + // + // Deliberately not tier-aware. A cheaper plan really is worse latency, and hiding that + // behind per-tier bands would score every dish against its own plan and never tell + // anyone their tier is the reason. + IdleRttIdealMs: 23.0, IdleRttNormalLowMs: 30.0, IdleRttNormalHighMs: 40.0, IdleRttPoorMs: 64.0, IdleLossIdealPct: 0.2, IdleLossAcceptablePct: 0.5, LoadedLossDownLowPct: 0.5, LoadedLossDownHighPct: 1.0, LoadedLossUpLowPct: 0.25, LoadedLossUpHighPct: 0.5, - LoadedDeltaExcellentMs: 5.0, LoadedDeltaAcceptableMs: 25.0, + // Set from 403 real Starlink tests. The old 25 ms ceiling sat above the 95th + // percentile of measured delta (17.3 down, 21.6 up), so 97% of load events passed and + // nothing could ever fail it. 12 ms sits near p88 and flags the worst sixth; 3 ms is + // about the median, so "excellent" still means better than this link's usual. + // Not pushed lower on purpose: idle RTT itself swings 16 to 77 ms with obstructions + // and handovers, and a quarter of measured deltas come out negative, so a tighter + // ceiling would be reading that movement rather than queueing. + LoadedDeltaExcellentMs: 3.0, LoadedDeltaAcceptableMs: 12.0, JitterIdealMs: 5.0, JitterTypicalMs: 6.5, JitterPoorMs: 15.0, StabilityMadIdealMs: 5.0, StabilityMadTypicalMs: 9.0, StabilityMadPoorMs: 22.0), diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs index 24d333eded..1d80e551ba 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthRegistry.cs @@ -4,10 +4,13 @@ namespace NetworkOptimizer.Web.Services.Monitoring.IspHealth; /// /// Owns one (and its ) -/// per site. The report snapshot, compute lock, custom-window cache, and adaptive -/// window state are all per-site; a single instance pinned to the default site put -/// the main site's ISP Health score on every site's Monitoring page. Scoped -/// resolution forwards to the current site's instance, same pattern as +/// per (site, WAN). The report snapshot, compute lock, custom-window cache, and adaptive +/// window state are all per-instance; a single instance pinned to the default site put +/// the main site's ISP Health score on every site's Monitoring page. The WAN dimension +/// keys one report per graded WAN: the null/absent WAN is the configured-primary +/// instance every install has (single-WAN sites never create another), and the WAN +/// selectors resolve non-primary WANs by their UniFi wan key ("wan2"). Scoped +/// resolution forwards to the current site's primary instance, same pattern as /// MonitoringInfluxRegistry / MonitoringCollectionRegistry. /// public class IspHealthRegistry : ISiteScopedRegistry @@ -20,21 +23,75 @@ public IspHealthRegistry(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - /// The ISP Health service for a site, created on first use. - public IspHealthService GetFor(string slug) => - _instances.GetOrAdd(slug, s => + // Composite key: "{slug}" for the primary instance (identical to the pre-multi-WAN key, so + // nothing about the primary path changes), "{slug}|{wanKey}" for a scoped WAN. The slug + // alphabet has no '|', so keys cannot collide, and EvictSite can sweep by prefix. + private static string Key(string slug, string? wanInterface) => + string.IsNullOrWhiteSpace(wanInterface) + ? slug + // Normalized ("wan1" == "wan") so a legacy alias can never mint a second instance + // grading the same WAN. + : $"{slug}|{NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface.Trim())}"; + + /// The site's primary-WAN ISP Health service, created on first use. + public IspHealthService GetFor(string slug) => GetFor(slug, null); + + /// + /// The ISP Health service grading one WAN of a site, created on first use. Null (or empty) + /// is the configured-primary instance; a UniFi wan key + /// ("wan2") grades that WAN alone. + /// + public IspHealthService GetFor(string slug, string? wanInterface) => + _instances.GetOrAdd(Key(slug, wanInterface), _ => { - var resolver = ActivatorUtilities.CreateInstance(_serviceProvider, s); - return ActivatorUtilities.CreateInstance(_serviceProvider, s, resolver); + var resolver = ActivatorUtilities.CreateInstance(_serviceProvider, slug); + return string.IsNullOrWhiteSpace(wanInterface) + ? ActivatorUtilities.CreateInstance(_serviceProvider, slug, resolver) + : ActivatorUtilities.CreateInstance(_serviceProvider, slug, resolver, + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface.Trim())); }); - /// The default site's ISP Health service. + /// The default site's primary-WAN ISP Health service. public IspHealthService GetDefault() => GetFor(SiteManagementService.DefaultSiteSlug); + /// + /// Drops the cached report for EVERY WAN of a site, so the next read recomputes. + /// + /// Callers reach for this after the monitoring targets change, and a target belongs to one WAN + /// but the change is not knowable per-WAN from where they stand: pausing a flaky target, an + /// upstream discovery committing hops, a rediscovery replacing them. Invalidating only the + /// injected instance - which is always the primary - left every secondary WAN's report frozen + /// on inputs that no longer existed, for as long as the process lived. + /// + /// + /// Cheap: it marks the caches stale rather than computing anything, and a WAN nobody opens + /// never recomputes at all. + /// + /// + public void InvalidateSite(string slug) + { + foreach (var (key, instance) in _instances) + { + if (string.Equals(key, slug, StringComparison.OrdinalIgnoreCase) + || key.StartsWith(slug + "|", StringComparison.OrdinalIgnoreCase)) + { + instance.Invalidate(); + } + } + } + /// + /// Sweeps every WAN instance of the site, not just the primary. public Func? EvictSite(string slug) { - _instances.TryRemove(slug, out _); + foreach (var key in _instances.Keys) + { + if (string.Equals(key, slug, StringComparison.OrdinalIgnoreCase) + || key.StartsWith(slug + "|", StringComparison.OrdinalIgnoreCase)) + { + _instances.TryRemove(key, out _); + } + } return null; } } diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs index b8006ee65a..977b1bcbe6 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthScorer.cs @@ -85,7 +85,7 @@ public IspHealthReport Score(IspHealthInputs inputs, AccessProfile profile) var jitterFloor = ComputeJitterFloor(inputs); _logger?.LogDebug("ISP Health: path jitter floor {Floor} ms", FormatMsOrNull(jitterFloor)); var (loadedLatency, hasLoadedLatency) = ScoreLoadedLatency(loadedDeltas, profile); - var (loadedLoss, hasLoadedLoss) = ScoreLoadedLoss(inputs.LossPoolSeries, loadWindows, profile); + var (loadedLoss, hasLoadedLoss) = ScoreLoadedLoss(inputs, inputs.LossPoolSeries, loadWindows, profile); // Physical Link: the access medium's own physical layer (optical RX, DOCSIS RF/FEC, // cellular signal). Null factor (omitted, no penalty) when no source matched the WAN. @@ -408,36 +408,215 @@ internal LoadedDeltas ResolveLoadedDeltas( double? down = null, up = null; if (loadWindows.Count > 0) { - down = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp); - up = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown); + down = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp, upstream: false); + up = LoadedLatencyDelta(inputs, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown, upstream: true); } bool downFromSpeedTest = false, upFromSpeedTest = false; if (down == null || up == null) { var (tests, _) = SelectSpeedTests(inputs); - var downDeltas = tests - .Where(t => t.DownloadLatencyMs.HasValue && t.PingMs.HasValue) - .Select(t => Math.Max(0, t.DownloadLatencyMs!.Value - t.PingMs!.Value)) - .ToList(); - var upDeltas = tests - .Where(t => t.UploadLatencyMs.HasValue && t.PingMs.HasValue) - .Select(t => Math.Max(0, t.UploadLatencyMs!.Value - t.PingMs!.Value)) + // Recency-weighted, so a fix shows up in days rather than after the good tests + // outnumber the bad ones. Still a median: one clean run cannot clear a standing + // finding, and one bad run cannot create one. + List<(DateTime Time, double Value)> Deltas(Func loaded) => tests + .Where(t => loaded(t).HasValue && t.PingMs.HasValue) + .Select(t => (t.Time, Math.Max(0, loaded(t)!.Value - t.PingMs!.Value))) .ToList(); + + var downDeltas = Deltas(t => t.DownloadLatencyMs); + var upDeltas = Deltas(t => t.UploadLatencyMs); + // No load weighting here: a speed test saturates the line by definition, so every one + // of these is a fully loaded episode already. if (down == null && downDeltas.Count > 0) { - down = SeriesStats.Median(downDeltas); + down = RecentRegimeDelta(downDeltas) ?? RecencyWeightedDelta(downDeltas, inputs.WindowEnd); downFromSpeedTest = true; } if (up == null && upDeltas.Count > 0) { - up = SeriesStats.Median(upDeltas); + up = RecentRegimeDelta(upDeltas) ?? RecencyWeightedDelta(upDeltas, inputs.WindowEnd); upFromSpeedTest = true; } } return new LoadedDeltas(down, up, downFromSpeedTest, upFromSpeedTest); } + /// + /// The loaded delta the speed tests support, newest first. Normally the recency-weighted + /// median; but when the newest runs in a row all sit far below everything older, that is a + /// line someone FIXED, and the older tests describe a connection that no longer exists. + /// + /// Age-weighting alone cannot see this. The scoring window is short, so a fix this afternoon + /// leaves a handful of clean tests against a handful of bad ones only hours older - too close + /// in age for decay to separate, and the median stays on the bad cluster for days after the + /// line stopped misbehaving. + /// + /// + /// + /// The delta a run of the NEWEST measurements supports when they all sit far below everything + /// older - a line someone fixed, where the older measurements describe a connection that no + /// longer exists. Null when the evidence does not say that. + /// + /// Age-weighting alone cannot see this. The scoring window is short, so a fix this afternoon + /// leaves a few clean measurements against a few bad ones only hours older: too close in age + /// for decay to separate, and the median stays on the bad cluster for days after the line + /// stopped misbehaving. + /// + /// + /// Asked BEFORE the monitoring path's noise floor on purpose. That floor drops deltas under + /// half a millisecond, which is exactly what a fixed line produces - so the evidence of the fix + /// lives in the samples it throws away, and a rule running after it could never see one. + /// + /// + private double? RecentRegimeDelta(IReadOnlyList<(DateTime Time, double Value)> newestFirst) + { + var run = _options.LoadedLatencyRegimeSamples; + if (run <= 0 || newestFirst.Count <= run) return null; + + var recent = newestFirst.Take(run).Select(s => s.Value).ToList(); + var older = newestFirst.Skip(run).Select(s => s.Value).ToList(); + if (SeriesStats.Median(older) is not { } baseline || baseline <= LoadedLatencyRegimeFloorMs) + return null; + + // The floor keeps a connection whose delta is already small from tripping this on ordinary + // measurement noise - halving 1 ms proves nothing. + var threshold = Math.Max( + baseline * _options.LoadedLatencyRegimeDropFraction, + LoadedLatencyRegimeFloorMs); + return recent.All(v => v < threshold) ? SeriesStats.Median(recent) : null; + } + + /// + /// Weighs each measurement by age and, where the load behind it is known, by how hard the line + /// was working - then takes the median at half the total weight. + /// + /// + /// One episode's added delay, by the SAME statistic the pooled path has always used: every + /// access hop's samples together, those below the noise floor dropped, median of what remains. + /// + /// That statistic IS the attribution and is deliberately untouched. Pooling the hops and taking + /// a low-order statistic of the credible ones is what tells a hop that genuinely queues from + /// one that only deprioritizes ICMP - the throttled hop sits at the top of the distribution + /// where a low-order statistic ignores it, while a flat near hop falls below the floor and + /// cannot dilute an OLT that really did spike. Reaching for the worst hop instead would promote + /// the very noise this rejects. + /// + /// + /// Nothing above the floor is zero: the line was loaded and it stayed clean. That is a reading, + /// not a gap - and it was the reading being thrown away, which is how a WAN whose every episode + /// was clean still reported the median of a handful of stray samples. + /// + /// + /// + /// Raises one episode's delta to a WAN speed test's own loaded-vs-idle figure when a test ran + /// during it and read higher, in the SAME direction. + /// + /// A test carries its own idle reference () taken by the + /// same probe against the same endpoint seconds apart, so the difference needs no baseline of + /// ours and inherits none of its blind spots. + /// + /// + /// Deliberately one-directional, and deliberately per-episode. Taking the larger of two + /// estimates biases upward wherever both are about right, so it is confined to the episodes a + /// test actually overlapped rather than allowed to lift the whole factor. + /// + /// + private List<(DateTime Time, double Value)> QualifyingTests( + DateTime start, DateTime end, IspHealthInputs inputs, bool upstream) + { + var tolerance = TimeSpan.FromSeconds(Math.Max(0, _options.LoadedLatencySpeedTestMatchSeconds)); + var found = new List<(DateTime Time, double Value)>(); + foreach (var test in inputs.WanSpeedTests) + { + if (test.Time < start - tolerance || test.Time > end + tolerance) continue; + var underLoad = upstream ? test.UploadLatencyMs : test.DownloadLatencyMs; + if (!underLoad.HasValue || !test.PingMs.HasValue) continue; + + // Only a test that actually filled the pipe measured this link under load. Without + // this the lift is genuinely biased: a stalled or server-limited test reads high for + // reasons that are not your access queue, and nothing downstream can pull it back. + // Unknown plan speed means the question cannot be asked, so the test is not used. + var achieved = upstream ? test.UploadMbps : test.DownloadMbps; + var expected = upstream ? inputs.ExpectedUploadMbps : inputs.ExpectedDownloadMbps; + if (!(expected > 0) || achieved < expected * _options.LoadedLatencySpeedTestMinPlanFraction) + continue; + + found.Add((test.Time, Math.Max(0, underLoad.Value - test.PingMs.Value))); + } + return found; + } + + private static double EpisodeDelta(List deltas, double noiseFloor) + { + var credible = deltas.Where(d => d >= noiseFloor).ToList(); + return credible.Count == 0 ? 0 : SeriesStats.Median(credible)!.Value; + } + + private double? RecencyWeightedDelta( + IReadOnlyList<(DateTime Time, double Value)> samples, + DateTime windowEnd, + Func? loadWeight = null) + => SeriesStats.WeightedMedian(samples + .Select(s => ( + s.Value, + SeriesStats.RecencyWeight(windowEnd - s.Time, _options.LoadedLatencyRecencyHalfLifeHours) + * (loadWeight?.Invoke(s.Time) ?? 1))) + .ToList()); + + /// + /// How much a load episode's latency is worth as evidence, from how hard the line was actually + /// working during it. A window carrying a fifth of the plan barely loads the buffers, so what + /// it shows says little about behavior when the pipe is full; a window at or past + /// counts in full. Never + /// zero - light load is weak evidence, not none - and 1 throughout when the plan speed is + /// unknown, which leaves the figure exactly as it was before load was considered. + /// + private Func BuildLoadWeighting( + IspHealthInputs inputs, bool upstream, IReadOnlySet loaded) + { + var floor = _options.LoadedLatencyMinLoadWeight; + var windowSeconds = Math.Max(1, _options.LoadWindowSeconds); + var fullSeconds = Math.Max(windowSeconds, _options.LoadedLatencyFullCredibilitySustainedSeconds); + var episodeSeconds = SeriesStats.LoadEpisodeSeconds(loaded, windowSeconds); + + double DurationWeight(DateTime key) => + episodeSeconds.TryGetValue(key, out var seconds) + ? SeriesStats.Credibility(seconds, fullSeconds, floor) + : floor; + + // Utilization needs the plan speed. Without it there is nothing to measure "hard" against, + // so that half is left at 1 and duration alone decides - which is still an improvement and + // leaves nothing worse than before. + var planMbps = upstream ? inputs.ExpectedUploadMbps : inputs.ExpectedDownloadMbps; + if (planMbps is not > 0) return time => DurationWeight(FloorToWindow(time)); + + var planBps = planMbps.Value * 1_000_000; + var utilizationByWindow = new Dictionary(); + foreach (var rate in inputs.WanRates) + { + var bps = upstream ? rate.UploadBps : rate.DownloadBps; + if (bps is not > 0) continue; + var key = FloorToWindow(rate.Time); + utilizationByWindow[key] = Math.Max(utilizationByWindow.GetValueOrDefault(key), bps.Value / planBps); + } + + var start = _options.LoadedCredibilityUtilizationStart; + var full = _options.LoadedCredibilityUtilizationFull; + return time => + { + var key = FloorToWindow(time); + var duration = DurationWeight(key); + var utilization = utilizationByWindow.TryGetValue(key, out var u) + ? SeriesStats.CredibilityBetween(u, start, full, floor) + : floor; + return duration * utilization; + }; + } + + /// Below this a loaded delta is too small for a "it was fixed" call to mean anything. + private const double LoadedLatencyRegimeFloorMs = 3; + /// /// Median RTT of the first clean ISP hop during idle windows. Without load /// classification, falls back to the 10th percentile of all RTTs, which @@ -503,6 +682,21 @@ internal LoadedDeltas ResolveLoadedDeltas( }, null, null, null); } + // Both directions pinned at UniFi Network's 1 Mbps minimum is not a 1 Mbps plan - it is the + // lowest the field accepts from someone with nothing real to enter, most often a dish in + // standby or a metered backup held in reserve. A ratio against it is meaningless, but the + // link is not: what matters for a standby WAN is whether it carries usable traffic when + // called on, so it is graded on that instead. Only when BOTH sit at the floor; a real plan + // with a 1 Mbps upstream is unusual but expressible, and half a sentinel is still a plan. + // Corroborated where the dish says so outright, inferred otherwise. The reported tier is + // ground truth and stands on its own: a dish capped by its plan measured against a REAL + // configured plan is the same story told worse, since the shortfall is the tier and not + // the link either way. + var reportedTier = inputs.PhysicalLink?.ReducedSpeedTier == true; + var standby = reportedTier + || (inputs.ExpectedDownloadMbps <= _options.PlanFloorMbps + && inputs.ExpectedUploadMbps <= _options.PlanFloorMbps); + var (tests, stale) = SelectSpeedTests(inputs); if (tests.Count == 0) { @@ -514,8 +708,12 @@ internal LoadedDeltas ResolveLoadedDeltas( }, null, null, null); } - var down = ScoreDirection(tests.Select(t => t.DownloadMbps), inputs.ExpectedDownloadMbps); - var up = ScoreDirection(tests.Select(t => t.UploadMbps), inputs.ExpectedUploadMbps); + var down = standby + ? ScoreStandbyDirection(tests.Select(t => t.DownloadMbps)) + : ScoreDirection(tests.Select(t => t.DownloadMbps), inputs.ExpectedDownloadMbps); + var up = standby + ? ScoreStandbyDirection(tests.Select(t => t.UploadMbps)) + : ScoreDirection(tests.Select(t => t.UploadMbps), inputs.ExpectedUploadMbps); var scores = new[] { down?.Score, up?.Score }.Where(s => s.HasValue).Select(s => s!.Value).ToList(); if (scores.Count == 0) { @@ -536,6 +734,25 @@ internal LoadedDeltas ResolveLoadedDeltas( var typicalUp = up?.TypicalMbps ?? bestUp; var planText = $"{FormatMbps(inputs.ExpectedDownloadMbps ?? 0)} / {FormatMbps(inputs.ExpectedUploadMbps ?? 0)} Mbps plan"; var multi = tests.Count > 1; + if (standby) + { + var standbyNote = multi ? $" Fastest of {tests.Count} WAN tests." : ""; + return (new IspScoreFactor + { + Name = "Speed vs Plan", + Score = (int)Math.Round(scores.Average()), + Weight = _options.SpeedVsPlanWeight, + ValueText = $"{FormatMbps(bestDown)} / {FormatMbps(bestUp)} Mbps", + Description = (reportedTier + ? "The dish reports a reduced-speed plan tier (such as Standby), so throughput " + + "is capped by the plan rather than the link. Graded on whether it carries " + + "usable traffic." + : "Backup link with expected speeds at 1 / 1 Mbps, the lowest UniFi Network " + + "allows. Graded on whether it carries usable traffic, not against a plan speed.") + + standbyNote + staleNote + }, new SpeedTestSample(bestTest.Time, bestDown, bestUp), down?.TypicalMbps, up?.TypicalMbps); + } + var description = multi ? $"Fastest of {tests.Count} WAN tests vs your {planText}. Typical {FormatMbps(typicalDown)} / {FormatMbps(typicalUp)} Mbps (down / up).{staleNote}" : $"Your latest WAN speed test vs your {planText} (down / up).{staleNote}"; @@ -549,6 +766,41 @@ internal LoadedDeltas ResolveLoadedDeltas( }, new SpeedTestSample(bestTest.Time, bestDown, bestUp), down?.TypicalMbps, up?.TypicalMbps); } + /// + /// Grades a standby link on capability rather than ratio: is it carrying usable traffic. + /// + /// Reaching the nominal 1 Mbps IS meeting the stated plan, so that scores full. Below it the + /// taper is deliberately forgiving - a dish in standby delivering 0.6 / 0.1 Mbps is doing + /// exactly its job in the emergency it exists for, and the ratio scoring called that a 17. + /// Only a link carrying essentially nothing scores badly, because that is the only outcome + /// that would actually fail its owner. + /// + /// + private (double Score, double BestMbps, double TypicalMbps)? ScoreStandbyDirection( + IEnumerable resultsMbps) + { + var sorted = resultsMbps.OrderBy(v => v).ToList(); + if (sorted.Count == 0) return null; + var trim = (int)Math.Floor(sorted.Count * _options.SpeedTestOutlierTrimFraction); + var kept = sorted.Skip(Math.Min(trim, sorted.Count - 1)).ToList(); + + var best = kept[^1]; + var typical = SeriesStats.Median(kept)!.Value; + var totalWeight = _options.SpeedCapacityWeight + _options.SpeedTypicalWeight; + var score = (StandbyScore(best) * _options.SpeedCapacityWeight + + StandbyScore(typical) * _options.SpeedTypicalWeight) / totalWeight; + return (score, best, typical); + + static double StandbyScore(double mbps) => mbps switch + { + >= 1.0 => 100, // meets the nominal plan outright + >= 0.1 => 80 + 20 * (mbps - 0.1) / 0.9, // usable for messaging, mail, alarms + >= 0.01 => 40 + 40 * (mbps - 0.01) / 0.09, // reachable, barely + > 0 => 40 * mbps / 0.01, + _ => 0 + }; + } + /// /// Outlier-trims one direction's results and blends capacity (best) with typical /// delivery (median of the rest). Returns the score plus the best and typical for display. @@ -742,7 +994,8 @@ private double ScoreLoadedDelta(double delta, AccessProfile profile) IspHealthInputs inputs, Dictionary loadWindows, Func directionSelector, - Func oppositeSelector) + Func oppositeSelector, + bool upstream) { const double noiseFloor = 0.5; var loaded = DilateLoadedWindows(loadWindows, directionSelector, oppositeSelector); @@ -751,25 +1004,170 @@ private double ScoreLoadedDelta(double delta, AccessProfile profile) ? inputs.AccessHopSeries : new List> { inputs.FirstHopSeries }; - var pooledDeltas = new List(); - foreach (var hop in accessCohort) + // Everything monitored out this WAN, LAN targets excluded - transit, the internet + // destinations, and the user's own witness targets join the access hops. A queue on the + // access link is in front of ALL of them, so under real bufferbloat they rise together; + // one hop rising while the rest read clean at the same second is that responder, not the + // link. This is the absolution DestinationSeries already performs for jitter by ancestry, + // done here by simultaneity, which needs no proven route between the two. + var agreementCohort = accessCohort + .Concat(inputs.TransitAsnSeries.Select(a => a.Samples)) + .Concat(inputs.DestinationSeries.Select(a => a.Samples)) + .Concat(inputs.WitnessSeries.Select(a => a.Samples)) + .Where(series => series.Count > 0) + .ToList(); + + var perHop = new List<(DateTime Time, double Value, int Series)>(); + for (var h = 0; h < agreementCohort.Count; h++) { + var hop = agreementCohort[h]; var baseline = ComputeIdleBaseline(hop, loadWindows); if (baseline == null) continue; - var deltas = hop + var series = h; + perHop.AddRange(hop .Where(s => s.RttAvgMs.HasValue && loaded.Contains(FloorToWindow(s.Time))) - .Select(s => s.RttAvgMs!.Value - baseline.Value); + .Select(s => (s.Time, s.RttAvgMs!.Value - baseline.Value, series))); + } + + // Hops that reported at the same instant are collapsed to what they AGREED on before any + // of this looks at magnitudes. Pooling them flat and then keeping whatever cleared the + // noise floor asked "was any sample high", which one ICMP-deprioritized responder answers + // yes to on its own; the clean hops it was sitting next to were discarded by that same + // floor before the median ever saw them. Collapsing first asks "was the LINK high", which + // is the question the score is about, and a lone squealer loses the vote. + var pooled = SeriesStats.CommonModeByInstant( + perHop, + TimeSpan.FromSeconds(_options.LoadedLatencyAgreementToleranceSeconds), + _options.LoadedLatencyAgreementMinCohort, + noiseFloor); + + // Grouped by EPISODE - the run of consecutive loaded windows - not by window. A window is + // seven seconds, so "the newest three windows" is the last twenty seconds and any brief + // lull inside one bad evening would read as a line that was fixed. An episode is however + // long the line actually stayed loaded, which is the unit a person means by "a load event". + var episodeStarts = SeriesStats.LoadEpisodeStarts(loaded, Math.Max(1, _options.LoadWindowSeconds)); + var episodes = pooled + .Where(x => episodeStarts.ContainsKey(FloorToWindow(x.Time))) + .GroupBy(x => episodeStarts[FloorToWindow(x.Time)]) + .Select(g => (Time: g.Key, Value: EpisodeDelta(g.Select(x => x.Value).ToList(), noiseFloor))) + .OrderByDescending(e => e.Time) + .ToList(); - pooledDeltas.AddRange(deltas); + // Has the elevation STOPPED? Comparing medians cannot answer that here: most loaded samples + // sit near zero even while the line misbehaves, so the median over everything is ~0 before + // and after a fix, and the figure that gets reported comes from the elevated minority the + // noise floor keeps. Whether elevation is still happening IS visible - and a run of clean + // episodes after elevated ones is a line someone fixed. + // + // A line that was not fixed is untouched: still-bad lines have elevated episodes among + // their newest, and always-clean lines have no elevated episodes to go stale. + if (episodes.Count == 0 || pooled.Count < _options.MinLoadedSamples) return null; + + // Where a WAN speed test ran during an episode, it measured the same event on purpose and + // at full saturation, while these probes only sampled it on their own cadence - so a short + // event's peak queue can build and drain between two probes and never be seen. Taken only + // when it reads HIGHER: that is the direction passive sampling fails in. When the test + // reads lower, the series saw something the test's own window did not cover, and the + // measurement stands. + var episodeEnds = episodeStarts + .GroupBy(kv => kv.Value) + .ToDictionary(g => g.Key, g => g.Max(kv => kv.Key) + .AddSeconds(Math.Max(1, _options.LoadWindowSeconds))); + // What a speed test measured during each episode, where one qualified. Applied to the + // FINAL figure rather than to the episode it came from, because the figure is a median + // across episodes and a median cannot be moved by one member however it is weighted - + // lifting per-episode left the better instrument unable to change a reported number even + // once. But applied only over the episodes the answer is actually being drawn from: a + // test is evidence about the line AS IT WAS THEN, and a window-wide maximum would let a + // test from before a fix override the clean run that proves the fix. + var testsByEpisode = episodes.ToDictionary( + e => e.Time, + e => QualifyingTests( + e.Time, episodeEnds.TryGetValue(e.Time, out var end) ? end : e.Time, + inputs, upstream)); + + // Judged the same way the speed-test fallback judges its own pool: a recent run of clean + // tests is read as the line having been FIXED and the older ones as describing a + // connection that no longer exists, otherwise recency-weighted. A plain maximum threw all + // of that away - the worst test in the window won outright, so a line whose recent tests + // are all clean kept reporting its worst day from a week ago. + double SpeedTestOver(IEnumerable<(DateTime Time, double Value)> over) + { + var deltas = over + .SelectMany(e => testsByEpisode.TryGetValue(e.Time, out var t) ? t : []) + .DistinctBy(t => t.Time) + .OrderByDescending(t => t.Time) + .ToList(); + if (deltas.Count == 0) return double.NegativeInfinity; + return RecentRegimeDelta(deltas) + ?? RecencyWeightedDelta(deltas, inputs.WindowEnd) + ?? double.NegativeInfinity; } - var credible = pooledDeltas.Where(d => d >= noiseFloor).ToList(); - if (credible.Count < _options.MinLoadedSamples) return null; - return Math.Max(0, SeriesStats.Median(credible)!.Value); + var loadWeight = BuildLoadWeighting(inputs, upstream, loaded); + var stale = _options.LoadedLatencyElevationStaleEpisodes; + var elevatedEpisodes = episodes.Where(e => e.Value >= noiseFloor).ToList(); + + ElevationVerdict.Verdict? verdict = null; + if (stale > 0 && episodes.Count > stale) + { + verdict = ElevationVerdict.For( + episodes, noiseFloor, stale, + _options.LoadedLatencyElevationStaleNeedsSameHour, + TimeSpan.FromSeconds(Math.Max(1, _options.LoadWindowSeconds)), + LoadedLatencyRegimeFloorMs); + + } + + // Logged for EVERY report, verdict or not. Gating this behind the verdict's own condition + // meant a WAN with too few load episodes to judge - the case most worth looking at - was + // the one that said nothing at all. The newest elevated episode is named so the moment can + // be pulled up in the time series rather than inferred from what sits near it. + _logger?.LogDebug( + "ISP Health: loaded latency {Dir} - {Episodes} episode(s) from {Cohort} target(s), " + + "{Elevated} elevated (newest {NewestElevated}), clean run {CleanRun}, needs {Needed}, " + + "problem hour re-tested: {HourCovered} -> {Verdict}", + upstream ? "up" : "down", episodes.Count, agreementCohort.Count, elevatedEpisodes.Count, + elevatedEpisodes.Count > 0 + // Local, not UTC: this is read by someone about to go and look at that moment in + // the time series, and every other part of this reasons in their hours too. + ? $"{TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(elevatedEpisodes[0].Time, DateTimeKind.Utc), TimeZoneInfo.Local):yyyy-MM-dd HH:mm:ss} local at " + + elevatedEpisodes[0].Value.ToString("0.0", CultureInfo.InvariantCulture) + " ms" + : "none", + verdict?.CleanRun.Count.ToString(CultureInfo.InvariantCulture) ?? "n/a", stale, + verdict?.ProblemHourReTested.ToString() ?? "n/a", + verdict is null ? "too few episodes to judge" + : verdict.ElevationIsOver ? "elevation over" + : elevatedEpisodes.Count == 0 ? "clean - no elevated episodes" + : "still elevated"); + + // The line was fixed: the elevated episodes describe a connection that no longer exists, + // so only the clean run since speaks for it. + // Only tests taken DURING the clean run speak for a line that was fixed. The elevated + // episodes describe a connection that no longer exists, and so do the tests that ran in + // them - letting those back in through the lift would re-assert the very finding the + // clean run just cleared. + if (verdict is { ElevationIsOver: true }) + return Math.Max(0, Math.Max( + SeriesStats.Median(verdict.CleanRun.Select(e => e.Value).ToList())!.Value, + SpeedTestOver(verdict.CleanRun))); + + // The reported figure is the median ACROSS EPISODES - what this line typically does under + // load - weighted by recency and by how credible each episode's load was. + // + // It used to be the median of the SAMPLES above the noise floor, which is a different + // question: the worst of it. One elevated episode among five then set the whole number, + // and a WAN whose every episode was clean could still report tens of milliseconds off a + // handful of stray samples. The floor still decides what counts as elevated for the + // verdict above - it is not a filter on what gets reported. + return Math.Max(0, Math.Max( + RecencyWeightedDelta(episodes, inputs.WindowEnd, loadWeight) ?? 0, + SpeedTestOver(episodes))); } private (IspScoreFactor Factor, bool HasData) ScoreLoadedLoss( + IspHealthInputs inputs, List> lossPool, Dictionary loadWindows, AccessProfile profile) @@ -784,8 +1182,8 @@ private double ScoreLoadedDelta(double delta, AccessProfile profile) }, false); } - var downLoss = LoadedMeanLoss(lossPool, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp); - var upLoss = LoadedMeanLoss(lossPool, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown); + var downLoss = LoadedMeanLoss(inputs, lossPool, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp, upstream: false); + var upLoss = LoadedMeanLoss(inputs, lossPool, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown, upstream: true); var scores = new List(); if (downLoss.HasValue) scores.Add(ScoreLossBand(downLoss.Value, profile.LoadedLossDownLowPct, profile.LoadedLossDownHighPct)); @@ -831,17 +1229,20 @@ private double ScoreLossBand(double loss, double bandLow, double bandHigh) } private double? LoadedMeanLoss( + IspHealthInputs inputs, List> lossPool, Dictionary loadWindows, Func directionSelector, - Func oppositeSelector) + Func oppositeSelector, + bool upstream) { var loaded = DilateLoadedWindows(loadWindows, directionSelector, oppositeSelector); - var losses = lossPool.SelectMany(series => series) + var samples = lossPool.SelectMany(series => series) .Where(s => s.LossPercent.HasValue && !InOutage(s.Time) && loaded.Contains(FloorToWindow(s.Time))) - .Select(s => _gatewayFloor.Apply(s.LossPercent!.Value, s.Time)) + .Select(s => (s.Time, Value: _gatewayFloor.Apply(s.LossPercent!.Value, s.Time))) .ToList(); + var losses = samples.Select(s => s.Value).ToList(); // Loaded loss rests on however many samples happen to fall inside the loaded windows, and on // a long window the rate series is aggregated far coarser than LoadWindowSeconds, so that set // can be small enough for a few dark samples to set the whole figure. Log what it was built @@ -851,7 +1252,17 @@ private double ScoreLossBand(double loss, double bandLow, double bandHigh) losses.Count, loaded.Count, losses.Count(l => l >= 99.0), losses.Count > 0 ? losses.Average().ToString("0.##", CultureInfo.InvariantCulture) : "n/a"); if (losses.Count < _options.MinLoadedSamples) return null; - return losses.Average(); + // Same credibility rules as loaded latency: a sustained saturation says far more about + // behavior under load than a two-second burst that may not even have been load, and recent + // evidence outranks old evidence of the same kind. A weighted MEAN rather than a median, + // because loss is a rate - most samples are zero even on a bad line, and a median over + // them reports zero however bad the rest are. + var loadWeight = BuildLoadWeighting(inputs, upstream, loaded); + return SeriesStats.WeightedMean(samples + .Select(s => (s.Value, + SeriesStats.RecencyWeight(inputs.WindowEnd - s.Time, _options.LoadedLatencyRecencyHalfLifeHours) + * loadWeight(s.Time))) + .ToList()) ?? losses.Average(); } /// @@ -1983,24 +2394,34 @@ private List CollectIssues( // Queues. Loss under load while it shapes means the rate it holds isn't backing off // enough for the real-time capacity drop, so point at its own tuning knobs (Severity // deepens the time-of-day dips; nominal speeds set the ceiling everything scales from). + // One recommendation used to serve both findings below, worded for loss - so a + // bufferbloat finding was answered with advice about drops the user was not seeing. + // The Adaptive SQM branch now says which symptom it is talking about; the other two + // are symptom-neutral and stay one string. string recommendation; + string latencyRecommendation; if (inputs.AdaptiveSqmEnabled) { recommendation = "Adaptive SQM is already shaping this WAN, so loss under load means the rate it holds isn't backing off enough when the line congests. In your Adaptive SQM settings, raise the Severity so the peak-hour rate dips go deeper, or lower the nominal download/upload if the line consistently delivers less than its plan. If loss persists once the rate is pulled down, the drops are upstream and only your ISP can fix them."; + latencyRecommendation = "Adaptive SQM is already shaping this WAN, so latency under load means the rate it holds isn't backing off enough when the line congests. In your Adaptive SQM settings, raise the Severity so the peak-hour rate dips go deeper, or lower the nominal download/upload if the line consistently delivers less than its plan. If the loaded latency persists once the rate is pulled down, the queue is upstream and only your ISP can drain it."; } else if (inputs.SmartQueuesEnabled) { recommendation = "Smart Queues is enabled on this WAN but the line still degrades under load; check that its configured rates match what the line actually delivers."; + latencyRecommendation = recommendation; } else { recommendation = "Enable Smart Queues (SQM) on this WAN in UniFi Network (Settings, Internet, your WAN, Smart Queues)."; + latencyRecommendation = recommendation; } // Only pitch Adaptive SQM when the WAN isn't already running it. if (!inputs.AdaptiveSqmEnabled && inputs.CongestionEvents.Count(e => e.Disposition == CongestionDisposition.Confirmed) >= _options.SqmRecurringCongestionEvents) { - recommendation += " This connection also shows a recurring congestion pattern; consider Adaptive SQM, which tracks time-of-day capacity changes automatically."; + const string alsoConsider = " This connection also shows a recurring congestion pattern; consider Adaptive SQM, which tracks time-of-day capacity changes automatically."; + recommendation += alsoConsider; + latencyRecommendation += alsoConsider; } if (latencyTriggered) { @@ -2009,7 +2430,7 @@ private List CollectIssues( Severity = IspIssueSeverity.Warning, Title = "Bufferbloat under load", Description = "Latency rises well beyond the excellent range for this connection type when the line is loaded.", - Recommendation = recommendation, + Recommendation = latencyRecommendation, LinkUrl = "/sqm", LinkText = "Adaptive SQM" }); @@ -2113,8 +2534,8 @@ private List CollectIssues( var loss = false; if (loadWindows.Count > 0) { - var downLoss = LoadedMeanLoss(inputs.LossPoolSeries, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp); - var upLoss = LoadedMeanLoss(inputs.LossPoolSeries, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown); + var downLoss = LoadedMeanLoss(inputs, inputs.LossPoolSeries, loadWindows, w => w.IsLoadedDown, w => w.IsLoadedUp, upstream: false); + var upLoss = LoadedMeanLoss(inputs, inputs.LossPoolSeries, loadWindows, w => w.IsLoadedUp, w => w.IsLoadedDown, upstream: true); loss = downLoss > profile.LoadedLossDownHighPct || upLoss > profile.LoadedLossUpHighPct; } return (latency, loss); diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs index e55839e9fa..57c348465e 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/IspHealthService.cs @@ -3,6 +3,7 @@ using NetworkOptimizer.Core.Helpers; using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; namespace NetworkOptimizer.Web.Services.Monitoring.IspHealth; @@ -30,6 +31,11 @@ public class IspHealthService private readonly ILogger _logger; private readonly string _siteSlug; private readonly bool _isDefault; + // The UniFi wan key ("wan2") this instance grades, or null for the configured-primary + // instance - which is every install's only instance until it has more than one WAN. + // The primary instance resolves its wan key per compute (today's behavior, unchanged); + // a scoped instance grades exactly the WAN it was created for. + private readonly string? _scopedWanKey; private readonly IspHealthOptions _options = new(); private const int MaxCustomWindowHours = 720; // 30-day cap on the date/time filter, matching the UI private readonly SemaphoreSlim _computeLock = new(1, 1); @@ -66,10 +72,13 @@ public IspHealthService( SiteConnectionRegistry siteConnections, PhysicalLinkResolver physicalLinkResolver, ILogger logger, - string siteSlug = SiteManagementService.DefaultSiteSlug) + string siteSlug = SiteManagementService.DefaultSiteSlug, + string? wanInterface = null) { _siteSlug = string.IsNullOrEmpty(siteSlug) ? SiteManagementService.DefaultSiteSlug : siteSlug; _isDefault = _siteSlug == SiteManagementService.DefaultSiteSlug; + _scopedWanKey = string.IsNullOrWhiteSpace(wanInterface) + ? null : GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface.Trim()); _influx = influxRegistry.GetFor(_siteSlug); _dbFactory = dbFactory; _siteDbFactory = siteDbFactory; @@ -78,6 +87,12 @@ public IspHealthService( _logger = logger; } + /// + /// The UniFi wan key this instance grades, or null for the configured-primary instance + /// (which resolves its WAN per compute). Registry key, and what the UI selectors route on. + /// + public string? ScopedWanInterface => _scopedWanKey; + /// /// Every site (the home site included) reads its expected ISP plan speeds from the UniFi /// Console, so computing ISP Health before that connection is up would cache a report with @@ -149,15 +164,21 @@ public async Task SetAccessTechnologyAsync(AccessTechnology technology, Cancella { await using (var db = await CreateSiteDbAsync(ct)) { - // Primary WAN context, wan-first like the reader's ordering - but NOT filtered to - // non-Unknown: setting it when it is currently unset is the whole point. Create it if - // the table is empty, matching Upstream Discovery's create-if-missing on commit. - var ctxRow = (await db.WanDiscoveryContexts.ToListAsync(ct)) - .OrderBy(c => string.Equals(c.WanInterface, "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .FirstOrDefault(); + var rows = await db.WanDiscoveryContexts.ToListAsync(ct); + // The SCORED WAN's context row - a scoped instance writes its own WAN's technology, + // never the primary's. The primary resolves its key like the compute does (configured + // role first, "wan"-first guess offline) - and is NOT filtered to non-Unknown: + // setting it when it is currently unset is the whole point. Create it if missing, + // matching Upstream Discovery's create-if-missing on commit. + var writeKey = _scopedWanKey + ?? await ResolveConfiguredPrimaryWanKeyAsync(ct) + ?? ResolvePrimaryWanKey(rows); + var ctxRow = rows.FirstOrDefault(c => string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface ?? ""), + GatewayWanHelper.WanInterfaceKeyFromKey(writeKey), StringComparison.OrdinalIgnoreCase)); if (ctxRow == null) { - ctxRow = new WanDiscoveryContext { WanInterface = "wan" }; + ctxRow = new WanDiscoveryContext { WanInterface = writeKey }; db.WanDiscoveryContexts.Add(ctxRow); } ctxRow.AccessTechnology = technology; @@ -569,9 +590,17 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // below (tolerance-matched; a recompute can shift a boundary by a bucket). List ackedOutageStarts; // The WAN this report grades, in MonitoringTarget.WanInterface's namespace (the UniFi WAN - // name, "wan"/"wan2" - NOT the data-path ifname GetPrimaryWanInterfaceAsync returns). Used - // to keep another WAN's internet destinations out of the partial-loss breadth pool. + // name, "wan"/"wan2" - NOT the data-path ifname GetPrimaryWanInterfaceAsync returns). + // Every input below - targets, discoveries, latency series, counters, expected speeds - + // is scoped to this one WAN, so a second WAN's data can never leak into this report. string? primaryWanKey; + string scoredWanKey; + // True for the configured-primary instance (_scopedWanKey null): it additionally owns + // every row with no WAN stamped (hand-added and legacy targets), preserving single-WAN + // behavior exactly. A scoped instance owns only rows stamped with its own wan key. + var primaryScope = _scopedWanKey == null; + // The wan-tag scope the latency reads filter on (see MonitoringInfluxClient.LatencyWanScope). + MonitoringInfluxClient.LatencyWanScope? wanScope; await using (var db = await CreateSiteDbAsync(ct)) { var settings = await db.MonitoringSettings.AsNoTracking().FirstOrDefaultAsync(ct); @@ -579,20 +608,27 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi return new ComputeOutcome(IspHealthStatus.NotConfigured, null, new List()); // Access technology lives per-WAN in WanDiscoveryContexts (the wizard's - // store, which replaced the global MonitoringSettings column); prefer the - // primary WAN's context and fall back to the legacy global value. - var wanContexts = await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct); - var primaryContext = wanContexts - .OrderBy(c => string.Equals(c.WanInterface, "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .FirstOrDefault(c => c.AccessTechnology != AccessTechnology.Unknown); - technology = primaryContext?.AccessTechnology ?? settings.AccessTechnology; - // Same wan-first ordering, but the interface NAME and without the access-technology + // store, which replaced the global MonitoringSettings column). Same wan-first + // ordering as before, but the interface NAME and without an access-technology // filter: a WAN whose technology was never set still owns its targets. Falls back to // "wan" so a site with no discovery context yet still scopes to the conventional primary. - primaryWanKey = wanContexts - .OrderBy(c => string.Equals(c.WanInterface, "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .Select(c => c.WanInterface) - .FirstOrDefault(w => !string.IsNullOrEmpty(w)) ?? "wan"; + var wanContexts = await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct); + // Primary is a ROLE: ask the console which group holds it (any wanN can); the + // name-ordered context guess is the offline fallback only. + primaryWanKey = await ResolveConfiguredPrimaryWanKeyAsync(ct) ?? ResolvePrimaryWanKey(wanContexts); + scoredWanKey = _scopedWanKey ?? primaryWanKey; + + // The scored WAN's OWN discovery context decides its technology; the legacy global + // MonitoringSettings value is the primary's fallback only (installs predating the + // per-WAN context). A scoped WAN with no technology set funnels to NeedsTechnology + // below rather than borrowing the primary's - grading LTE against fiber thresholds + // is exactly the mispairing per-WAN scoring exists to kill. + var scoredContext = wanContexts.FirstOrDefault(c => + string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface ?? ""), + GatewayWanHelper.WanInterfaceKeyFromKey(scoredWanKey), StringComparison.OrdinalIgnoreCase)); + technology = scoredContext?.AccessTechnology is { } t && t != AccessTechnology.Unknown + ? t + : primaryScope ? settings.AccessTechnology : AccessTechnology.Unknown; targets = await db.MonitoringTargets.AsNoTracking() .Where(t => t.Enabled && (t.TargetType == MonitoringTargetType.AccessIsp @@ -604,19 +640,41 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // an Internet target. Not graded as an ISP/transit card themselves. || t.TargetType == MonitoringTargetType.Custom)) .ToListAsync(ct); + // Scope to the WAN being graded. In memory (case-insensitive like every other + // WanInterface comparison), and null-WanInterface rows go to the primary only - + // hand-added and legacy targets were always primary-path measurements. + targets = ScopeTargetsToWan(targets, scoredWanKey, includeUnassigned: primaryScope); + // Fabric targets stay unscoped: the LAN gateway is shared by every WAN, and its + // series only scopes outages (gateway-unreachable => LAN outage, not WAN). fabricTargets = await db.MonitoringTargets.AsNoTracking() .Where(t => t.Enabled && t.TargetType == MonitoringTargetType.Fabric && t.DeviceMac != null) .ToListAsync(ct); - // TODO (multi-WAN): discoveries are read across ALL WANs, not scoped to the WAN - // being scored. UpstreamDiscovery rows carry WanInterface, but ISP Health scores - // a single (primary) WAN and ancestry/hopOrderKnown here is global, so a second - // WAN's discovery data could flip the absolve gate for a WAN that has none of its - // own. Scope by WanInterface once ISP Health grades per-WAN. See TODO.md. - var discoveries = await db.UpstreamDiscoveries.AsNoTracking() + // Discoveries scoped like the targets: this WAN's rows, plus unstamped legacy rows + // for the primary only. Ancestry, hopOrderKnown, and the hop-number map all follow, + // so another WAN's trace data can never flip this WAN's jitter-absolve gate or + // hop ordering - and a scoped WAN with no discovery of its own conservatively + // reads as "no trace map" (hopOrderKnown false) instead of borrowing one. + var discoveries = (await db.UpstreamDiscoveries.AsNoTracking() .Where(d => d.IsActive && d.MonitoringTargetId != null) - .ToListAsync(ct); + .ToListAsync(ct)) + .Where(d => string.IsNullOrEmpty(d.WanInterface) + ? primaryScope + : string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(d.WanInterface), + GatewayWanHelper.WanInterfaceKeyFromKey(scoredWanKey), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Latency reads filter the Influx `wan` tag to this WAN's series: untagged points + // for the primary, a WAN's context tag values for a scoped WAN (see BuildWanScope). + var bindingContexts = await db.WanContexts.AsNoTracking().ToListAsync(ct); + // No contexts means nothing has ever written a wan tag here, so there is nothing to + // filter apart: the primary instance reads exactly the unfiltered query it always + // has. That keeps every single-WAN install on the query shape that is already proven + // in the field rather than on a tag-absence predicate for no gain. + wanScope = primaryScope && bindingContexts.Count == 0 + ? null + : BuildWanScope(bindingContexts, scoredWanKey, primaryScope); // TargetId -> ancestor hop IPs. Join discovery rows to the loaded targets by PK. var targetIdById = targets.ToDictionary(t => t.Id, t => t.TargetId); ancestorIpsByTargetId = discoveries @@ -676,8 +734,8 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // A PPPoE session costs latency and loaded loss on top of whatever the medium does, so it // is overlaid on the medium's profile rather than replacing it. Read from the gateway, not // from the user: the encapsulation and the medium are independent facts, and only the - // medium needs asking for. Scoped to the primary WAN, matching what ISP Health grades - // (see the multi-WAN TODO above). + // medium needs asking for. Read off the SCORED WAN's own data-path interface - a PPPoE + // secondary behind a plain-DHCP primary gets its overlay, and vice versa. // Null (couldn't tell) scores like false - there is nothing else it can do - but it is // logged as the unknown it is rather than passed off as a settled answer. var pppoeSession = await IsPppoeWanAsync(ct); @@ -719,10 +777,13 @@ private async Task ComputeCoreAsync(DateTime windowStart, DateTi // technology resolution, and console calls. The "fetch" figure lumped it in with the reads, // which measured the four latency queries at ~1s from the box while fetch showed ~6.8s. var setupMs = computeSw.ElapsedMilliseconds; - var ispSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.AccessIsp, outageQueryStart, windowEnd, aggregate, ct); - var transitSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Transit, windowStart, windowEnd, aggregate, ct); - var internetSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.InternetService, outageQueryStart, windowEnd, aggregate, ct); - var customSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Custom, windowStart, windowEnd, aggregate, ct); + // Every type-level read carries the wan-tag scope, so a second WAN's series never enter + // this report even where a target id joined both (reassignment history). The gateway + // (fabric) read below stays unscoped by design: the LAN gateway serves every WAN. + var ispSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.AccessIsp, outageQueryStart, windowEnd, aggregate, wanScope, ct); + var transitSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Transit, windowStart, windowEnd, aggregate, wanScope, ct); + var internetSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.InternetService, outageQueryStart, windowEnd, aggregate, wanScope, ct); + var customSeriesTask = _influx.QueryLatencyDetailByTargetTypeAsync(MonitoringTargetType.Custom, windowStart, windowEnd, aggregate, wanScope, ct); // Rates keep a fine interval whatever the window length. Thinning them with everything else // destroys the only property that separates sustained load from a spike - whether neighboring // samples are loaded too - because a minute-long transfer and a one-sample counter artifact @@ -840,10 +901,30 @@ static Dictionary> TrimFrom(Dictionary ispSeries.ContainsKey(t.TargetId)) + .SelectMany(t => TransitUnreachableDetector.Detect( + t.TargetId, t.AsnNumber ?? 0, AsnNameCleanup.Clean(t.AsnName), ispSeries[t.TargetId], _options) + .Concat(TransitUnreachableDetector.DetectMostlyDark( + t.TargetId, t.AsnNumber ?? 0, AsnNameCleanup.Clean(t.AsnName), ispSeries[t.TargetId], _options))) + .ToList(); + var darkWindows = transitDarkWindows.Concat(ispDarkWindows).ToList(); + var darkByTargetId = darkWindows .GroupBy(w => w.TargetId) .ToDictionary(g => g.Key, g => g.ToList()); + // Hops with a discovery row but HopNumber 0 answered pings yet never landed in a trace + // (OLT/CMTS ICMP-deprioritization); only meaningful once there is trace data at all. + var notTracedTargetIds = hopOrderKnown + ? hopNumberByTargetId.Where(kv => kv.Value == 0).Select(kv => kv.Key).ToHashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + // Loss pool: ALL enabled AccessIsp + Transit targets plus well-known anycast DNS. // Every probe crosses the access link before reaching its target, so loss on ANY // of these is a signal of access-layer loss - including under load, where the @@ -857,13 +938,44 @@ static Dictionary> TrimFrom(Dictionary(); - identifiedPool.AddRange(ispTargets.Where(t => ispSeries.ContainsKey(t.TargetId)) - .Select(t => new LossPoolFilter.PoolEntry(t.TargetId, ispSeries[t.TargetId]))); + // Access hops that answer pings but sit on no traced path are excluded outright. Nothing + // of yours crosses them, so their loss is not loss you suffered - it is a box beside the + // road dropping the probes aimed at it. Their jitter was already discounted for exactly + // this reason; the same logic was never carried over to loss, and one ICMP-deprioritized + // OLT answering badly could hold the pooled figure up on its own. + // ...unless they are ALL that this site has. An off-path OLT is weak evidence, but it is + // the only access-layer member available on a network with nothing else pingable in front + // of transit, and dropping it would leave access-layer loss measured entirely by hops + // beyond the access network. Weak evidence in the right place beats none. + var ispWithSeries = ispTargets.Where(t => ispSeries.ContainsKey(t.TargetId)).ToList(); + var onPathIsp = ispWithSeries.Where(t => !notTracedTargetIds.Contains(t.TargetId)).ToList(); + var ispForPool = onPathIsp.Count > 0 ? onPathIsp : ispWithSeries; + + var offPathIsp = ispWithSeries.Except(ispForPool).ToList(); + if (offPathIsp.Count > 0) + _logger.LogDebug( + "ISP Health: excluding {Count} off-path access hop(s) from the loss pool: {Targets}", + offPathIsp.Count, string.Join(", ", offPathIsp.Select(t => t.Address))); + else if (onPathIsp.Count == 0 && ispWithSeries.Count > 0) + _logger.LogDebug( + "ISP Health: keeping {Count} off-path access hop(s) in the loss pool - the site has no on-path access hop", + ispWithSeries.Count); + + identifiedPool.AddRange(ispForPool + .Select(t => new LossPoolFilter.PoolEntry(t.TargetId, + darkByTargetId.TryGetValue(t.TargetId, out var ispDark) + ? ispSeries[t.TargetId].Where(s => !ispDark.Any(w => s.Time >= w.Start && s.Time <= w.End)).ToList() + : ispSeries[t.TargetId]))); identifiedPool.AddRange(transitTargets.Where(t => transitSeries.ContainsKey(t.TargetId)).Select(t => new LossPoolFilter.PoolEntry(t.TargetId, darkByTargetId.TryGetValue(t.TargetId, out var dark) ? transitSeries[t.TargetId].Where(s => !dark.Any(w => s.Time >= w.Start && s.Time <= w.End)).ToList() : transitSeries[t.TargetId]))); + // Anycast DNS goes in RAW, deliberately - no unreachable carve-out. Those addresses are + // served from everywhere at once and effectively never have an outage of their own, so a + // resolver going dark is the ISP failing to reach it, which is exactly the loss this pool + // exists to catch. Carving it out for symmetry with the hops above would delete the + // clearest outage signal there is. identifiedPool.AddRange(targets .Where(t => t.TargetType == MonitoringTargetType.InternetService && AnycastDnsIps.Contains(t.Address) @@ -1049,17 +1161,13 @@ double MedianRtt(AsnSeries s) => SeriesStats.Median( .Take(2) .ToList(); // Every internet destination on the WAN being graded - the partial pass's breadth evidence. - // Null WanInterface is INCLUDED: the tracer stamps only what it discovers, so a hand-added - // destination has none, and dropping those would quietly shrink the pool on exactly the - // installs that curated it. Only a target explicitly bound to a DIFFERENT WAN is excluded, - // so a failover link's destinations can't manufacture breadth for the primary. (The rest of - // ISP Health is still primary-WAN-only by assumption rather than by filter - see TODO.md - // "Multi-WAN Support (ISP Health & NMS)".) + // The target list is already scoped to this WAN (ScopeTargetsToWan: this WAN's rows, plus + // null-WanInterface hand-added rows for the primary only), so no per-row WAN check remains - + // a failover link's destinations can't manufacture breadth here because they never enter + // `targets` at all. var breadthInternet = targets .Where(t => t.TargetType == MonitoringTargetType.InternetService - && internetSeriesExt.ContainsKey(t.TargetId) - && (string.IsNullOrEmpty(t.WanInterface) - || string.Equals(t.WanInterface, primaryWanKey, StringComparison.OrdinalIgnoreCase))) + && internetSeriesExt.ContainsKey(t.TargetId)) .Select(t => new AsnSeries { AsnNumber = t.AsnNumber ?? 0, @@ -1184,7 +1292,7 @@ string TransitLabel(AsnSeries s) var blackoutSpans = outages.Where(o => !o.IsPartial).Select(o => (o.Start, o.End)).ToList(); double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => Math.Max(0, (new DateTime(Math.Min(e.Ticks, b.End.Ticks)) - new DateTime(Math.Max(s.Ticks, b.Start.Ticks))).TotalSeconds)); - var unreachableEvents = TransitUnreachableDetector.MergeByAsn(transitDarkWindows, _options) + var unreachableEvents = TransitUnreachableDetector.MergeByAsn(darkWindows, _options) .Where(e => OverlapSeconds(e.Start, e.End) < (e.End - e.Start).TotalSeconds * 0.5) .Select(e => new PathShiftEvent { @@ -1228,9 +1336,11 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => // chartClusters (one line per cluster) is the chart view computed from the same // snapshot the detectors ran on, so deeper-cluster "+N ms hop" labels still match // event labels. It is published together with the report (see Snapshot). - var primaryWanInterface = await GetPrimaryWanInterfaceAsync(ct); - var loadExclusions = await BuildSqmProbeExclusionsAsync(windowStart, windowEnd, primaryWanInterface, ct); - var adaptiveSqmEnabled = await IsAdaptiveSqmEnabledAsync(primaryWanInterface, ct); + // SQM probe exclusions and the Adaptive SQM flag key off the SCORED WAN's own + // data-path interface (SqmWanConfigurations rows are per interface). + var scoredDataPathInterface = await GetScoredWanDataPathInterfaceAsync(ct); + var loadExclusions = await BuildSqmProbeExclusionsAsync(windowStart, windowEnd, scoredDataPathInterface, ct); + var adaptiveSqmEnabled = await IsAdaptiveSqmEnabledAsync(scoredDataPathInterface, ct); // Match the WAN's access technology to one monitored physical device (ONT/SFP, cable // modem, or cellular modem) and aggregate its window metrics for the Physical Link factor. @@ -1266,9 +1376,7 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => HopOrderKnown = hopOrderKnown, // Hops with a discovery row but HopNumber 0 answered pings yet never landed in a trace // (OLT/CMTS ICMP-deprioritization); only meaningful once we have trace data at all. - NotTracedTargetIds = hopOrderKnown - ? hopNumberByTargetId.Where(kv => kv.Value == 0).Select(kv => kv.Key).ToHashSet(StringComparer.OrdinalIgnoreCase) - : new HashSet(StringComparer.OrdinalIgnoreCase), + NotTracedTargetIds = notTracedTargetIds, LoadExclusionWindows = loadExclusions, PhysicalLink = physical.Input }; @@ -1302,7 +1410,7 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => } /// - /// Whether the primary WAN carries its traffic over a PPPoE session, read from the gateway's + /// Whether the scored WAN carries its traffic over a PPPoE session, read from that WAN's /// data-path interface name (uplink_ifname) - "ppp0" is a PPPoE session and nothing else. /// Cheap: the underlying device call is already cached. /// @@ -1319,28 +1427,157 @@ double OverlapSeconds(DateTime s, DateTime e) => blackoutSpans.Sum(b => // Through the resolver, not the console directly: PPPoE is read off the interface NAME, // and the remembered profile holds that name, so an offline site keeps its overlay // instead of silently grading a PPPoE line against its medium's raw thresholds. - var dataPath = await GetPrimaryWanInterfaceAsync(ct); + var dataPath = await GetScoredWanDataPathInterfaceAsync(ct); if (string.IsNullOrEmpty(dataPath)) { - _logger.LogWarning("ISP Health could not resolve the primary WAN's data-path interface; " + + _logger.LogWarning("ISP Health could not resolve the scored WAN's data-path interface; " + "scoring without the PPPoE overlay, so a PPPoE line will grade against its medium's " + "unadjusted thresholds until the next recompute"); return null; } var isPppoe = NetworkUtilities.IsPppoeInterface(dataPath); - _logger.LogDebug("ISP Health: primary WAN data-path interface is {Interface}; PPPoE overlay {Applied}", + _logger.LogDebug("ISP Health: scored WAN data-path interface is {Interface}; PPPoE overlay {Applied}", dataPath, isPppoe ? "applied" : "not applicable"); return isPppoe; } catch (Exception ex) { - _logger.LogWarning(ex, "ISP Health could not resolve the primary WAN's data-path interface; " + + _logger.LogWarning(ex, "ISP Health could not resolve the scored WAN's data-path interface; " + "scoring without the PPPoE overlay until the next recompute"); return null; } } + /// + /// Scoping helpers, static and internal so the single-WAN equivalence tests exercise the + /// exact predicates the compute uses. + /// + /// + /// Rows with no WAN stamped (hand-added targets, rows predating per-WAN discovery) belong to + /// the primary: they were always primary-path measurements, and dropping them would shrink + /// the pool on exactly the installs that curated it. A scoped WAN owns only rows stamped + /// with its own key. + /// + internal static List ScopeTargetsToWan( + List targets, string wanKey, bool includeUnassigned) => + // Keys normalized ("wan1" == "wan"): legacy installs stamped rows with the wan1 alias, + // and an unnormalized comparison would silently drop them from their own report. + targets.Where(t => string.IsNullOrEmpty(t.WanInterface) + ? includeUnassigned + : string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(t.WanInterface), + GatewayWanHelper.WanInterfaceKeyFromKey(wanKey), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + /// + /// The configured primary WAN's key from a resolved networkconf row ("WAN2" -> "wan2"), or + /// null when there is none to read. Primary is a ROLE, not a name: any wanN group can be the + /// configured primary (failover priority / load-balance weight decide), so this - never a + /// name-ordered guess - is the authoritative answer while the console can be asked. + /// + internal static string? ConfiguredPrimaryWanKey(NetworkInfo? primary) => + string.IsNullOrEmpty(primary?.WanNetworkgroup) + ? null : GatewayWanHelper.WanInterfaceKeyFromKey(primary!.WanNetworkgroup!); + + /// Configured primary key from the console; null when it cannot be asked. + private async Task ResolveConfiguredPrimaryWanKeyAsync(CancellationToken ct) + { + try + { + return ConfiguredPrimaryWanKey(await _connectionService.GetPrimaryWanNetworkAsync(ct)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health could not resolve the configured primary WAN from the console"); + return null; + } + } + + /// + /// LAST-RESORT GUESS at the primary's wan key, for when the console cannot say which WAN + /// holds the primary role: the conventional "wan"-group discovery row first, then any row, + /// defaulting to "wan" with no rows at all. This is wrong exactly on an offline multi-WAN + /// site whose configured primary is another group (WAN2-primary with a WAN1 failover) - + /// there is nothing better to ask offline, and the next connected compute corrects it. + /// Callers must prefer whenever the console answers. + /// + internal static string ResolvePrimaryWanKey(IEnumerable contexts) => + GatewayWanHelper.WanInterfaceKeyFromKey(contexts + .OrderBy(c => string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface ?? ""), "wan", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .Select(c => c.WanInterface) + .FirstOrDefault(w => !string.IsNullOrEmpty(w)) ?? "wan"); + + /// + /// The Influx wan-tag scope for the WAN being scored. Primary: untagged points (every point + /// the primary path has ever written), plus the tag values of any context bound to the + /// primary WAN - so a primary probed through an explicit context keeps those points too. + /// Scoped WAN: its stable wan key (what the writers tag new points with, + /// WanContext.InfluxWanTag) plus its contexts' display names, which tagged the points + /// written before the stable-key tagging landed. Never untagged - untagged is the primary's. + /// + internal static MonitoringInfluxClient.LatencyWanScope BuildWanScope( + IEnumerable contexts, string wanKey, bool primaryScope) + { + // Context match is key-normalized ("wan1" == "wan"), but the TAG VALUES stay raw: points + // were written with each context's literal InfluxWanTag, so a legacy wan1-keyed context + // contributes the "wan1" tag its points actually carry. The scoped WAN's own normalized + // key is added for points the writers tag going forward. Note the wanKey parameter is + // whatever key the caller RESOLVED (configured primary or scoped key) - never a literal. + var normalizedKey = GatewayWanHelper.WanInterfaceKeyFromKey(wanKey); + var tags = contexts + .Where(c => !string.IsNullOrEmpty(c.WanInterface) && string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(c.WanInterface!), normalizedKey, StringComparison.OrdinalIgnoreCase)) + .SelectMany(c => new[] { c.InfluxWanTag, c.Name }) + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (primaryScope) + return MonitoringInfluxClient.LatencyWanScope.Primary(tags); + if (!tags.Contains(normalizedKey, StringComparer.Ordinal)) + tags.Insert(0, normalizedKey); + return MonitoringInfluxClient.LatencyWanScope.ForWan(tags); + } + + /// + /// The scored WAN's data-path interface: the primary resolver for the primary instance + /// (unchanged, incl. its remembered-profile offline fallback), the WAN's own uplink for a + /// scoped instance - live from the console when connected, from the WAN's remembered + /// profile row when not. + /// + private async Task GetScoredWanDataPathInterfaceAsync(CancellationToken ct) + { + if (_scopedWanKey == null) + return await GetPrimaryWanInterfaceAsync(ct); + + var group = GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey); + try + { + var ifaces = await _connectionService.GetWanInterfacesForGroupAsync(group, ct); + var live = ifaces?.UplinkIfName ?? ifaces?.PhysicalIfName; + if (!string.IsNullOrEmpty(live)) return live; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN {Group}'s data-path interface from the console", group); + } + if (_connectionService.IsConnected) return null; + try + { + await using var db = await CreateSiteDbAsync(ct); + return await db.WanProfiles.AsNoTracking() + .Where(w => w.WanNetworkgroup == group && w.DataPathInterface != null) + .OrderByDescending(w => w.UpdatedAt) + .Select(w => w.DataPathInterface) + .FirstOrDefaultAsync(ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read WAN {Group}'s remembered data-path interface", group); + return null; + } + } + /// /// Per-ASN RTT series for the tab chart (ISP + transit) plus the report's events for chart /// annotations. With no window it serves the cached 48 h report; with an explicit window @@ -1393,32 +1630,136 @@ private async Task> QueryWanRatesAsync(DateTime from, Dat } /// - /// Resolves the gateway MAC and the CONFIGURED primary WAN's SNMP counter interface(s) - the same - /// WAN as the expected speeds and SQM exclusion (e.g. "eth6" for a VLAN-tagged primary), not the - /// live active uplink. Falls back to the active uplink only if the config-primary can't be - /// resolved, so analysis still runs. Returns (null, null) when no gateway is discovered. + /// Resolves the gateway MAC and the SCORED WAN's SNMP counter interface(s) - the same WAN as + /// the expected speeds and SQM exclusion (e.g. "eth6" for a VLAN-tagged WAN). The pairing is + /// the point: these counters are divided by this WAN's plan speeds, and that load figure sets + /// the Packet Loss ceiling quadratically (ScorePacketLoss), splits loaded from idle samples + /// (LoadClassifier), and drives congestion load-coincidence (CongestionTopology.Load) - so + /// another WAN's counters here mis-grade all three at once. + /// + /// Primary instance: configured primary live, then the primary's remembered profile row + /// (same WAN, cached), then the live active uplink as the last resort so analysis still + /// runs - that last step is the one place bytes can come from a different WAN than the + /// plan speeds, and it is logged as such. Scoped instance: that WAN's own counter interface + /// (live, then its profile row) and NOTHING cross-WAN - no active-uplink, no WAN1 fallback. /// private async Task<(string? Mac, List? IfNames)> ResolveWanCounterAsync(CancellationToken ct) { var devices = await _connectionService.GetDiscoveredDevicesAsync(ct); var gw = devices?.FirstOrDefault(d => d.Type == DeviceType.Gateway || d.HardwareType == DeviceType.Gateway); + + if (_scopedWanKey != null) + { + var group = GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey); + string? counter = null; + try + { + var ifaces = await _connectionService.GetWanInterfacesForGroupAsync(group, ct); + counter = ifaces?.CounterIfName; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not resolve WAN {Group}'s counter interface from the console", group); + } + var mac = gw?.Mac; + if (string.IsNullOrEmpty(counter) || string.IsNullOrEmpty(mac)) + { + try + { + await using var db = await CreateSiteDbAsync(ct); + var profile = await db.WanProfiles.AsNoTracking() + .Where(w => w.WanNetworkgroup == group) + .OrderByDescending(w => w.UpdatedAt) + .FirstOrDefaultAsync(ct); + counter = string.IsNullOrEmpty(counter) ? profile?.CounterInterface : counter; + mac = string.IsNullOrEmpty(mac) ? profile?.GatewayMac : mac; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not read WAN {Group}'s remembered counter interface", group); + } + } + if (string.IsNullOrEmpty(mac) || string.IsNullOrEmpty(counter)) + { + _logger.LogDebug("ISP Health: no counter interface resolved for WAN {Group}; load context is empty for this report", group); + return (mac, null); + } + return (mac, new List { counter! }); + } + if (gw?.Mac == null) return (null, null); var primaryIfaces = await _connectionService.GetPrimaryWanInterfacesAsync(ct); var wanCounterNames = !string.IsNullOrEmpty(primaryIfaces?.CounterIfName) ? new List { primaryIfaces!.CounterIfName! } - : gw.WanInterfaceNames; + : null; + // Config-primary unresolved: prefer the primary's own remembered counter interface (same + // WAN, merely cached) before the live active uplink - during a failover the active uplink + // is ANOTHER WAN, and its bytes against the primary's plan speeds understate load, which + // relaxes into the strictest idle loss ceiling. The active uplink stays as the very last + // resort so a site that never resolved a primary still gets load context. + if (wanCounterNames == null) + { + try + { + // Prefer the CONFIGURED primary group's remembered row when the console can + // still say which group holds the primary role; the first-by-group-name pick is + // the last resort and is a documented GUESS - on a WAN2-primary site with a WAN1 + // failover row it returns the failover's counter. Nothing better exists offline + // (WanProfile carries no primary marker); the next connected read corrects it. + var cfgGroup = await ResolveConfiguredPrimaryWanKeyAsync(ct) is { } cfgKey + ? GatewayWanHelper.WanNetworkGroupFromKey(cfgKey) : null; + await using var db = await CreateSiteDbAsync(ct); + var remembered = await db.WanProfiles.AsNoTracking() + .Where(w => w.CounterInterface != null && (cfgGroup == null || w.WanNetworkgroup == cfgGroup)) + .OrderBy(w => w.WanNetworkgroup) + .ThenByDescending(w => w.UpdatedAt) + .Select(w => w.CounterInterface) + .FirstOrDefaultAsync(ct); + if (!string.IsNullOrEmpty(remembered)) + { + _logger.LogDebug("ISP Health: primary WAN unresolved, using its remembered counter interface {Iface}", remembered); + wanCounterNames = new List { remembered! }; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not read the remembered primary counter interface"); + } + } + wanCounterNames ??= gw.WanInterfaceNames; if (wanCounterNames == null || wanCounterNames.Count == 0) { _logger.LogDebug("ISP Health: no WAN counter interface resolved"); return (gw.Mac, null); } - if (primaryIfaces?.CounterIfName == null) - _logger.LogDebug("ISP Health: primary WAN unresolved, falling back to active uplink {Ifaces}", string.Join(",", wanCounterNames)); + if (primaryIfaces?.CounterIfName == null && ReferenceEquals(wanCounterNames, gw.WanInterfaceNames)) + _logger.LogDebug("ISP Health: primary WAN unresolved, falling back to active uplink {Ifaces} - " + + "during a failover these are another WAN's counters paired with the primary's plan speeds", + string.Join(",", wanCounterNames)); return (gw.Mac, wanCounterNames); } + /// + /// The scored WAN's counter pairing (gateway MAC + counter interface names) for callers + /// outside the scoring pipeline - the Investigate loaded-loss lookup and the WAN traffic + /// reference - so they classify loaded-vs-idle against the same WAN whose latency they show. + /// + public async Task<(string? GatewayMac, List CounterIfNames)> GetWanCounterInterfacesAsync(CancellationToken ct = default) + { + try + { + var (mac, ifNames) = await ResolveWanCounterAsync(ct); + return (mac, ifNames ?? new List()); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health could not resolve the scored WAN's counter interfaces"); + return (null, new List()); + } + } + /// /// Hour-of-day usage fingerprint from the WAN throughput we already record (no new measurement): /// per local hour-of-day, the fraction of sampled time the line was actively in use (DS/US above @@ -1433,13 +1774,21 @@ private async Task> QueryWanRatesAsync(DateTime from, Dat if (!_options.UsageWeightingEnabled) return null; try { - var (mac, ifNames) = await ResolveWanCounterAsync(ct); - if (mac == null || ifNames == null || ifNames.Count == 0) return null; + // ALL WANs, deliberately - the one input that widens across WANs. The fingerprint asks + // "was the user doing anything in this hour", not "how loaded is the link being graded": + // an hour carried by a secondary WAN is still an hour the user was active, so it must + // not read idle and soften that hour's outage weighting. Identical across the per-WAN + // instances by construction. The summed multi-interface read is opted into explicitly + // (see QueryGatewayWanRatesAsync's contract); with one WAN the list has one name and + // the query is byte-identical to before. + var (mac, ifNames) = await ResolveAllWanCounterInterfacesAsync(ct); + if (mac == null || ifNames.Count == 0) return null; var from = windowEnd.AddDays(-_options.UsageFingerprintLookbackDays); // Active usage is sustained (streaming, calls, uploads); a 5-min mean is plenty to catch // it and keeps the lookback series small. - var rates = await _influx.QueryGatewayWanRatesAsync(mac, ifNames, from, windowEnd, TimeSpan.FromMinutes(5), ct: ct); + var rates = await _influx.QueryGatewayWanRatesAsync(mac, ifNames, from, windowEnd, TimeSpan.FromMinutes(5), + sumAcrossInterfaces: true, ct: ct); if (rates.Count == 0) return null; var tz = TimeZoneInfo.Local; @@ -1473,6 +1822,45 @@ private async Task> QueryWanRatesAsync(DateTime from, Dat } } + /// + /// Gateway MAC plus EVERY WAN's counter interface, for the all-WAN usage fingerprint only + /// (see the summing contract on QueryGatewayWanRatesAsync). Live enumeration when the + /// console answers, augmented by the remembered per-WAN profile rows so WANs the console + /// currently omits (down, disabled) still contribute their recorded usage. + /// + private async Task<(string? Mac, List IfNames)> ResolveAllWanCounterInterfacesAsync(CancellationToken ct) + { + string? mac = null; + var names = new List(); + try + { + var devices = await _connectionService.GetDiscoveredDevicesAsync(ct); + mac = devices?.FirstOrDefault(d => d.Type == DeviceType.Gateway || d.HardwareType == DeviceType.Gateway)?.Mac; + foreach (var wan in await _connectionService.GetAllWanInterfacesAsync(ct)) + if (!string.IsNullOrEmpty(wan.CounterIfName)) + names.Add(wan.CounterIfName!); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not enumerate WAN counter interfaces from the console"); + } + try + { + await using var db = await CreateSiteDbAsync(ct); + var profiles = await db.WanProfiles.AsNoTracking() + .Where(w => w.CounterInterface != null) + .ToListAsync(ct); + foreach (var p in profiles) + names.Add(p.CounterInterface!); + mac ??= profiles.Select(p => p.GatewayMac).FirstOrDefault(m => !string.IsNullOrEmpty(m)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ISP Health: could not read the remembered WAN profiles for the usage fingerprint"); + } + return (mac, names.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + /// Expected plan speeds for callers outside the scoring pipeline (e.g. loaded-loss investigation). public async Task<(double? DownMbps, double? UpMbps)> GetExpectedWanSpeedsAsync(CancellationToken ct = default) { @@ -1496,6 +1884,13 @@ public async Task> GetLossPoolTargetIdsAsync(CancellationToken ct = || t.TargetType == MonitoringTargetType.Transit || t.TargetType == MonitoringTargetType.InternetService)) .ToListAsync(ct); + // Same WAN scope as the compute (ScopeTargetsToWan there), so the Investigate highlight + // averages exactly the pool this instance's score is graded on - configured primary + // first, name-ordered guess only offline, like the compute. + var scoredKey = _scopedWanKey + ?? await ResolveConfiguredPrimaryWanKeyAsync(ct) + ?? ResolvePrimaryWanKey(await db.WanDiscoveryContexts.AsNoTracking().ToListAsync(ct)); + targets = ScopeTargetsToWan(targets, scoredKey, includeUnassigned: _scopedWanKey == null); // Flat-lined targets the last computed report dropped come out here too. Subtracting from the // report rather than re-deriving it keeps this the single definition: the exclusion is a // measurement judgment and this method only reads the database, so it cannot make it itself. @@ -1525,17 +1920,25 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface string? source = null; var smartQueues = false; WanIdentity? wan = null; + // A scoped instance reads ITS WAN's networkconf row; the primary instance keeps the + // configured-primary resolution unchanged. There is deliberately no cross-WAN fallback + // anywhere below: a WAN whose plan the console never reported ends unscored on Speed vs + // Plan rather than graded against another WAN's plan. + var scopedGroup = _scopedWanKey == null ? null : GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey); try { var networks = await _connectionService.GetNetworksAsync(ct); - var primary = UniFiConnectionService.ResolvePrimaryWanNetwork(networks, _logger); - if (primary != null) + var net = scopedGroup == null + ? UniFiConnectionService.ResolvePrimaryWanNetwork(networks, _logger) + : networks.FirstOrDefault(n => n.IsWan && n.Enabled + && string.Equals(n.WanNetworkgroup, scopedGroup, StringComparison.OrdinalIgnoreCase)); + if (net != null) { - if (primary.WanDownloadMbps > 0) down = primary.WanDownloadMbps; - if (primary.WanUploadMbps > 0) up = primary.WanUploadMbps; + if (net.WanDownloadMbps > 0) down = net.WanDownloadMbps; + if (net.WanUploadMbps > 0) up = net.WanUploadMbps; if (down != null || up != null) source = "UniFi Network"; - smartQueues = primary.WanSmartqEnabled; - wan = new WanIdentity(primary.Name, primary.WanNetworkgroup, primary.WanIfname); + smartQueues = net.WanSmartqEnabled; + wan = new WanIdentity(net.Name, net.WanNetworkgroup, net.WanIfname); } } catch (Exception ex) @@ -1545,8 +1948,7 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface // Remember what the console said, per WAN. This is what lets a site whose console has gone // away still be graded, and it is stored per WAN because plan speeds belong to a WAN: - // scoring reads the primary today, and multi-WAN scoring is planned, at which point each - // WAN's row is already here. + // every scored WAN writes its own row here, keyed by WanNetworkgroup. if (wan?.NetworkGroup is { Length: > 0 }) await RememberWanSpeedsAsync(wan, down, up, ct); @@ -1559,9 +1961,13 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface // where the SQM value is a shaping target someone typed in - what to rate-limit to, // not what the ISP confirmed the line does. // - // With no console we cannot ask which WAN is primary, so prefer the first WAN group and - // fall back to the most recently confirmed row. Multi-WAN scoring picks its own WAN here. + // Primary with no console: we cannot ask which WAN holds the primary ROLE (WanProfile + // carries no primary marker), so the first-by-group-name row is a documented GUESS - + // on a WAN2-primary site whose WAN1 failover also has a remembered row, it grades + // against the failover's plan until the console comes back. A scoped instance reads + // exactly its own WAN's row - another WAN's row is never an answer. var remembered = await db.WanProfiles.AsNoTracking() + .Where(w => scopedGroup == null || w.WanNetworkgroup == scopedGroup) .OrderBy(w => w.WanNetworkgroup) .ThenByDescending(w => w.UpdatedAt) .FirstOrDefaultAsync(ct); @@ -1575,9 +1981,13 @@ private record WanIdentity(string? Name, string? NetworkGroup, string? Interface } // Truly inferred, so it goes last: only reached when the console has never told us. + // The primary keeps the lowest-numbered row (unchanged); a scoped WAN matches its + // own WAN number and otherwise stays unscored. if (down == null || up == null) { + var scopedWanNumber = _scopedWanKey == null ? 0 : GatewayWanHelper.WanIndexFromKey(_scopedWanKey); var sqmWan = await db.SqmWanConfigurations.AsNoTracking() + .Where(c => scopedWanNumber == 0 || c.WanNumber == scopedWanNumber) .OrderBy(c => c.WanNumber) .FirstOrDefaultAsync(ct); if (sqmWan != null) @@ -1613,9 +2023,15 @@ private async Task RememberWanSpeedsAsync(WanIdentity wan, double? down, double? // Keep the previous data path when the device read comes back empty on an otherwise // successful console read: overwriting it with the physical port would make a later // offline PPPoE check grade the line without its overlay, which is what splitting these - // two columns exists to prevent. - var dataPath = await _connectionService.GetPrimaryWanDataPathInterfaceAsync(ct) - ?? row.DataPathInterface ?? wan.Interface; + // two columns exists to prevent. Scoped instances resolve THEIR WAN's data path; the + // primary keeps the primary resolver. + var liveDataPath = _scopedWanKey == null + ? await _connectionService.GetPrimaryWanDataPathInterfaceAsync(ct) + : (await _connectionService.GetWanInterfacesForGroupAsync( + GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey), ct)) is { } scopedIfaces + ? scopedIfaces.UplinkIfName ?? scopedIfaces.PhysicalIfName + : null; + var dataPath = liveDataPath ?? row.DataPathInterface ?? wan.Interface; row.DataPathInterface = dataPath; row.CounterInterface = NetworkUtilities.PreferredWanCounterInterface(wan.Interface, dataPath); @@ -1631,6 +2047,26 @@ private async Task RememberWanSpeedsAsync(WanIdentity wan, double? down, double? row.DownloadMbps = down; row.UploadMbps = up; row.UpdatedAt = DateTime.UtcNow; + + // Record which WAN holds the primary role, and whether the site load balances, while + // a console is answering. Both are read where no console can be reached - the probe + // push path has none at all - and both are otherwise guessed from the WAN's NAME, + // which carries no role information. Exactly one row may claim primary, so the others + // are cleared in the same save rather than left to accumulate stale claims. + var networks = await _connectionService.GetNetworksAsync(ct); + var primaryGroup = UniFiConnectionService.ResolvePrimaryWanNetwork(networks)?.WanNetworkgroup; + if (!string.IsNullOrEmpty(primaryGroup)) + { + var loadBalances = UniFiConnectionService.ResolveSiteLoadBalances(networks); + foreach (var profile in await db.WanProfiles.ToListAsync(ct)) + { + profile.IsPrimary = string.Equals( + profile.WanNetworkgroup, primaryGroup, StringComparison.OrdinalIgnoreCase); + profile.SiteLoadBalances = loadBalances; + } + row.IsPrimary = string.Equals(row.WanNetworkgroup, primaryGroup, StringComparison.OrdinalIgnoreCase); + row.SiteLoadBalances = loadBalances; + } await db.SaveChangesAsync(ct); } catch (Exception ex) @@ -1646,8 +2082,10 @@ private async Task RememberWanSpeedsAsync(WanIdentity wan, double? down, double? /// offline site still resolves it - the interface is what the throughput series are keyed on, /// so without it a site with plenty of stored history reads as having none. /// - /// Multi-WAN planned: this returns the primary only, and picks the first WAN group when there - /// is no console to ask. Per-WAN scoring resolves its own WAN's interface from its own row. + /// The offline pick is first-by-group-name, a documented GUESS: primary is a role, so on an + /// offline WAN2-primary site with a WAN1 failover row this returns the failover's data path + /// (WanProfile carries no primary marker to prefer). Per-WAN scoring resolves its own WAN's + /// interface from its own row and never lands here. /// private async Task GetPrimaryWanInterfaceAsync(CancellationToken ct) { @@ -1765,7 +2203,25 @@ private async Task> LoadWanSpeedTestsAsync(DateTime window // yields a recent capacity number. Bounded above by windowEnd for historical windows. var fallbackStart = windowEnd.AddDays(-_options.SpeedTestFallbackDays); var since = windowStart < fallbackStart ? windowStart : fallbackStart; + // Tests are attributed to the scored WAN by their recorded WAN group. A scoped WAN + // takes only tests stamped with its own group, never unstamped ones - an unstamped + // test ran over the default route, which is the primary's. + var scopedGroupLower = _scopedWanKey == null + ? null + : GatewayWanHelper.WanNetworkGroupFromKey(_scopedWanKey).ToLowerInvariant(); await using var db = await CreateSiteDbAsync(ct); + + // The primary's own group, when a connected compute has recorded which WAN holds the + // role. Without it the predicate below falls back to the conventional first group, + // which is right on the sites that have one WAN or lead with WAN1 and wrong on a site + // whose primary is WAN2 - there it would miss every test stamped "WAN2" and count the + // FAILOVER link's tests as the primary's, grading a backup circuit against the fiber + // plan. Unstamped rows stay in either way: they predate stamping and ran over the + // default route, which is the primary's by definition. + var primaryGroupLower = scopedGroupLower != null + ? null + : (await db.WanProfiles.AsNoTracking() + .FirstOrDefaultAsync(w => w.IsPrimary == true, ct))?.WanNetworkgroup?.ToLowerInvariant(); var results = await db.Iperf3Results.AsNoTracking() .Where(r => r.Success && r.TestTime >= since @@ -1774,7 +2230,10 @@ private async Task> LoadWanSpeedTestsAsync(DateTime window || r.Direction == SpeedTestDirection.CloudflareWanGateway || r.Direction == SpeedTestDirection.UwnWan || r.Direction == SpeedTestDirection.UwnWanGateway) - && (r.WanNetworkGroup == null || r.WanNetworkGroup.ToLower() == "wan")) + && (scopedGroupLower == null + ? (r.WanNetworkGroup == null + || r.WanNetworkGroup.ToLower() == (primaryGroupLower ?? "wan")) + : r.WanNetworkGroup != null && r.WanNetworkGroup.ToLower() == scopedGroupLower)) .OrderByDescending(r => r.TestTime) .Select(r => new { r.TestTime, r.DownloadBitsPerSecond, r.UploadBitsPerSecond, r.PingMs, r.DownloadLatencyMs, r.UploadLatencyMs }) .ToListAsync(ct); diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs index aa7a6c9454..7441be01bd 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkModels.cs @@ -131,6 +131,14 @@ public class PhysicalLinkInput public double? DishDropRateMax { get; init; } /// Dish-logged outage seconds over the window, normalized per day via . + /// + /// The dish reports its throughput capped by the PLAN rather than by the link - a + /// reduced-speed tier such as Standby. Ground truth for "this is slow on purpose", which + /// otherwise has to be inferred from expected speeds sitting at the lowest value UniFi + /// Network accepts. + /// + public bool? ReducedSpeedTier { get; init; } + public double? OutageSecondsTotal { get; init; } /// Dish-logged outage count over the window. diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs index 50dec45914..9ca161b10f 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/PhysicalLinkResolver.cs @@ -439,6 +439,13 @@ private static bool IsFresh(DateTime? lastPolled, int intervalSeconds) => CurrentlyObstructed = live?.CurrentlyObstructed, DishDropRateAvg = dropAvg, DishDropRateMax = dropMax > 0 ? dropMax : null, + // LowSpeedPolicyLimit is the plan itself being a reduced-speed tier, as the Starlink + // panel already reads it. Plain PolicyLimit is ordinary shaping on nearly every plan + // and says nothing, so it is deliberately not treated as one. + ReducedSpeedTier = live is null + ? null + : live.DownlinkRestrictedReason == "LowSpeedPolicyLimit" + || live.UplinkRestrictedReason == "LowSpeedPolicyLimit", OutageSecondsTotal = outageSeconds, OutageCountTotal = outageCount, SnrPersistentlyLow = live?.IsSnrPersistentlyLow, diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs index 665f4e4aeb..e8d2092ccd 100644 --- a/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs +++ b/src/NetworkOptimizer.Web/Services/Monitoring/IspHealth/SeriesStats.cs @@ -19,6 +19,199 @@ internal static class SeriesStats return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo); } + /// + /// Median with each value weighted, taken at the point where half the total weight has + /// accumulated. Still a median - one wild value cannot drag it the way a weighted mean can - + /// but a heavier sample counts for more of the half. + /// + /// Used where recent evidence should outrank old evidence of the same kind: a plain median + /// over a week-long window treats a measurement from an hour ago exactly like one from six + /// days ago, so a line that was fixed this afternoon keeps reporting the fault until the good + /// samples outnumber the bad ones. + /// + /// + /// + /// Collapses simultaneous samples from different series into one value per instant, weighing + /// the elevation by how much of the cohort CORROBORATED it. + /// + /// Congestion on a link is in front of everything crossing it, so a real access-layer queue + /// lights up most of what reported in that second. One hop rising while the rest read clean at + /// the same instant is that hop's own responder, and the clean readings beside it are the + /// proof - proof a flat pool throws away, because the noise floor discards them before the + /// median ever sees them. + /// + /// + /// Magnitude comes from the series that actually saw it, and only the CREDENCE scales with the + /// cohort. Collapsing magnitude across the whole cohort instead made the number fall as more + /// targets were monitored - a WAN watching 28 targets diluted a genuine 8 ms to a third of a + /// millisecond, and far destinations swinging below their own baseline cancelled what was left. + /// Monitoring more would have scored better, which is backwards. + /// + /// + /// The denominator is what REPORTED in this instant, never the cohort's full size: targets do + /// not all probe on the same cadence, and one that said nothing has not said "clean". + /// + /// + public static List<(DateTime Time, double Value)> CommonModeByInstant( + IReadOnlyList<(DateTime Time, double Value, int Series)> samples, + TimeSpan tolerance, + int minCohort, + double elevationFloor) + { + var result = new List<(DateTime Time, double Value)>(); + if (samples.Count == 0) return result; + + var ordered = samples.OrderBy(s => s.Time).ToList(); + var i = 0; + while (i < ordered.Count) + { + var start = ordered[i].Time; + var j = i; + while (j < ordered.Count && ordered[j].Time - start <= tolerance) j++; + + var cluster = ordered.GetRange(i, j - i); + var reporting = cluster.Select(c => c.Series).Distinct().Count(); + if (reporting >= minCohort) + { + var elevated = cluster.Where(c => c.Value >= elevationFloor).ToList(); + // Nothing elevated is not "no reading" - it is every target that reported saying + // the link was fine, which is the strongest clean evidence there is. + var corroboration = (double)elevated.Select(c => c.Series).Distinct().Count() / reporting; + result.Add((start, elevated.Count == 0 ? 0 : elevated.Average(c => c.Value) * corroboration)); + } + else + { + // Nothing to corroborate against. A short event where one hop happened to be the + // only one probed is still evidence, just uncorroborated evidence. + result.AddRange(cluster.Select(c => (c.Time, c.Value))); + } + + i = j; + } + + return result; + } + + public static double? WeightedMedian(IReadOnlyList<(double Value, double Weight)> samples) + { + var usable = samples.Where(s => s.Weight > 0).OrderBy(s => s.Value).ToArray(); + if (usable.Length == 0) return null; + + var half = usable.Sum(s => s.Weight) / 2.0; + var running = 0.0; + foreach (var (value, weight) in usable) + { + running += weight; + if (running >= half) return value; + } + return usable[^1].Value; + } + + /// + /// Weighted arithmetic mean. Used where the quantity is naturally averaged - loss is a rate, + /// and a median over mostly-zero samples reports zero however bad the rest are. + /// + public static double? WeightedMean(IReadOnlyList<(double Value, double Weight)> samples) + { + var total = 0.0; + var weight = 0.0; + foreach (var (value, w) in samples) + { + if (w <= 0) continue; + total += value * w; + weight += w; + } + return weight > 0 ? total / weight : null; + } + + /// + /// How long the run of consecutive loaded windows containing each window lasted, in seconds. + /// + /// Duration is credibility, not just sample count. A short burst is where load classification + /// goes wrong most often, and it is too brief for buffers to fill, so its latency understates + /// what a full pipe does - weak evidence twice over. A long saturation is the best evidence + /// there is, better than a speed test, which is itself short and synthetic. + /// + /// + public static Dictionary LoadEpisodeSeconds( + IEnumerable loadedWindowKeys, int windowSeconds) + { + var size = Math.Max(1, windowSeconds); + var ordered = loadedWindowKeys.Distinct().OrderBy(t => t).ToList(); + var seconds = new Dictionary(); + for (var i = 0; i < ordered.Count;) + { + var run = 1; + while (i + run < ordered.Count + && (ordered[i + run] - ordered[i + run - 1]).TotalSeconds <= size + 0.001) + { + run++; + } + var episode = run * (double)size; + for (var j = 0; j < run; j++) seconds[ordered[i + j]] = episode; + i += run; + } + return seconds; + } + + /// + /// The start time of the run of consecutive loaded windows each window belongs to, so samples + /// can be grouped by EPISODE rather than by window. A window is seven seconds; an episode is + /// however long the line actually stayed loaded, which is the unit a person would call "a load + /// event" and the only one at which "the last three" means anything. + /// + public static Dictionary LoadEpisodeStarts( + IEnumerable loadedWindowKeys, int windowSeconds) + { + var size = Math.Max(1, windowSeconds); + var ordered = loadedWindowKeys.Distinct().OrderBy(t => t).ToList(); + var starts = new Dictionary(); + for (var i = 0; i < ordered.Count;) + { + var run = 1; + while (i + run < ordered.Count + && (ordered[i + run] - ordered[i + run - 1]).TotalSeconds <= size + 0.001) + { + run++; + } + for (var j = 0; j < run; j++) starts[ordered[i + j]] = ordered[i]; + i += run; + } + return starts; + } + + /// + /// A credibility multiplier that rises to 1 as a measure approaches the level at which it is + /// fully believable, and never falls below - weak evidence is not + /// absent evidence. A non-positive target means "cannot judge", which is 1 throughout. + /// + public static double Credibility(double measured, double fullAt, double floor) + => fullAt <= 0 ? 1 : Math.Clamp(measured / fullAt, floor, 1); + + /// + /// The same over a BAND: nothing earned below , everything earned at + /// . For measures whose interesting range does not begin at zero - a + /// ramp from zero would score every value near the top and separate nothing. + /// + public static double CredibilityBetween(double measured, double start, double fullAt, double floor) + { + var span = fullAt - start; + return span <= 0 + ? Credibility(measured, fullAt, floor) + : Math.Clamp((measured - start) / span, floor, 1); + } + + /// + /// Weight for a sample of a given age, halving every . Zero or + /// negative half-life means no decay at all, which is how a caller opts out. + /// + public static double RecencyWeight(TimeSpan age, double halfLifeHours) + { + if (halfLifeHours <= 0) return 1; + var hours = Math.Max(0, age.TotalHours); + return Math.Pow(0.5, hours / halfLifeHours); + } + /// /// Mean after winsorizing the upper tail: values above the given percentile are capped /// to it, then averaged. Keeps sustained elevation fully visible (those samples sit diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs b/src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs new file mode 100644 index 0000000000..8635b5c477 --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/LiveWanScope.cs @@ -0,0 +1,345 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.JSInterop; +using NetworkOptimizer.Core.Helpers; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// Which WAN the live throughput tiles are showing, for the surfaces that carry those tiles (the +/// Monitoring Live View tab and the dashboard's Live View panel). Both ask the same question and +/// answered it identically in their own code until this became one implementation. +/// +/// The selection is deliberately separate from the analysis selectors: it has its own per-site +/// storage key, so watching one WAN's live rate never moves the Network Performance or ISP Health +/// focus. It IS shared between the two live surfaces, which is the point of the shared key - the +/// dashboard and the Monitoring tab show the same WAN. +/// +/// +/// Transient: each component keeps its own instance and its own , so one +/// surface re-rendering never reaches into another's lifecycle. +/// +/// +public sealed class LiveWanScope +{ + private readonly MonitoringPathView _pathView; + private readonly SiteDbContextFactory _siteDb; + private readonly SiteContextService _siteContext; + private readonly IJSRuntime _js; + + private bool _loaded; + private bool _restored; + private bool _pinned; + + /// The ?wan= value meaning every WAN, for links from a view that spans them all. + public const string AllWansToken = "all"; + + /// + /// The ?wan= value meaning whichever WAN holds the primary role, for a link from + /// somewhere that shows the primary's figures without knowing which WAN that is. Named rather + /// than spelled "wan": primary is a ROLE in UniFi Network and any WAN group can hold it, so a + /// link that hardcoded WAN1 would open the wrong report on a site whose primary is not first. + /// + public const string PrimaryWanToken = "primary"; + + public LiveWanScope( + MonitoringPathView pathView, + SiteDbContextFactory siteDb, + SiteContextService siteContext, + IJSRuntime js) + { + _pathView = pathView; + _siteDb = siteDb; + _siteContext = siteContext; + _js = js; + } + + /// + /// A WAN the live tiles can show. is the interface whose + /// counters carry that WAN's throughput; null when nothing has ever recorded one, which the + /// tiles read as "no answer" rather than substituting another WAN's. + /// + /// Whether a WAN context names this WAN. A secondary WAN without one + /// is not probed at all, so anything offering to fix its monitoring has to send the user to + /// make the context first - discovery cannot help until there is one. + public sealed record Option(string Key, string Label, bool IsPrimary, string? CounterIfName, bool HasContext); + + /// + /// Raised after the selection changes. The surface owning this instance sets it: the scope + /// holds the selection but cannot re-render or reach JS interop, so re-rendering the tiles and + /// pointing the chart at the new WAN both happen here. Async because both of those are. + /// + public Func? OnChanged { get; set; } + + public IReadOnlyList - private static UpstreamRole InferAccessRole(AttributedHop hop, AccessTechnology tech, string? ouiVendor) + /// + /// Whether this is the nearest access hop we found. The vendor evidence describes the box on + /// the other end of the WAN and nothing beyond it, so it may only name that one. By nearest + /// rather than by TTL: the first-mile device is hop 1 from a gateway vantage and hop 2 or 3 + /// from a LAN one, and the same box should not change role with the vantage. + /// + private static UpstreamRole InferAccessRole( + AttributedHop hop, AccessTechnology tech, string? ouiVendor, bool isFirstMile) { var vendor = ouiVendor?.ToLowerInvariant() ?? string.Empty; // Known OLT/PON vendors. Adtran for tier-2/3 US telcos, Ubiquiti for UISP-Fiber @@ -2114,11 +2271,12 @@ private static UpstreamRole InferAccessRole(AttributedHop hop, AccessTechnology var isCmtsVendor = vendor.Contains("arris") || vendor.Contains("commscope") || vendor.Contains("casa") || vendor.Contains("cadant") || vendor.Contains("ubr"); - if ((tech == AccessTechnology.Gpon || tech == AccessTechnology.XgsPon) && isOltVendor && hop.HopNumber == 1) + if (!isFirstMile) return UpstreamRole.Aggregation; + if ((tech == AccessTechnology.Gpon || tech == AccessTechnology.XgsPon) && isOltVendor) return UpstreamRole.Bng; if (tech == AccessTechnology.Docsis && (isCmtsVendor || hop.HopNumber == 1)) return UpstreamRole.Cmts; - if (tech == AccessTechnology.PppoE && hop.HopNumber == 1) + if (tech == AccessTechnology.PppoE) return UpstreamRole.Bng; return UpstreamRole.Aggregation; } @@ -2218,6 +2376,90 @@ public void RecomputeL2NeighborLabel() /// After the user reviews and edits labels, commit the proposed targets into /// the MonitoringTargets table. Becomes the live source the latency tier probes. /// + /// + /// What this WAN's traffic costs, from its access technology and whether Data Usage has a cap + /// configured for it. Any cap above zero counts: setting one is the operator saying the link is + /// metered, whatever the toggle beside it is doing. + /// + private async Task ResolveProbePlanAsync( + NetworkOptimizerDbContext db, string wanInterface, CancellationToken ct) + { + var metered = false; + try + { + var key = GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface); + var configs = await db.WanDataUsageConfigs.AsNoTracking() + .Where(c => c.DataCapGb > 0) + .Select(c => c.WanKey) + .ToListAsync(ct); + metered = configs.Any(k => string.Equals( + GatewayWanHelper.WanInterfaceKeyFromKey(k), key, StringComparison.OrdinalIgnoreCase)); + } + catch (Exception ex) + { + // Unreadable config is not evidence of a cap: probe as normal rather than quietly + // throttling a link that may have none. + _logger.LogDebug(ex, "Could not read Data Usage config for {Wan}; probing unmetered", wanInterface); + } + return MeteredProbePolicy.For(State.AccessTechnology, metered); + } + + /// + /// Leaves only a metered WAN's budget of candidates ticked, nearest first. Access hops before + /// transit: they are the ISP's own first mile, the fewest, and the ones whose loss is the ISP's + /// to answer for. Nothing is removed and nothing is disabled that the operator ticked - this + /// only decides what arrives ticked, and the review is still theirs to change. + /// + internal static void ApplyAutoEnableBudget(UpstreamTracerState state, int? budget) + { + if (budget is not int max) return; + + // Three buckets, taken one at a time in rotation. Access hops used to be taken first and + // in full, which on a first mile that answers with a dozen ECMP addresses spent nearly the + // whole allowance before the other two were reached - one internet target survived, so the + // site could see its access cloud in detail and could not tell whether anything it + // actually reaches was up. Each bucket answers a different question: an access hop says + // whether the ISP's own first mile is at fault, a transit hop says which upstream is, and + // a path endpoint says whether any of it is reaching the things people use. A budget that + // buys depth in one of them measures a fraction of the path. + // Candidates the reachability gate already rejected are not in the running. This runs + // AFTER that gate, and switching one back on because it happened to fall inside the + // budget hands the operator a target that is known not to answer - and spends one of the + // few slots a metered WAN gets doing it. Only reachable candidates are considered, and + // the rejected ones keep the Enabled=false the gate gave them. + var buckets = new List>[] + { + state.AccessHops.Where(h => !h.Unreachable).OrderBy(h => h.HopNumber) + .Select(h => (Action)(on => h.Enabled = on)).ToList(), + state.TransitAsns.Where(t => t.Method != DiscoveryMethod.PathProxy && !t.Unreachable) + .Select(t => (Action)(on => t.Enabled = on)).ToList(), + state.TransitAsns.Where(t => t.Method == DiscoveryMethod.PathProxy && !t.Unreachable) + .Select(t => (Action)(on => t.Enabled = on)).ToList(), + }; + + var cursors = new int[buckets.Length]; + var remaining = max; + bool tookAny; + do + { + tookAny = false; + for (var b = 0; b < buckets.Length && remaining > 0; b++) + { + if (cursors[b] >= buckets[b].Count) continue; + buckets[b][cursors[b]++](true); + remaining--; + tookAny = true; + } + } + while (tookAny && remaining > 0); + + // Whatever the rotation did not reach is left off - within a bucket that is its own order, + // so the nearest access hops and the first-listed endpoints are the ones kept. + for (var b = 0; b < buckets.Length; b++) + for (var i = cursors[b]; i < buckets[b].Count; i++) + buckets[b][i](false); + } + public async Task CommitResultsAsync(CancellationToken ct = default) { if (State.Step != TracerStep.ReviewingResults) return; @@ -2227,7 +2469,16 @@ public async Task CommitResultsAsync(CancellationToken ct = default) // Scope all writes to the WAN this discovery ran against. Multi-WAN setups // get one row in MonitoringTargets per (target, wan) and one row in // WanDiscoveryContexts per WAN. - var wanInterface = State.WanInterface ?? "wan"; + var wanInterface = _binding?.WanInterface ?? State.WanInterface ?? "wan"; + // A context run's targets carry both keys: the WAN says where the data belongs, the + // context says who probes them. Setting them together is what closes the gap where a + // context's targets had a context but no WAN, so no per-WAN reader could find them. + var wanContextId = _binding?.WanContextId; + + // What this WAN's probing costs. Targets are created at the plan's cadence, and on a + // metered WAN the ones already here are slowed to match - a link that has just been + // declared metered is exactly the one whose existing targets are the problem. + var probePlan = await ResolveProbePlanAsync(db, wanInterface, ct); // A confirmed provider change resets the connection's upstream monitoring wholesale: // pause every enabled access/transit/path target - auto-discovered and hand-added alike @@ -2251,11 +2502,14 @@ public async Task CommitResultsAsync(CancellationToken ct = default) foreach (var hop in State.AccessHops.Where(h => h.Enabled)) { _logger.LogDebug("Commit access hop: id={TargetId} label='{Label}' addr={Address}", hop.TargetId, hop.Label, hop.Address); - await UpsertTargetAsync(db, hop, wanInterface, ct); + await UpsertTargetAsync(db, hop, wanInterface, wanContextId, ct, probePlan.PollIntervalSeconds); } foreach (var hop in State.AccessHops.Where(h => !h.Enabled)) { - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == hop.Address, ct); + // With per-WAN twin rows an address can have several rows; pause only the one this + // WAN owns (another WAN's row - and its measuring - is that WAN's to manage). + var existing = (await db.MonitoringTargets.Where(t => t.Address == hop.Address).ToListAsync(ct)) + .FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); if (existing != null) { existing.Enabled = false; @@ -2267,7 +2521,8 @@ public async Task CommitResultsAsync(CancellationToken ct = default) { _logger.LogDebug("Commit transit: id={TargetId} label='{Label}' addr={Address} method={Method}", transit.TargetId, transit.Label, transit.HopAddress ?? transit.PathProxyTarget, transit.Method); - await UpsertTransitTargetAsync(db, transit, wanInterface, ct); + await UpsertTransitTargetAsync(db, transit, wanInterface, wanContextId, ct, + pollIntervalSeconds: probePlan.PollIntervalSeconds); } foreach (var transit in State.TransitAsns.Where(t => !t.Enabled)) { @@ -2277,12 +2532,15 @@ public async Task CommitResultsAsync(CancellationToken ct = default) // later. Transit ASNs stay on their off-path / miss-counter mechanism (update-only). if (transit.Method == DiscoveryMethod.PathProxy) { - await UpsertTransitTargetAsync(db, transit, wanInterface, ct, enabled: false); + await UpsertTransitTargetAsync(db, transit, wanInterface, wanContextId, ct, enabled: false, + pollIntervalSeconds: probePlan.PollIntervalSeconds); continue; } var addr = transit.HopAddress ?? transit.PathProxyTarget; if (string.IsNullOrEmpty(addr)) continue; - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == addr, ct); + // Same per-WAN row selection as the access-hop pause above. + var existing = (await db.MonitoringTargets.Where(t => t.Address == addr).ToListAsync(ct)) + .FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); if (existing != null) { existing.Enabled = false; @@ -2340,7 +2598,10 @@ await UpstreamRediscoveryService.ClearMissCountKeysAsync(db, wanInterface, ctxRow.NeedsReview = false; ctxRow.UpdatedAt = DateTime.UtcNow; - var settings = await db.MonitoringSettings.FirstOrDefaultAsync(ct); + // MonitoringSettings holds the LEGACY single-WAN timestamp and review flag, which the + // primary run owns. A context run must leave them alone: clearing the review flag here + // would dismiss a pending review of the primary WAN that nobody has looked at. + var settings = _binding == null ? await db.MonitoringSettings.FirstOrDefaultAsync(ct) : null; if (settings != null) { settings.LastUpstreamDiscoveryAt = DateTime.UtcNow; @@ -2348,6 +2609,25 @@ await UpstreamRediscoveryService.ClearMissCountKeysAsync(db, wanInterface, settings.UpdatedAt = DateTime.UtcNow; } + + // Slow what is already here to match. A WAN only reaches a rung by being declared metered + // or by its technology, and in both cases the targets already probing it are the cost - + // creating new ones at the right cadence while the old ones keep running at 10s would fix + // nothing. Fabric targets never leave the WAN, so they are left alone; so is anything + // already slower than the plan, which is a deliberate choice of the operator's. + if (probePlan.Rung > 0) + { + var repaced = await db.MonitoringTargets + .Where(t => t.TargetType != MonitoringTargetType.Fabric + && t.PollIntervalSeconds < probePlan.PollIntervalSeconds) + .ToListAsync(ct); + repaced = repaced.Where(t => OwnsTargetRow(t.WanInterface, wanInterface)).ToList(); + foreach (var target in repaced) target.PollIntervalSeconds = probePlan.PollIntervalSeconds; + if (repaced.Count > 0) + _logger.LogInformation("Metered WAN {Wan}: slowed {Count} existing target(s) to {Interval}s", + wanInterface, repaced.Count, probePlan.PollIntervalSeconds); + } + await db.SaveChangesAsync(ct); // Persist same-path hop ordering so ISP Health can confirm a farther transit @@ -2356,7 +2636,11 @@ await UpstreamRediscoveryService.ClearMissCountKeysAsync(db, wanInterface, // Drop the ISP Health cache so the "re-run discovery" banner clears on the next tab // view without a manual refresh - the freshly committed ancestry is now in the DB. - _ispHealth.Invalidate(); + // + // Every WAN of the site: this run commits targets for the WAN it was bound to, which is + // usually NOT the primary, and the injected instance always is. Invalidating that alone + // left the report for the very WAN just discovered showing its pre-discovery state. + _ispHealthRegistry.InvalidateSite(_siteSlug); State.Step = TracerStep.Done; State.CurrentActivity = "Targets committed. The agent will start probing on the next latency-tier cycle."; @@ -2471,7 +2755,80 @@ private async Task PersistHopOrderAsync(NetworkOptimizerDbContext db, string wan written, wanInterface, _lastTraces.Count); } - private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, AccessHopCandidate hop, string wanInterface, CancellationToken ct) + /// + /// Whether this run may write to an existing target row. A row already homed on a DIFFERENT + /// WAN belongs to that WAN's discovery: letting each run re-home it would have the two + /// trading it back and forth every cycle - and would let one WAN's run pause a target the + /// other WAN is measuring. A run that finds an address claimed by another WAN creates its + /// OWN row for it instead (see ), so the same host is + /// probed from every WAN that discovers it and each WAN's series stay separable. A row with + /// no WAN yet is unclaimed and adoptable, which is how every pre-existing row behaves on a + /// single-WAN install: there, this is always true and nothing changes. + /// + /// The WAN currently stamped on the row, if any. + /// The WAN this discovery run is committing. + /// + /// The discovery-context row a tracer rehydrates from. A bound (context) tracer takes + /// exactly its own WAN's row. The primary tracer takes the CONFIGURED primary's row when + /// the console answered (primary is a role - any wanN group can hold it); with no console + /// answer it falls back to a documented GUESS: the conventional "wan" row first, then the + /// most recently discovered. That guess is wrong exactly on an offline site whose + /// configured primary is not the "wan" group - acceptable only because there is nothing + /// better to ask, and the next connected rehydrate corrects it. Keys normalized + /// ("wan1" == "wan"). + /// + internal static WanDiscoveryContext? PickRehydrateContext( + IReadOnlyList contexts, string? boundWanInterface, string? configuredPrimaryKey) + { + static string Norm(string? k) => string.IsNullOrEmpty(k) + ? "" : NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(k); + if (!string.IsNullOrEmpty(boundWanInterface)) + return contexts.FirstOrDefault(c => Norm(c.WanInterface) == Norm(boundWanInterface)); + if (!string.IsNullOrEmpty(configuredPrimaryKey)) + { + var configured = contexts.FirstOrDefault(c => Norm(c.WanInterface) == Norm(configuredPrimaryKey)); + if (configured != null) return configured; + } + return contexts + .OrderBy(c => Norm(c.WanInterface) == "wan" ? 0 : 1) + .ThenByDescending(c => c.LastDiscoveryAt ?? c.UpdatedAt) + .FirstOrDefault(); + } + + internal static bool OwnsTargetRow(string? rowWanInterface, string wanInterface) + => string.IsNullOrEmpty(rowWanInterface) + // Normalized ("wan1" == "wan"): legacy rows stamped with the wan1 alias are the SAME + // WAN as a "wan" run, not a rival - unnormalized, every re-run on such an install + // would twin its own targets. + || string.Equals( + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(rowWanInterface), + NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface), + StringComparison.OrdinalIgnoreCase); + + /// + /// The WAN-qualified target id for this WAN's twin of a host another WAN's discovery already + /// claimed. MonitoringTarget.TargetId is unique (and is the Influx target_id tag), so a host + /// reached from several WANs - a core resolver, a shared ISP hop - gets one row PER WAN: the + /// first WAN keeps the base id (existing installs and their history unchanged), every later + /// WAN gets "{baseId}@{wanKey}". Distinct ids keep result routing and the per-target Influx + /// series unambiguous with zero read-side cost; cross-WAN "same host" linkage for comparison + /// views is by (twins share it). + /// + internal static string WanQualifiedTargetId(string baseTargetId, string wanInterface) + => $"{baseTargetId}@{NetworkOptimizer.UniFi.GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface)}"; + + /// + /// Creates or re-validates the monitoring target for a discovered access hop, stamped with + /// the WAN it was discovered on and - on a context run - the context whose agent probes it. + /// Test-visible (internal, see InternalsVisibleTo) because the double stamping and the + /// leave-another-WAN's-row-alone rule are the whole of per-WAN discovery's write side. + /// + /// The site's database. + /// The discovered hop. + /// WAN this discovery ran against. + /// Context this run belongs to, or null for the primary run. + /// Cancellation. + internal static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, AccessHopCandidate hop, string wanInterface, int? wanContextId, CancellationToken ct, int pollIntervalSeconds = MeteredProbePolicy.DefaultIntervalSeconds) { // UniFi's WAN SLA probe targets (1.1.1.1 / 8.8.8.8) are public DNS resolvers, not // ISP first-mile infrastructure. They never belong as an Access ISP target; drop any @@ -2485,13 +2842,23 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access return; } - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.TargetId == hop.TargetId, ct); - existing ??= await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == hop.Address, ct); + // This WAN's own row for the hop: the base id where this WAN owns it (or it is + // unclaimed), this WAN's twin, or any row for the address this WAN owns. When the + // address is claimed by ANOTHER WAN, this run creates its own WAN-qualified twin so + // the host is probed from both WANs with separable series (see WanQualifiedTargetId). + var twinId = WanQualifiedTargetId(hop.TargetId, wanInterface); + var rows = await db.MonitoringTargets + .Where(t => t.TargetId == hop.TargetId || t.TargetId == twinId || t.Address == hop.Address) + .ToListAsync(ct); + var existing = rows.FirstOrDefault(t => t.TargetId == hop.TargetId && OwnsTargetRow(t.WanInterface, wanInterface)) + ?? rows.FirstOrDefault(t => t.TargetId == twinId) + ?? rows.FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); + var claimedByOtherWan = existing == null && rows.Count > 0; if (existing == null) { db.MonitoringTargets.Add(new MonitoringTarget { - TargetId = hop.TargetId, + TargetId = claimedByOtherWan ? twinId : hop.TargetId, Name = hop.Label, Address = hop.Address, ProbeMode = hop.RespondedTo, @@ -2500,12 +2867,13 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access AsnNumber = hop.AsnNumber, AsnName = CleanAsnName(hop.AsnName), VantagePoint = "server", - PollIntervalSeconds = 10, + PollIntervalSeconds = pollIntervalSeconds, PingCount = 5, Enabled = true, AutoDiscovered = true, DiscoveryMethod = hop.Method, WanInterface = wanInterface, + WanContextId = wanContextId, PtrHostname = hop.PtrHostname, AutoLabel = hop.Role.ToString(), CreatedAt = DateTime.UtcNow, @@ -2522,6 +2890,9 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access existing.Address = hop.Address; existing.ProbeMode = hop.RespondedTo; existing.WanInterface = wanInterface; + // Written, never cleared: a target the user assigned to a context by hand keeps + // that assignment when the primary run re-verifies it. + if (wanContextId != null) existing.WanContextId = wanContextId; existing.Name = hop.Label; if (hop.AsnNumber.HasValue) existing.AsnNumber = hop.AsnNumber; if (!string.IsNullOrEmpty(hop.AsnName)) existing.AsnName = CleanAsnName(hop.AsnName); @@ -2530,7 +2901,18 @@ private static async Task UpsertTargetAsync(NetworkOptimizerDbContext db, Access } } - private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, TransitAsnCandidate transit, string wanInterface, CancellationToken ct, bool enabled = true) + /// + /// Creates or re-validates the monitoring target for a discovered transit ASN hop or path-end + /// host, with the same WAN + context stamping and same-WAN ownership rule as + /// . Test-visible for the same reason. + /// + /// The site's database. + /// The discovered transit candidate. + /// WAN this discovery ran against. + /// Context this run belongs to, or null for the primary run. + /// Cancellation. + /// Whether the target is committed enabled (a declined path-end is saved paused). + internal static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, TransitAsnCandidate transit, string wanInterface, int? wanContextId, CancellationToken ct, bool enabled = true, int pollIntervalSeconds = MeteredProbePolicy.DefaultIntervalSeconds) { if (transit.Method == DiscoveryMethod.Unresolved || string.IsNullOrEmpty(transit.TargetId)) return; @@ -2539,14 +2921,22 @@ private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, : MonitoringTargetType.Transit; var address = transit.HopAddress ?? transit.PathProxyTarget; - var existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.TargetId == transit.TargetId, ct); - if (existing == null && !string.IsNullOrEmpty(address)) - existing = await db.MonitoringTargets.FirstOrDefaultAsync(t => t.Address == address, ct); + // Same twin rule as UpsertTargetAsync: a host another WAN's discovery already claimed + // gets this WAN's own WAN-qualified row, so both WANs probe it with separable series. + var twinId = WanQualifiedTargetId(transit.TargetId, wanInterface); + var rows = await db.MonitoringTargets + .Where(t => t.TargetId == transit.TargetId || t.TargetId == twinId + || (address != null && t.Address == address)) + .ToListAsync(ct); + var existing = rows.FirstOrDefault(t => t.TargetId == transit.TargetId && OwnsTargetRow(t.WanInterface, wanInterface)) + ?? rows.FirstOrDefault(t => t.TargetId == twinId) + ?? rows.FirstOrDefault(t => OwnsTargetRow(t.WanInterface, wanInterface)); + var claimedByOtherWan = existing == null && rows.Count > 0; if (existing == null) { db.MonitoringTargets.Add(new MonitoringTarget { - TargetId = transit.TargetId, + TargetId = claimedByOtherWan ? twinId : transit.TargetId, Name = transit.Label ?? transit.AsnName, Address = transit.HopAddress ?? transit.PathProxyTarget ?? "0.0.0.0", ProbeMode = transit.RespondedTo ?? NetworkOptimizer.Core.Enums.ProbeMode.Icmp, @@ -2555,13 +2945,14 @@ private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, AsnNumber = transit.AsnNumber, AsnName = transit.AsnName, VantagePoint = "server", - PollIntervalSeconds = 15, + PollIntervalSeconds = pollIntervalSeconds, PingCount = 5, Enabled = enabled, PtrHostname = transit.HopHostname, AutoDiscovered = true, DiscoveryMethod = transit.Method, WanInterface = wanInterface, + WanContextId = wanContextId, CreatedAt = DateTime.UtcNow, LastVerified = DateTime.UtcNow }); @@ -2575,6 +2966,7 @@ private static async Task UpsertTransitTargetAsync(NetworkOptimizerDbContext db, if (!string.IsNullOrEmpty(transit.HopHostname)) existing.PtrHostname = transit.HopHostname; existing.DiscoveryMethod = transit.Method; existing.WanInterface = wanInterface; + if (wanContextId != null) existing.WanContextId = wanContextId; // Refresh ASN bookkeeping in case the resolver picked up a name now // (legacy rows from before the GeoLite2 path landed had nulls). if (transit.AsnNumber > 0) existing.AsnNumber = transit.AsnNumber; @@ -2711,16 +3103,32 @@ private async Task ResolveDestinationAsnsAsync(CancellationToken ct) /// attribution but sit in public or shared/CGNAT (RFC 6598) space - Bell's 142.124.x /// aggregation hops (#984). Being upstream of us and downstream of the ISP's announced /// border makes them the ISP's access infrastructure even though no ASN maps to them. - /// RFC1918 hops are excluded: those can be a bridged CPE's LAN side or a double-NAT - /// middlebox. Traces whose first attributed hop is NOT the access ASN (e.g. a trace + /// RFC1918 hops count too: an ISP whose access network is numbered out of private space (a CMTS + /// or BNG on 10/8) leaves no other trace of its first mile, and dropping those hops is what left + /// such sites with no access targets at all. Our own gateway is excluded by address; nothing + /// else is, because there is no reliable way to tell a bridged CPE from the ISP's first device + /// here - the sequences carry only hops that RESPONDED, so position is not TTL distance, and a + /// probe running ON the gateway has no gateway hop to count from at all. A wrong one is + /// proposed, not applied: discovery review is where the operator unticks it. + /// Traces whose first attributed hop is NOT the access ASN (e.g. a trace /// that only ever surfaces the destination's edge) contribute nothing - we can't prove /// their prefix hops sit below the access border. Dedupes across traces, preserves /// first-seen order. /// + /// + /// A private hop this close is on our own side of the WAN - the gateway itself, a bridged CPE, + /// a middlebox. The ISP's first-mile gear is a WAN crossing away and answers in milliseconds, + /// not fractions of one, so distance separates the two where position cannot: non-responding + /// hops make position unreliable, and a probe running on the gateway has no gateway hop at all. + /// + internal const double LocalHopRttMs = 1.2; + internal static List CollectUnannouncedAccessAddresses( IEnumerable> traceAddressSequences, IReadOnlyDictionary asnByIp, - int accessAsn) + int accessAsn, + IReadOnlyCollection? gatewayIps = null, + IReadOnlyDictionary? minRttByIp = null) { var result = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -2729,6 +3137,7 @@ internal static List CollectUnannouncedAccessAddresses( var prefix = new List(); foreach (var address in trace) { + if (gatewayIps != null && gatewayIps.Contains(address)) continue; if (asnByIp.TryGetValue(address, out var asn)) { if (asn == accessAsn) @@ -2736,9 +3145,15 @@ internal static List CollectUnannouncedAccessAddresses( if (seen.Add(p)) result.Add(p); break; } - var cls = NetworkUtilities.ClassifyPublicAddress(address); - if (cls is PublicAddressClass.PublicIPv4 or PublicAddressClass.Cgnat) - prefix.Add(address); + // Only private space is judged on distance: public and CGNAT hops are carrier space + // whatever they measure. An address with no timing is kept - silence is not evidence. + if (minRttByIp != null + && NetworkUtilities.ClassifyPublicAddress(address) + is not (PublicAddressClass.PublicIPv4 or PublicAddressClass.Cgnat) + && minRttByIp.TryGetValue(address, out var hopRtt) + && hopRtt < LocalHopRttMs) + continue; + prefix.Add(address); } } return result; @@ -2837,7 +3252,72 @@ internal static HashSet ComputeExcludedTier1Asns( var parts = hostname.Split('.'); if (IsIpDerivedHostname(parts, ipAddress ?? string.Empty)) return null; if (parts.Length <= 2) return null; - return string.Join('.', parts.Take(parts.Length - 2)); + var label = string.Join('.', parts.Take(parts.Length - 2)); + return PlaceholderPtrLabels.Contains(label) ? null : label; + } + + /// + /// PTR labels that name nothing. Starlink answers most of its network with + /// "undefined.hostname.localhost", which parses as a perfectly good label and produced a row + /// called "<Org> undefined" - repeated for every hop, so they were not even distinguishable + /// from each other. A placeholder is treated as no PTR at all. + /// + private static readonly HashSet PlaceholderPtrLabels = + new(StringComparer.OrdinalIgnoreCase) { "undefined", "unknown", "none", "null", "localhost" }; + + /// + /// Access technology implied by the access ISP itself. Only satellite is safe to read this + /// way: a terrestrial ISP's AS carries fiber, cable and DSL customers behind the same number, + /// while SpaceX's carries one medium. Matched on the number AND the org name so a secondary + /// ASN, or a registry rename, still lands. + /// + internal static AccessTechnology? TechnologyFromAccessAsn(int? asn, string? orgName) + => IsStarlinkAsn(asn, orgName) ? AccessTechnology.Satellite : null; + + /// + /// Whether an ASN is SpaceX's. Matched on the number AND the org name so a secondary ASN, or a + /// registry rename, still lands. + /// + internal static bool IsStarlinkAsn(int? asn, string? orgName) + { + if (asn == 14593) return true; + if (string.IsNullOrWhiteSpace(orgName)) return false; + return orgName.Contains("starlink", StringComparison.OrdinalIgnoreCase) + || orgName.Contains("space exploration", StringComparison.OrdinalIgnoreCase) + || orgName.Contains("spacex", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Whether a PTR answers with a placeholder rather than a name - "undefined.hostname.localhost" + /// and the like. Distinct from having no PTR at all, which is a different and less telling + /// thing: a host that answers with a placeholder is saying something about what it is. + /// + internal static bool IsPlaceholderPtrHostname(string? hostname) + { + if (string.IsNullOrEmpty(hostname)) return false; + var first = hostname.Split('.')[0]; + return PlaceholderPtrLabels.Contains(first); + } + + /// + /// Re-applies the metered probe budget to the candidates on screen. The budget reads the + /// access technology, which is often only right once someone sets it in the review - a + /// satellite WAN identified after the run would otherwise keep the pre-selection an unmetered + /// run made, which is the whole allowance it was meant to save. + /// + public async Task ReapplyProbeBudgetAsync(CancellationToken ct = default) + { + try + { + await using var db = await CreateDbAsync(ct); + var plan = await ResolveProbePlanAsync( + db, _binding?.WanInterface ?? State.WanInterface ?? "wan", ct); + ApplyAutoEnableBudget(State, plan.MaxAutoEnabled); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Re-applying the probe budget after an access technology change failed"); + } } private bool Fail(string message) diff --git a/src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs b/src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs new file mode 100644 index 0000000000..69a267a81f --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/Monitoring/WanContextTargetStamping.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; + +namespace NetworkOptimizer.Web.Services.Monitoring; + +/// +/// Keeps (probe routing) and +/// (which WAN the data describes - the key every +/// per-WAN reader scopes on) moving together at runtime. The deploy-time backfill migration +/// only fixed rows that existed then; every later assignment, context edit, and context +/// deletion goes through here so the two keys can never drift apart again. +/// +public static class WanContextTargetStamping +{ + /// + /// The WanInterface a target should carry after a WAN-context (re)assignment: the context's + /// WAN, or null when the target moves back to the primary (an unstamped target IS a + /// primary-path measurement to every scoped reader). + /// + public static void ApplyAssignment(MonitoringTarget target, int? wanContextId, string? contextWanInterface) + { + target.WanContextId = wanContextId; + target.WanInterface = wanContextId == null ? null : contextWanInterface; + } + + /// + /// Re-stamps every target assigned to a context after the context's WAN changed, so their + /// data is attributed to the WAN the context now measures. Caller saves. + /// + public static async Task RestampContextTargetsAsync( + NetworkOptimizerDbContext db, int wanContextId, string? wanInterface, CancellationToken ct = default) + { + var targets = await db.MonitoringTargets.Where(t => t.WanContextId == wanContextId).ToListAsync(ct); + foreach (var target in targets) + target.WanInterface = wanInterface; + return targets.Count; + } + + /// + /// Moves a deleted context's targets back to the primary: both keys cleared, because a row + /// keeping the dead context's WAN stamp would stay invisible to the primary report while no + /// context probes it any more. Caller saves. + /// + public static async Task ReleaseContextTargetsAsync( + NetworkOptimizerDbContext db, int wanContextId, CancellationToken ct = default) + { + var targets = await db.MonitoringTargets.Where(t => t.WanContextId == wanContextId).ToListAsync(ct); + foreach (var target in targets) + ApplyAssignment(target, null, null); + return targets.Count; + } +} diff --git a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs index 3c104f0434..cd3254fbfc 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringCollectionAgent.cs @@ -71,6 +71,7 @@ public class MonitoringCollectionAgent : BackgroundService // Counter delta cache for server-side rate computation. Key = "deviceMac/ifName". private readonly ConcurrentDictionary _counterCache = new(); + private bool _fabricSeeded; // Per-target last-probed time, for per-target poll intervals on a shared loop. private readonly ConcurrentDictionary _targetLastProbed = new(); @@ -458,6 +459,19 @@ private async Task FastTierCollectAsync(MonitoringSettings settings, Cancellatio // port the AP is plugged into (spec 5.6). _fabric.UpdateUnifiPortRates(devices, DateTime.UtcNow); + // First cycle after a start: show the last figures we recorded rather than a dash. + // Rates are derived from consecutive SNMP counter reads, and that cache is in memory, so a + // restart cannot produce one until a device has been polled TWICE - and none of that + // begins until the console (on an agent site, the console THROUGH the tunnel) has named + // the devices. Until then the fabric tiles read "-" though the data is sitting in Influx. + // Seeding from it is the same trick the flow map already uses for per-port rates; the + // live path overwrites each device the moment its own second poll lands. + if (!_fabricSeeded) + { + _fabricSeeded = true; + await SeedFabricSumsAsync(devices, ct); + } + // Resolve the gateway LAN IP once per cycle so the SNMP poll targets the // LAN-side address (which actually answers) instead of UniFi's reported WAN // public IP for the gateway (which never will). @@ -748,6 +762,60 @@ private async Task MaybeSelfHealSnmpAsync( private readonly LanFabricAggregator _fabric = new(); + + /// + /// Fills the live fabric totals from the most recent readings in InfluxDB so a restart does + /// not blank them until two fresh SNMP polls have happened. Types match what the live path + /// records for - switches, gateways and cellular modems - so an AP cannot inflate the seeded + /// total any more than it can the live one. + /// + private async Task SeedFabricSumsAsync(IReadOnlyList devices, CancellationToken ct) + { + if (!_influx.IsConfigured) return; + var until = DateTime.UtcNow; + var from = until - TimeSpan.FromMinutes(2); + + foreach (var device in devices) + { + if (string.IsNullOrEmpty(device.Mac)) continue; + if (device.DeviceType != NetworkOptimizer.Core.Enums.DeviceType.Switch + && device.DeviceType != NetworkOptimizer.Core.Enums.DeviceType.Gateway + && device.DeviceType != NetworkOptimizer.Core.Enums.DeviceType.CellularModem) + continue; + try + { + using var queryCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + queryCts.CancelAfter(TimeSpan.FromSeconds(5)); + var points = await _influx.QueryInterfaceRatesAsync( + NormalizeMac(device.Mac), from, until, null, queryCts.Token); + if (points.Count == 0) continue; + + // One reading per interface - the newest - then summed, which is how the live + // path builds the same figure from a single poll's interfaces. + double inBps = 0, outBps = 0; + DateTime stamp = default; + foreach (var per in points.GroupBy(p => p.IfName, StringComparer.OrdinalIgnoreCase)) + { + var latest = per.OrderByDescending(p => p.Time).First(); + inBps += latest.RateInBps ?? 0; + outBps += latest.RateOutBps ?? 0; + if (latest.Time > stamp) stamp = latest.Time; + } + if (stamp == default) continue; + _liveStats.RecordFabricSum(NormalizeMac(device.Mac), inBps, outBps, stamp); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // A slow Influx must not hold up the poll cycle that is about to replace this. + _logger.LogDebug("Fabric seed timed out for {Device}", device.Mac); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Fabric seed failed for {Device}", device.Mac); + } + } + } + private async Task MediumTierCollectAsync(MonitoringSettings settings, CancellationToken ct) { // Before anything filtered: device-state alerting needs EVERY adopted device, offline ones @@ -1518,7 +1586,7 @@ await _influx.WriteLatencyAsync( sent: ping.Sent, received: ping.Received, timestamp: ping.Timestamp, - wanContext: wanContext?.Name); + wanContext: wanContext?.InfluxWanTag); // Surface fabric probe results on the dashboard's device cards (5.6). Other // target types (WAN, transit) feed cloud nodes on the 3D map; the per-device diff --git a/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs b/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs index 28ba97975b..ae3ca37ddd 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringLiveStats.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using NetworkOptimizer.Storage.Models; using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.UniFi; using NetworkOptimizer.Web.Services.Monitoring; namespace NetworkOptimizer.Web.Services; @@ -20,7 +21,7 @@ public class MonitoringLiveStats private readonly ILogger _logger; private readonly IDbContextFactory _dbFactory; - private List<(string TargetId, MonitoringTargetType TargetType)>? _ispTransitTargets; + private List<(string TargetId, MonitoringTargetType TargetType, string? WanInterface)>? _ispTransitTargets; private DateTime _ispTransitTargetsCacheTime; private static readonly TimeSpan TargetCacheTtl = TimeSpan.FromSeconds(30); private readonly Lock _targetCacheLock = new(); @@ -332,7 +333,7 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc } /// Cached list of enabled ISP+Transit monitoring targets. Refreshed every 30s. - public async Task> GetIspTransitTargetsAsync( + public async Task> GetIspTransitTargetsAsync( CancellationToken ct = default) { lock (_targetCacheLock) @@ -347,10 +348,10 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc && (t.TargetType == MonitoringTargetType.AccessIsp || t.TargetType == MonitoringTargetType.Transit) && (t.AsnNumber == null || !WellKnownAsns.NonTransitInfrastructure.Contains(t.AsnNumber.Value))) - .Select(t => new { t.TargetId, t.TargetType }) + .Select(t => new { t.TargetId, t.TargetType, t.WanInterface }) .ToListAsync(ct); - var result = targets.Select(t => (t.TargetId, t.TargetType)).ToList(); + var result = targets.Select(t => (t.TargetId, t.TargetType, t.WanInterface)).ToList(); lock (_targetCacheLock) { _ispTransitTargets = result; @@ -368,10 +369,33 @@ public void RecordTargetProbe(string targetId, double? rttAvgMs, double lossPerc /// blanked the chart exactly when loss mattered most. Shared by the live-stats /// endpoint and the LAN flow map WAN globes so both always show the same number. /// + /// + /// Scope to one WAN's targets. Null keeps the site-wide mean, which is what every caller meant + /// before there was more than one WAN to tell apart. An unstamped target belongs to the + /// primary - the same rule every per-WAN reader uses - so a secondary WAN with no targets of + /// its own returns nothing rather than borrowing the primary's numbers and presenting them as + /// its own. + /// public async Task<(double? MeanRttMs, double MeanLossPercent)> GetMeanIspTransitLiveAsync( - CancellationToken ct = default) + CancellationToken ct = default, + string? wanInterface = null, + bool isPrimary = false) { var targets = await GetIspTransitTargetsAsync(ct); + // No WAN named means the primary, not every WAN. A chart showing one WAN asks for it by + // omitting the parameter, and skipping the filter entirely averaged in the other WANs' + // targets - a speed test on a secondary WAN then appeared as a latency and loss spike on + // the primary's chart, from readings that were never on its path. Unchanged on a + // single-WAN site, where every target is the primary's already. + var key = string.IsNullOrEmpty(wanInterface) + ? GatewayWanHelper.DefaultWanKey + : GatewayWanHelper.WanInterfaceKeyFromKey(wanInterface!); + var primaryScope = isPrimary || string.IsNullOrEmpty(wanInterface); + targets = targets.Where(t => string.IsNullOrEmpty(t.WanInterface) + ? primaryScope + : string.Equals(GatewayWanHelper.WanInterfaceKeyFromKey(t.WanInterface!), key, + StringComparison.OrdinalIgnoreCase)) + .ToList(); var ispRtts = new List(); var ispLosses = new List(); diff --git a/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs b/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs index c1493ace9d..648dd14da4 100644 --- a/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs +++ b/src/NetworkOptimizer.Web/Services/MonitoringTargetService.cs @@ -81,6 +81,18 @@ public async Task AddAsync(NewMonitoringTarget spec, Cancellat AsnName = asnName }; + // Same stamping the reassign path uses, so a target created against a WAN context carries + // both keys from its first poll: the context that routes the probe and the WAN the readings + // are filed under. + if (spec.WanContextId is int newContextId) + { + await using var contextDb = CreateDb(); + var context = await contextDb.WanContexts.FindAsync(new object?[] { newContextId }, ct); + if (context == null) + throw new MonitoringTargetValidationException("That WAN context no longer exists."); + Monitoring.WanContextTargetStamping.ApplyAssignment(entity, newContextId, context.WanInterface); + } + await using (var db = CreateDb()) { db.MonitoringTargets.Add(entity); @@ -95,7 +107,8 @@ public async Task AddAsync(NewMonitoringTarget spec, Cancellat probeMode = entity.ProbeMode.ToString(), entity.Port, entity.PollIntervalSeconds, - entity.AsnNumber + entity.AsnNumber, + entity.WanContextId }); // Trace-on-save: an Internet/Custom target only absolves the ISP/transit hops it crosses @@ -159,14 +172,26 @@ public Task DismissLanFlakyHintAsync(int id, CancellationToken ct = defaul }); /// - public Task SetWanContextAsync(int id, int? wanContextId, CancellationToken ct = default) => - UpdateAsync(id, ct, row => + public async Task SetWanContextAsync(int id, int? wanContextId, CancellationToken ct = default) + { + // The context's WAN rides along with the assignment: WanContextId routes the probes and + // WanInterface says which WAN the data describes, and every per-WAN reader scopes on the + // latter - an assignment that moved only the routing would keep grading the data under + // the old WAN. Moving back to the primary clears both (see WanContextTargetStamping). + string? contextWanInterface = null; + if (wanContextId is int contextId) + { + await using var db = CreateDb(); + contextWanInterface = (await db.WanContexts.FindAsync(new object?[] { contextId }, ct))?.WanInterface; + } + return await UpdateAsync(id, ct, row => { if (row.WanContextId == wanContextId) return null; var before = row.WanContextId; - row.WanContextId = wanContextId; + Monitoring.WanContextTargetStamping.ApplyAssignment(row, wanContextId, contextWanInterface); return new { field = "WanContextId", from = before, to = wanContextId }; }); + } /// /// Applies a single-field edit and records what actually changed. A mutate that returns null diff --git a/src/NetworkOptimizer.Web/Services/UiHintService.cs b/src/NetworkOptimizer.Web/Services/UiHintService.cs new file mode 100644 index 0000000000..a133d257db --- /dev/null +++ b/src/NetworkOptimizer.Web/Services/UiHintService.cs @@ -0,0 +1,116 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Storage.Models.Identity; + +namespace NetworkOptimizer.Web.Services; + +/// +/// Teaching hints that retire once the user has plainly seen them. +/// +/// Some gestures cannot be discovered by looking - a modifier click is the obvious case - so the +/// UI has to say them out loud. Saying them forever is its own kind of noise: the hint is for the +/// first encounter, not the hundredth. This counts how many times a user has been shown one and +/// stops at . +/// +/// +/// Per user, not per site or per install: what someone has learned travels with them, and one +/// operator learning a gesture says nothing about their colleagues. A user we cannot identify +/// (no Identity session) always sees the hint and nothing is recorded - the hint is the safe +/// outcome, and there is nowhere honest to keep the count. +/// +/// +public class UiHintService +{ + /// How many times a hint is shown before it is treated as learned. + public const int ShowLimit = 2; + + private readonly IDbContextFactory _authDb; + private readonly AuthenticationStateProvider _authState; + private readonly ILogger _logger; + + public UiHintService( + IDbContextFactory authDb, + AuthenticationStateProvider authState, + ILogger logger) + { + _authDb = authDb; + _authState = authState; + _logger = logger; + } + + /// + /// Whether this user should still be shown the hint. Errs toward showing it: a hint one time + /// too many is a smaller cost than a gesture nobody ever discovers. + /// + public async Task ShouldShowAsync(string hintKey, CancellationToken ct = default) + { + var userId = await CurrentUserIdAsync(); + if (userId == null) return true; + try + { + await using var db = await _authDb.CreateDbContextAsync(ct); + var shown = await db.UserUiHints.AsNoTracking() + .Where(h => h.UserId == userId && h.HintKey == hintKey) + .Select(h => (int?)h.TimesShown) + .FirstOrDefaultAsync(ct); + return (shown ?? 0) < ShowLimit; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not read hint state for {Hint}; showing it", hintKey); + return true; + } + } + + /// + /// Counts one showing. Call once per occasion the user could actually have read it - a page + /// visit - not once per render, or a component that re-renders on a timer would burn the + /// allowance in seconds. + /// + public async Task RecordShownAsync(string hintKey, CancellationToken ct = default) + { + var userId = await CurrentUserIdAsync(); + if (userId == null) return; + try + { + await using var db = await _authDb.CreateDbContextAsync(ct); + var row = await db.UserUiHints + .FirstOrDefaultAsync(h => h.UserId == userId && h.HintKey == hintKey, ct); + if (row == null) + { + row = new UserUiHint { UserId = userId, HintKey = hintKey }; + db.UserUiHints.Add(row); + } + // Stops climbing at the limit: the number past that point means nothing, and leaving it + // to grow forever would make a future "reset hints" read as absurd. + if (row.TimesShown < ShowLimit) row.TimesShown++; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + catch (Exception ex) + { + // Losing a count costs one extra tooltip, so it is never worth failing a render over. + _logger.LogDebug(ex, "Could not record hint state for {Hint}", hintKey); + } + } + + private async Task CurrentUserIdAsync() + { + try + { + var user = (await _authState.GetAuthenticationStateAsync()).User; + return user.Identity?.IsAuthenticated == true + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + } + catch { return null; } + } +} + +/// Keys for hints that retire. Kept together so the set is visible at a glance. +public static class UiHintKeys +{ + /// Ctrl/Cmd-click on the WAN filter builds a comparison - invisible without saying so. + public const string WanFilterCompare = "wan-filter-compare"; +} diff --git a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs index 5c2959d6ba..a778ac5fc3 100644 --- a/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs +++ b/src/NetworkOptimizer.Web/Services/UniFiConnectionService.cs @@ -1457,6 +1457,22 @@ public async Task> GetNetworksAsync(CancellationToken cancella return primary; } + /// + /// Whether the site spreads traffic across WANs rather than running one primary with the rest + /// on failover. True when two or more enabled WANs are NOT marked failover-only, which is + /// UniFi's way of saying they share the load. + /// + /// It decides what an unpinned probe measures. Under failover-only, everything on the LAN + /// leaves by the primary, so an ordinary agent measures the primary honestly and needs no + /// policy route (during an actual failover it follows the backup - collateral we accept and + /// state). Under load balancing the same probe is spread across WANs and attributable to + /// none, so every probe source has to be pinned, the primary's included. + /// + /// + public static bool ResolveSiteLoadBalances(IReadOnlyList networks) => + networks.Count(n => n.IsWan && n.Enabled + && !string.Equals(n.WanLoadBalanceType, "failover-only", StringComparison.OrdinalIgnoreCase)) > 1; + /// /// Convenience: fetches networks and resolves the primary WAN in one call. /// @@ -1478,8 +1494,20 @@ public async Task> GetNetworksAsync(CancellationToken cancella { var primary = await GetPrimaryWanNetworkAsync(ct); if (primary?.WanNetworkgroup == null) return null; + return await GetWanInterfacesForGroupAsync(primary.WanNetworkgroup, ct); + } - if (_client == null) return null; + /// + /// Resolves the interface forms of ANY WAN by its network group ("WAN", "WAN2") from the + /// cached device call - the same walk performs for + /// the configured primary, generalized so per-WAN consumers (multi-WAN ISP Health, the WAN + /// throughput selectors) pair a WAN's counters and data path with that same WAN's plan + /// speeds instead of falling back to another WAN's. Returns null when the group's wan + /// object cannot be found. + /// + public async Task GetWanInterfacesForGroupAsync(string networkGroup, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(networkGroup) || _client == null) return null; var rawDevices = await _client.GetDevicesAsync(ct); var gw = rawDevices.FirstOrDefault(d => d.Type is "ugw" or "udm" or "uxg"); if (gw == null) return null; @@ -1492,7 +1520,7 @@ public async Task> GetNetworksAsync(CancellationToken cancella gw.AdditionalData != null && gw.AdditionalData.TryGetValue("ethernet_overrides", out var eoElem) ? eoElem : default); - // Find the wan object whose physical interface maps to the primary networkgroup + // Find the wan object whose physical interface maps to the requested networkgroup foreach (var wan in wanInterfaces) { string? ng = null; @@ -1500,11 +1528,11 @@ public async Task> GetNetworksAsync(CancellationToken cancella ifnameToNg.TryGetValue(wan.IfName, out ng); ng ??= GatewayWanHelper.WanNetworkGroupFromKey(wan.Key); - if (string.Equals(ng, primary.WanNetworkgroup, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(ng, networkGroup, StringComparison.OrdinalIgnoreCase)) { var counter = NetworkUtilities.PreferredWanCounterInterface(wan.IfName, wan.UplinkIfName); - _logger.LogDebug("Primary WAN interfaces: counter={Counter}, data-path={Uplink} (physical={Physical}, networkgroup={NG})", - counter, wan.UplinkIfName ?? wan.IfName, wan.IfName, ng); + _logger.LogDebug("WAN {NG} interfaces: counter={Counter}, data-path={Uplink} (physical={Physical})", + ng, counter, wan.UplinkIfName ?? wan.IfName, wan.IfName); return new PrimaryWanInterfaces(ng, wan.IfName, wan.UplinkIfName, counter); } } @@ -1512,6 +1540,38 @@ public async Task> GetNetworksAsync(CancellationToken cancella return null; } + /// + /// Every WAN's interface forms from the cached device call, one entry per wan1..wan6 object + /// with an uplink. The all-WAN usage fingerprint sums these counter interfaces; per-WAN load + /// callers must NOT use this list (see MonitoringInfluxClient.QueryGatewayWanRatesAsync's + /// summing contract) - they resolve their one WAN via + /// . + /// + public async Task> GetAllWanInterfacesAsync(CancellationToken ct = default) + { + var results = new List(); + if (_client == null) return results; + var rawDevices = await _client.GetDevicesAsync(ct); + var gw = rawDevices.FirstOrDefault(d => d.Type is "ugw" or "udm" or "uxg"); + if (gw == null) return results; + + var wanInterfaces = gw.GetWanInterfaces(); + var ifnameToNg = GatewayWanHelper.BuildNetworkGroupByIfname( + gw.AdditionalData != null && gw.AdditionalData.TryGetValue("ethernet_overrides", out var eoElem) + ? eoElem : default); + foreach (var wan in wanInterfaces) + { + if (string.IsNullOrEmpty(wan.UplinkIfName) && string.IsNullOrEmpty(wan.IfName)) continue; + string? ng = null; + if (!string.IsNullOrEmpty(wan.IfName)) + ifnameToNg.TryGetValue(wan.IfName, out ng); + ng ??= GatewayWanHelper.WanNetworkGroupFromKey(wan.Key); + var counter = NetworkUtilities.PreferredWanCounterInterface(wan.IfName, wan.UplinkIfName); + results.Add(new PrimaryWanInterfaces(ng, wan.IfName, wan.UplinkIfName, counter)); + } + return results; + } + /// /// Resolves the data-path interface name (e.g. "eth6.100", "ppp0") for the /// primary WAN - the Linux ifname SQM deploys on. Thin accessor over diff --git a/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs b/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs index fd3eb5dcbb..63f59e37ad 100644 --- a/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs +++ b/src/NetworkOptimizer.Web/Services/UpstreamDiscoveryService.cs @@ -16,14 +16,17 @@ public UpstreamDiscoveryService(UpstreamTracerService tracer, IAuditContext audi } /// - public async Task StartAsync(CancellationToken ct = default) + public async Task StartAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default) { - await _tracer.StartDiscoveryAsync(ct); + // The audit gate wraps whichever tracer runs; a per-WAN run is the same operator action + // on another WAN's instance, not a different action. + var t = tracer ?? _tracer; + await t.StartDiscoveryAsync(ct); // Shape of the result, not its contents: how far it got and how much it found. The path // itself (WAN address, first-mile neighbor) is discovery output the panel already shows, // and is not what an audit trail is for. - var s = _tracer.State; + var s = t.State; _audit.SetDetails(new { step = s.Step.ToString(), @@ -35,11 +38,12 @@ public async Task StartAsync(CancellationToken ct = default) } /// - public async Task CommitAsync(CancellationToken ct = default) + public async Task CommitAsync(Monitoring.UpstreamTracerService? tracer = null, CancellationToken ct = default) { + var t = tracer ?? _tracer; // Counted before the commit: committing clears the review lists, so reading them // afterwards would report every run as having applied nothing. - var s = _tracer.State; + var s = t.State; var detail = new { accessHops = s.AccessHops.Count, @@ -48,7 +52,7 @@ public async Task CommitAsync(CancellationToken ct = default) addedAsns = s.DiscoveryAddedAsns.Count }; - await _tracer.CommitResultsAsync(ct); + await t.CommitResultsAsync(ct); _audit.SetDetails(detail); } } diff --git a/src/NetworkOptimizer.Web/wwwroot/css/app.css b/src/NetworkOptimizer.Web/wwwroot/css/app.css index 33233b0468..38186e582c 100644 --- a/src/NetworkOptimizer.Web/wwwroot/css/app.css +++ b/src/NetworkOptimizer.Web/wwwroot/css/app.css @@ -209,6 +209,13 @@ a:hover { color: var(--text-primary); } +.agent-install .agent-flavor-note { + margin-left: 0.4rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); +} + .nav-link.active { background: var(--bg-tertiary); color: var(--accent-color); @@ -513,6 +520,37 @@ h1:focus { gap: 0.75rem; } +.monitoring-chart-header .chart-header-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-left: auto; + justify-content: flex-end; +} + +.isp-health-toolbar .wan-selector { + margin-right: auto; +} + +.monitoring-chart-header.chart-header-stacked .card-title { + flex-basis: 100%; +} + +@media (max-width: 768px) { + .monitoring-chart-header.chart-header-stacked .wan-selector, + .monitoring-chart-header .chart-header-controls { + margin-left: 0; + flex: 1 1 100%; + justify-content: flex-start; + } +} + +.wan-selector .wan-filter-reset { + position: static; + margin-left: 0.15rem; +} + @media (max-width: 768px) { .monitoring-chart-header:has(.time-range-selector):has(.settings-link) { position: relative; @@ -936,6 +974,9 @@ a.stat-card-link:active { flex: 1 1 120px; min-width: 100px; text-align: center; + display: flex; + flex-direction: column; + justify-content: center; } .monitoring-stat-pair { display: flex; @@ -947,6 +988,7 @@ a.stat-card-link:active { min-width: 0; } .monitoring-stat-card .stat-value { + font-variant-numeric: tabular-nums; font-size: 1.3rem; font-weight: 600; color: var(--text-primary); @@ -9417,6 +9459,51 @@ a.path-hop.hop-clickable:hover { } } +.stat-value-stacked { + display: grid; + grid-template-columns: 2.6rem 5.2rem; + justify-content: center; + align-items: center; + column-gap: 0.6rem; + row-gap: 0.1rem; + font-size: 0.95rem; + line-height: 1.35; + font-variant-numeric: tabular-nums; +} + +.stat-stack-row { + display: contents; +} + +.stat-stack-wan { + color: var(--text-muted); + font-size: 0.7rem; + text-align: right; +} + +.stat-stack-value { + text-align: left; + font-size: 1rem; +} + +.wan-pill-token { + color: var(--text-muted); + margin-left: 0.3rem; +} + +.time-btn.active .wan-pill-token { + color: var(--text-secondary); +} + +.wan-selector-standalone { + width: max-content; + max-width: 100%; +} + +.time-range-selector.wan-selector-on-page { + background: var(--bg-tertiary); +} + .time-range-selector { display: flex; gap: 0.25rem; @@ -9815,6 +9902,11 @@ a.path-hop.hop-clickable:hover { padding: 0.5rem 0.25rem; } + .wan-selector.wan-selector-many .time-btn { + font-size: 0.7rem; + padding: 0.5rem 0.15rem; + } + .filter-group { flex-wrap: wrap; } @@ -15410,6 +15502,38 @@ a.affected-client:hover { color: var(--text-primary); } +.monitoring-jump-btn { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.35rem; + border: none; + background: transparent; + color: var(--text-muted); + line-height: 1; + cursor: pointer; +} + +.monitoring-jump-btn:hover { + color: var(--text-primary); +} + +.monitoring-chart-header > .monitoring-jump-btn { + padding-right: 0.1rem; +} + +.live-view-jump-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.live-view-jump-row .monitoring-jump-btn { + margin-left: auto; + margin-top: auto; +} + + .wan-filter-badge { display: inline-flex; align-items: center; @@ -17037,6 +17161,15 @@ tr.stats-row-filtered { min-width: 0; padding: 0 12px; } + + .isp-health-toolbar { + flex-wrap: wrap; + row-gap: 0.5rem; + } + + .isp-health-toolbar .wan-selector { + flex-basis: 100%; + } } .isp-health-toolbar .btn-label-narrow { @@ -19240,61 +19373,12 @@ body.kiosk-mode .status-indicators { overflow-x: auto; } -.inline-add-form { - margin-top: 1rem; - padding: 1rem; - background: var(--bg-tertiary); - border-radius: 8px; -} - -.inline-add-form-row { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: flex-end; -} - -.inline-add-form-field { - flex: 1 1 150px; - min-width: 0; -} - -.inline-add-form-field label { - display: block; - margin-bottom: 0.25rem; - font-size: 0.8rem; -} - -.inline-add-form-field input, -.inline-add-form-field select { - width: 100%; -} - -.inline-add-form-actions { - display: flex; - gap: 0.5rem; -} - .form-error { color: var(--danger-color); margin-top: 0.5rem; font-size: 0.85rem; } -@media (max-width: 768px) { - .inline-add-form-field { - flex-basis: 100%; - } - - .inline-add-form-actions { - flex-basis: 100%; - } - - .inline-add-form-actions .btn { - flex: 1; - } -} - .site-card-agents { margin-left: auto; color: var(--text-secondary); diff --git a/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js index f505566983..8f86722522 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/cellular-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#4269d0', '#efb118', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js b/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js index 42a0ec0b64..e5fd19e215 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/chart-tooltip.js @@ -85,16 +85,31 @@ export function valueSortedTooltip({ series, dataPointIndex, w }, options = {}) // The axis formatter by default, since it is already right for the chart. An explicit one // is for charts whose axis deliberately omits a unit that the tooltip should still carry - // ISP Health's axis reads "12.4" under an "ms" title, but its tooltip says "12.4 ms". - const fmt = options.format ?? w.config.yaxis?.[0]?.labels?.formatter ?? (v => v); + // An ARRAY of formatters addresses series by index, for a chart whose series do not share a + // unit - throughput beside loss beside latency, where one formatter cannot be right for all. + const fmtOpt = options.format ?? w.config.yaxis?.[0]?.labels?.formatter ?? (v => v); + const fmtFor = i => Array.isArray(fmtOpt) ? (fmtOpt[i] ?? (v => v)) : fmtOpt; const rows = []; let ts = null; for (let i = 0; i < series.length; i++) { const v = series[i]?.[dataPointIndex]; if (v == null) continue; ts ??= w.globals.seriesX[i]?.[dataPointIndex]; - rows.push({ name: w.globals.seriesNames[i], color: w.globals.colors[i % w.globals.colors.length], v }); + rows.push({ name: w.globals.seriesNames[i], color: w.globals.colors[i % w.globals.colors.length], v, i }); + } + // Sorted by value only where the series share a SCALE - several WANs' throughput on one axis, + // several targets' latency on one axis - because then the number and the height on the chart + // rank the same way. Series on different axes must not be sorted: bits per second, a + // percentage and milliseconds have no common order, so ranking them by raw magnitude puts + // throughput on top forever and tells the reader nothing. Those pass sort: false and keep + // their fixed places, which is also where the eye expects to find them. + if (options.sort !== false) rows.sort((a, b) => b.v - a.v); + else if (Array.isArray(options.order)) { + // Reading order, not series order: the chart draws throughput first because it is the + // backdrop, but the pair a reader compares is RTT and loss, so those sit together. + const rank = new Map(options.order.map((n, i) => [n, i])); + rows.sort((a, b) => (rank.get(a.name) ?? 99) - (rank.get(b.name) ?? 99)); } - rows.sort((a, b) => b.v - a.v); // Seconds by default, because the Monitoring charts poll fast enough for them to mean // something. A chart on a slower cadence can drop them - ISP Health polls once a minute over a // 24 hour window, where a seconds field is noise and was never shown before this was shared. @@ -108,7 +123,7 @@ export function valueSortedTooltip({ series, dataPointIndex, w }, options = {}) + '' + '
' + '' + esc(r.name) + ': ' - + '' + esc(fmt(r.v)) + '' + + '' + esc(fmtFor(r.i)(r.v)) + '' + '
').join(''); } diff --git a/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js index 8dd60acc4e..41ada747f9 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/cm-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#4269d0', '#efb118', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js b/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js index 083769214c..28607510ab 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/collapse-reveal.js @@ -64,6 +64,16 @@ document.addEventListener('click', function (e) { var header = e.target.closest && e.target.closest('.card-header-collapsible'); if (!header) return; + // A control living in the header - a filter pill, a link, a badge - does its own thing, so + // on an already-open card there is nothing newly revealed to follow. On a CLOSED one the + // same click may well open it, and then following is the whole point: filtering a card you + // cannot see is the one case where the view should move. Decided from the target because + // this listener runs in the CAPTURE phase, where a component's own stopPropagation cannot + // reach it. + var control = e.target.closest('button, a, select, input, label'); + var opened = header.nextElementSibling; + var wasOpen = !!opened && opened.classList.contains('expanded'); + if (control && header.contains(control) && wasOpen) return; // Blazor re-renders before the transition starts, so begin on the next frame and run for a // little longer than the 0.25s expand to catch the final pixels. var until = performance.now() + FOLLOW_MS; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js index 23fc50e9e0..94fff60c72 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/device-health-charts.js @@ -3,7 +3,7 @@ // device-health-charts, and future chart sets share one implementation. import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; // A device answers SNMP but can still miss a single field on a poll - a temperature or diff --git a/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js index 237a006fef..e88e7da6b7 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/isp-health-charts.js @@ -3,7 +3,7 @@ // render as shaded x-axis ranges, path shifts as annotation lines. import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = ['#2ba89a', '#3b82f6', '#a78bfa', '#ef5858', '#f59e0b', '#10b981']; @@ -183,7 +183,12 @@ async function loadAndUpdate() { fetchController = new AbortController(); try { let url = '/api/monitoring/isp-health/asn-series'; - if (win) url += `?from=${encodeURIComponent(win.from)}&to=${encodeURIComponent(win.to)}`; + const params = []; + if (win) params.push(`from=${encodeURIComponent(win.from)}`, `to=${encodeURIComponent(win.to)}`); + // Selected WAN (null = primary): the panel's WAN selector routes the chart to the + // matching per-WAN report so lines and event annotations always agree with the score. + if (wanKey) params.push(`wan=${encodeURIComponent(wanKey)}`); + if (params.length) url += `?${params.join('&')}`; const resp = await fetch(url, { credentials: 'same-origin', signal: fetchController.signal }); if (!resp.ok) return; const json = await resp.json(); @@ -284,9 +289,14 @@ function renderBadges() { } } +// Returns whether it actually mounted. The panel renders the chart element only alongside a +// loaded report, so during a WAN switch (spinner up, report body out of the DOM) there is +// nothing to mount into - that case returns false, without throwing, so the caller can leave +// its mounted flag down and retry on a later render instead of recording a chart that was +// never built. export async function mount(elId, fromISO = null, toISO = null, hidden = null) { const el = document.getElementById(elId); - if (!el) return; + if (!el) return false; win = (fromISO && toISO) ? { from: fromISO, to: toISO } : null; hiddenTypes = new Set(hidden || []); @@ -311,6 +321,7 @@ export async function mount(elId, fromISO = null, toISO = null, hidden = null) { await loadAndUpdate(); // Guarded at the tick, not inside loadAndUpdate, so an explicit reload is never suppressed. pollTimer = setInterval(() => { if (!tooltipHeld(el)) loadAndUpdate(); }, POLL_MS); + return true; } export async function reload() { @@ -327,6 +338,20 @@ export async function setWindow(fromISO, toISO) { await loadAndUpdate(); } +// NOT reset by unmount, on purpose: the panel drops the chart while it switches WAN (the +// element leaves the DOM with the report body) and pushes the new key before the re-mount, +// so the fresh mount's first fetch reads it and loads the right WAN straight away. +let wanKey = null; + +export function setWan(w) { + const next = w || null; + // Same key is a no-op rather than a reload: the post-mount push repeats the key the mount + // just fetched with, and refetching it would only abort-and-redo an identical request. + if (next === wanKey) return; + wanKey = next; + loadAndUpdate(); +} + export function setDotNetRef(ref) { dotNetRef = ref; } diff --git a/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js b/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js index 33d45e7033..c54aca8d18 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js @@ -3698,8 +3698,9 @@ export class LanFlowMap { if (g.userData?.cloud) { const cloud = g.userData.cloud; - // TODO: enable for all WANs once multi-WAN upstream tracing is implemented - if (cloud.kind === 0 && cloud.wanInterface === this._snapshot?.primaryWanInterface) { + // Every access-ISP globe, not just the primary's: upstream discovery runs per WAN now, + // and the menu carries the WAN so it opens on that globe's own discovery. + if (cloud.kind === 0) { this._showCloudContextMenu(e.clientX, e.clientY, cloud); } return; @@ -3742,7 +3743,9 @@ export class LanFlowMap { e.stopPropagation(); this._dismissContextMenu(); if (this._dotnetRef) { - this._dotnetRef.invokeMethodAsync('NavigateToUpstreamDiscovery'); + // The globe knows which WAN it is, so the panel opens on that WAN's discovery + // rather than on the primary's - which on a secondary globe is the wrong panel. + this._dotnetRef.invokeMethodAsync('NavigateToUpstreamDiscoveryForWan', cloud.wanInterface || null); } }); menu.appendChild(item); diff --git a/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js index 1e33fd7436..3f16e65c8e 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/latency-charts.js @@ -6,7 +6,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#7EB26D', '#EAB839', '#6ED0E0', '#EF843C', '#E24D42', '#1F78C1']; @@ -48,6 +48,41 @@ let visibilityObserver = null; let isInViewport = true; let lastFetchData = null; let savedState = null; +// Per-WAN scope, set by Blazor (which owns the WAN pill bar and its visibility gate). +// null = no scoping at all: single-WAN sites never reach this code path and render +// exactly as before. Shape: { primaryKey, selected: [wanKey...], tokens: {key: 'WAN1'} }; +// selecting every key is comparison mode (per-host color kept, per-WAN dash pattern). +let wanScope = null; +// Dash patterns by WAN order: primary solid, then visibly distinct patterns per extra WAN. +const WAN_DASH_PATTERNS = [0, 6, 2, 9]; + +function effectiveWanKey(t) { + // Unstamped targets are primary-path measurements (same rule as the server side). + return (t.wanInterface || wanScope?.primaryKey || 'wan').toLowerCase(); +} + +function wanComparisonActive() { + return !!wanScope && wanScope.selected.length > 1; +} + +function filterTargetsToWanScope(targets) { + if (!wanScope) return targets; + const sel = new Set(wanScope.selected.map(k => k.toLowerCase())); + return targets.filter(t => sel.has(effectiveWanKey(t))); +} + +function wanDisplayName(t) { + if (!wanComparisonActive()) return t.name; + const key = effectiveWanKey(t); + const token = wanScope.tokens?.[key] || key.toUpperCase(); + return `${t.name} (${token})`; +} + +function wanDashFor(t) { + if (!wanComparisonActive()) return 0; + const idx = wanScope.selected.map(k => k.toLowerCase()).indexOf(effectiveWanKey(t)); + return WAN_DASH_PATTERNS[Math.max(0, idx) % WAN_DASH_PATTERNS.length]; +} let investigateMarker = null; // { startMs, endMs, label, loaded } while investigating a loss event // Highlight the investigated loss event on the RTT and loss charts, mirroring the @@ -287,6 +322,15 @@ const SHARED_OUTAGE_MIN_TARGETS = 3; // live in Blazor (Monitoring.razor), which has the target metadata. Entirely best-effort: // wrapped so a failure here can never disturb chart rendering, and a no-op until Blazor has // handed us its DotNet reference via window.__netoptLatencyRef. +// A ?at= in the URL says where a link wanted this window. The moment the user moves it themselves +// that stops being true, so Blazor is told to drop the parameter - otherwise a reload or the back +// button drags them back to the linked instant. Called from the user's own handlers only, never +// from frameMoment/frameTrailing, which ARE the link landing. Best-effort, like the hints below. +function notifyTimelineMoved() { + try { window.__netoptLatencyRef?.invokeMethodAsync('OnTimelineMovedByUser'); } + catch { /* no ref yet, or the circuit is gone - the window still moved */ } +} + function notifyLanFlakyHints(data) { try { const ref = window.__netoptLatencyRef; @@ -340,32 +384,41 @@ async function loadAndUpdate() { const data = await fetchData(); if (!data || !data.targets) return; - targetMeta = data.targets.map(t => ({ + // WAN scoping is client-side over the full per-type payload: the fetch stays shared + // across WAN selections, and comparison mode simply keeps every WAN's rows. Twin rows + // of one host share a name (and therefore a color); the WAN suffix + dash pattern + // are what tells them apart in comparison mode. + const scopedTargets = filterTargetsToWanScope(data.targets); + + targetMeta = scopedTargets.map(t => ({ id: t.targetId, - name: t.name, + name: wanDisplayName(t), color: hashColor(t.name), })); - const rttSeries = data.targets.map(t => ({ - name: t.name, + const rttSeries = scopedTargets.map(t => ({ + name: wanDisplayName(t), color: hashColor(t.name), data: (t.rtt || []).map(p => ({ x: new Date(p.time).getTime(), y: p.value })), })); - const lossSeries = data.targets.map(t => ({ - name: t.name, + const lossSeries = scopedTargets.map(t => ({ + name: wanDisplayName(t), color: hashColor(t.name), data: (t.loss || []).map(p => ({ x: new Date(p.time).getTime(), y: p.value })), })); - lastFetchData = data; + lastFetchData = { ...data, targets: scopedTargets }; + const dashArray = scopedTargets.map(wanDashFor); if (rttChart) rttChart.updateSeries(rttSeries, false); if (lossChart) lossChart.updateSeries(lossSeries, false); const annotations = buildInvestigateAnnotations(); - if (rttChart) rttChart.updateOptions({ annotations }, false, false); - if (lossChart) lossChart.updateOptions({ annotations }, false, false); + if (rttChart) rttChart.updateOptions({ annotations, stroke: { curve: 'smooth', width: 2, dashArray } }, false, false); + // Same dashes as the RTT chart: twins of one host share its color, so the pattern is the only + // thing telling their WANs apart here too. + if (lossChart) lossChart.updateOptions({ annotations, stroke: { curve: 'smooth', width: 2, dashArray } }, false, false); updateChartVisibility(); @@ -383,7 +436,13 @@ async function loadAndUpdate() { if (wanCard) wanCard.style.display = showWanRate ? '' : 'none'; if (showWanRate && wanRateChart) { - const timeParams = buildQueryParams().replace(/category=[^&]*&?/, ''); + let timeParams = buildQueryParams().replace(/category=[^&]*&?/, ''); + // The throughput reference follows the WAN filter: the solo-selected WAN, or the + // primary while comparing (never a sum - Blazor labels the card accordingly). + if (wanScope) { + const focused = wanScope.selected.length === 1 ? wanScope.selected[0] : wanScope.primaryKey; + if (focused) timeParams += `${timeParams ? '&' : ''}wan=${encodeURIComponent(focused)}`; + } try { const resp = await fetch(`/api/monitoring/wan-rate-chart?${timeParams}`, { credentials: 'same-origin' }); if (resp.ok) { @@ -425,7 +484,7 @@ function renderStatsTable(container, showAll) { const rtt = computeStats(rttVals); const loss = computeStats(lossVals); const meta = targetMeta.find(m => m.id === t.targetId); - return { id: t.targetId, label: t.name, color: meta?.color || '#9ca3af', + return { id: t.targetId, label: meta?.name || t.name, color: meta?.color || '#9ca3af', visible: meta && visibility[meta.id] !== false, values: [rtt?.mean, rtt?.min, rtt?.max, rtt?.p95, rtt?.p99, loss?.mean, loss?.max] }; }); @@ -560,6 +619,7 @@ function updateCustomLabel(container) { function applyDragZoom(xaxis) { const container = document.getElementById(containerId); if (container && xaxis && Number.isFinite(xaxis.min) && Number.isFinite(xaxis.max) && xaxis.min < xaxis.max) { + notifyTimelineMoved(); customFrom = new Date(xaxis.min); customTo = new Date(xaxis.max); isCustomRange = true; @@ -589,15 +649,23 @@ function getEffectiveTo() { return null; } -export async function mount(elId) { +// initialWanScope arrives with the mount rather than in a call behind it: this module is imported +// asynchronously, so a separate push can land before the import resolves and be dropped silently. +// Taking it here also survives the unmount/remount of leaving the tab and returning. +export async function mount(elId, initialWanScope, initialCategory) { containerId = elId; const container = document.getElementById(elId); if (!container) return; - // Seed the category from whichever filter button the server rendered active (LAN by - // default, ISP when the site has no LAN targets), so the initial load matches the UI. - const activeCategoryBtn = container.querySelector('[data-category].active'); - if (activeCategoryBtn) currentCategory = activeCategoryBtn.dataset.category; + setWanScope(initialWanScope); + + // The opening category comes from the server, which knows whether the WANs on screen have any + // LAN targets. From here the module owns it: the buttons carry no server-rendered active class, + // so a re-render of the header cannot put a stale one back while this still holds another. + if (initialCategory) currentCategory = initialCategory; + container.querySelectorAll('[data-category]').forEach(b => { + b.classList.toggle('active', b.dataset.category === currentCategory); + }); const rttEl = container.querySelector('.latency-rtt-chart'); const lossEl = container.querySelector('.latency-loss-chart'); @@ -634,12 +702,12 @@ export async function mount(elId) { // Preset range buttons container.querySelectorAll('[data-range]').forEach(btn => { - btn.addEventListener('click', () => selectPresetRange(container, parseInt(btn.dataset.range))); + btn.addEventListener('click', () => { notifyTimelineMoved(); selectPresetRange(container, parseInt(btn.dataset.range)); }); }); // Shift arrows container.querySelectorAll('[data-shift]').forEach(btn => { - btn.addEventListener('click', () => shiftWindow(btn.dataset.shift)); + btn.addEventListener('click', () => { notifyTimelineMoved(); shiftWindow(btn.dataset.shift); }); }); // Custom range popover @@ -670,6 +738,7 @@ export async function mount(elId) { const from = fromInput?.value ? new Date(fromInput.value) : null; const to = toInput?.value ? new Date(toInput.value) : null; if (!from || !to || isNaN(from) || isNaN(to) || from >= to) return; + notifyTimelineMoved(); customFrom = from; customTo = to; isCustomRange = true; @@ -694,23 +763,19 @@ export async function mount(elId) { startPoll(); } -export function navigateToTime(isoTimestamp, category, label, loaded, eventStartIso, eventEndIso) { - if (!savedState) { - savedState = { category: currentCategory, rangeHours: currentRangeHours, - customFrom, customTo, isCustomRange, windowOffset, visibility: { ...visibility } }; - } - const ts = new Date(isoTimestamp).getTime(); - investigateMarker = label - ? { - startMs: eventStartIso ? new Date(eventStartIso).getTime() : ts, - endMs: eventEndIso ? new Date(eventEndIso).getTime() : ts, - label, - loaded: !!loaded, - } - : null; - const windowMs = 10 * 60000; // 10 min window centered on event - customFrom = new Date(ts - windowMs); - customTo = new Date(ts + windowMs); +// Frames a custom window centered on one instant and switches category, stashing the view it +// replaced so leaving can put the user's own filter back. Shared by the two ways in - the +// Investigate flow below and the jump from the Live tab - because centering, the range-button +// bookkeeping and the save-once rule are the same job for both; only the marker differs. +function stashView() { + if (savedState) return; + savedState = { category: currentCategory, rangeHours: currentRangeHours, + customFrom, customTo, isCustomRange, windowOffset, visibility: { ...visibility } }; +} + +function frameCustomWindow(ts, category, halfWindowMs) { + customFrom = new Date(ts - halfWindowMs); + customTo = new Date(ts + halfWindowMs); isCustomRange = true; windowOffset = 0; if (category) currentCategory = category; @@ -729,6 +794,69 @@ export function navigateToTime(isoTimestamp, category, label, loaded, eventStart startPoll(); } +export function navigateToTime(isoTimestamp, category, label, loaded, eventStartIso, eventEndIso) { + stashView(); + const ts = new Date(isoTimestamp).getTime(); + investigateMarker = label + ? { + startMs: eventStartIso ? new Date(eventStartIso).getTime() : ts, + endMs: eventEndIso ? new Date(eventEndIso).getTime() : ts, + label, + loaded: !!loaded, + } + : null; + frameCustomWindow(ts, category, 10 * 60000); // 10 min either side of the event +} + +/** + * Frames the window on a moment carried in from the Live tab while it was PARKED on that instant: + * 7.5 minutes either side, the same 15 minutes wide as the live jump below, so the two arrive at + * the same zoom and an event looks like itself whichever way you came in. + * Deliberately NOT navigateToTime - that is the Investigate flow, and it carries an event marker + * and label this has no business drawing. Same window machinery, no marker. + */ +export function frameMoment(isoTimestamp, category) { + investigateMarker = null; + frameCustomWindow(new Date(isoTimestamp).getTime(), category, 7.5 * 60000); +} + +/** + * Frames a trailing 15-minute window for a jump made while the Live tab was LIVE rather than + * parked. Centering on "now" would leave half the window in the future and freeze the chart at + * the instant of the click - and a frozen chart and a quiet network look identical, so someone + * who was watching would end up reading a still frame as the present. Someone who was watching + * carries on watching. 15m is also the shortest preset that keeps polling: startPoll stands down + * on custom ranges, so a trailing custom window would be the frozen chart this avoids. + */ +export function frameTrailing(category) { + investigateMarker = null; + if (category) currentCategory = category; + const container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll('[data-category]').forEach(b => { + b.classList.toggle('active', b.dataset.category === currentCategory); + }); + selectPresetRange(container, 0); +} + +/** + * The view the Live tab needs to reproduce this one: the instant at the CENTER of the window on + * screen, plus the category being charted. Center rather than either edge because the spike + * someone wants to watch play back is the thing they framed the window around, and a playback + * position at the edge puts it half a window away. A plain trailing range keeps no explicit + * bounds - getEffectiveFrom/To answer null for it - so its window is derived from the range. + */ +export function currentView() { + const from = getEffectiveFrom(); + const to = getEffectiveTo(); + const endMs = to ? to.getTime() : Date.now(); + const startMs = from ? from.getTime() : endMs - (RANGE_MS[currentRangeHours] || 3600000); + return { + atIso: new Date((startMs + endMs) / 2).toISOString(), + category: currentCategory, + }; +} + export function restoreState() { if (!savedState) return; investigateMarker = null; @@ -762,6 +890,22 @@ export function restoreState() { startPoll(); } +// Blazor pushes the WAN pill bar's state here. Passing null clears scoping entirely +// (the gate is closed - single WAN, no contexts). +export function setWanScope(scope) { + wanScope = scope && Array.isArray(scope.selected) && scope.selected.length > 0 ? scope : null; + visibility = {}; + // LAN targets belong to the site, not to a WAN, so a secondary WAN has none - staying on the + // LAN category there draws an empty chart. Only ever leave a category that has nothing to show; + // coming back to a WAN that does have LAN targets leaves the choice alone, because by then it + // may be the one the user made. + if (wanScope && wanScope.hasLan === false && currentCategory === 'Fabric') { + setCategory('AccessIsp'); + return; + } + loadAndUpdate(); +} + export function setCategory(cat) { currentCategory = cat; const container = document.getElementById(containerId); @@ -808,4 +952,5 @@ export function unmount() { savedState = null; investigateMarker = null; isInViewport = true; + wanScope = null; } diff --git a/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js index 77662f9a50..7750335981 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/ont-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#4269d0', '#efb118', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js index bbce0e364e..0130c8ed44 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/sfp-charts.js @@ -3,7 +3,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#7EB26D', '#EAB839', '#6ED0E0', '#EF843C', '#E24D42', '#1F78C1']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/site-context.js b/src/NetworkOptimizer.Web/wwwroot/js/site-context.js index f9e58bf904..0785dd5d0e 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/site-context.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/site-context.js @@ -208,3 +208,13 @@ window.noHighlightTarget = function (id, block, radius) { noHighlight(id, block, // A table row: tinted, because an offset ring around a row collides with the rows either side. window.noHighlightRow = function (id, block) { noHighlight(id, block || 'center', 'nav-highlight-row'); }; + +// Scroll with no ring, for something the user just caused to appear. The ring answers "which of +// these is the one you were sent to" - a question that only exists when a link brought you from +// somewhere else. A form that opened under the button you pressed needs no such answer, and +// flagging it would say something arrived that the user already knows they asked for. +window.noScrollTo = function (id, block) { + var el = document.getElementById(id); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: block || 'start' }); +}; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js b/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js index ddef49d303..fb6e4da056 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/starlink-charts.js @@ -4,7 +4,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import { computeStats, renderStatsTable as renderTable } from './chart-stats.js?v=4'; -import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=7'; +import { valueSortedTooltip, tooltipHeld, alignedPoints } from './chart-tooltip.js?v=8'; import { renderFilterReset, isFiltered } from './chart-filter.js?v=4'; const PALETTE = window.Apex?.colors || ['#2ba89a', '#3b82f6', '#a78bfa', '#ef5858', '#f59e0b', '#10b981']; diff --git a/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js b/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js index 8d06603b93..9b1a2f2c8d 100644 --- a/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js +++ b/src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js @@ -4,6 +4,7 @@ import ApexCharts from '/_content/Blazor-ApexCharts/js/apexcharts.esm.js'; import * as flowData from './lan-flow-data.js?v=7'; +import { valueSortedTooltip } from './chart-tooltip.js?v=8'; const HISTORY_MINUTES = 5; // Poll at twice the site's SNMP sample rate so no sample is missed when the two @@ -33,6 +34,12 @@ let pollTimer = null; let scrollTimer = null; let backfillTimer = null; let buffer = []; +// Comparison mode: [{ key, label }] for the WANs on screen, and one buffer per WAN keyed the same +// way. Empty for the ordinary single-WAN case, which never reads either. +let compareWans = []; +let compareBuffers = new Map(); +// Dash patterns by position, so a WAN keeps its pattern for as long as the selection holds. +const WAN_DASH = [0, 6, 2, 10, 4, 8]; let elId = null; let visHandler = null; let mountGen = 0; @@ -184,6 +191,9 @@ function removeMouseTracking() { lastMouse = null; } +/** True while several WANs are on screen together. */ +function comparing() { return compareWans.length > 1; } + function buildOpts() { return { chart: { @@ -219,28 +229,47 @@ function buildOpts() { }, }, }, - series: [ - { name: 'Download', type: 'area', data: [] }, - { name: 'Upload', type: 'area', data: [] }, - { name: 'Loss', type: 'area', data: [] }, - { name: 'RTT', type: 'line', data: [] }, - ], - colors: [COLOR_DL, COLOR_UL, COLOR_LOSS, COLOR_RTT], + series: comparing() + ? compareWans.flatMap(w => ([ + { name: `${w.label} down`, type: 'area', data: [] }, + { name: `${w.label} up`, type: 'area', data: [] }, + ])) + : [ + { name: 'Download', type: 'area', data: [] }, + { name: 'Upload', type: 'area', data: [] }, + { name: 'Loss', type: 'area', data: [] }, + { name: 'RTT', type: 'line', data: [] }, + ], + // Comparing: the COLOUR still says which direction a line is, because that is what the eye + // is sorting for, and the dash pattern says which WAN. Loss and RTT drop out of the chart + // in this mode - they are per-WAN figures in the stat cards above, and four lines per WAN + // is not a comparison anyone can read. + colors: comparing() + ? compareWans.flatMap(() => [COLOR_DL, COLOR_UL]) + : [COLOR_DL, COLOR_UL, COLOR_LOSS, COLOR_RTT], stroke: { curve: 'smooth', - width: [2, 2, 1, 1], - dashArray: [0, 0, 0, 6], + width: comparing() ? compareWans.flatMap(() => [2, 2]) : [2, 2, 1, 1], + dashArray: comparing() + ? compareWans.flatMap((_, i) => { const d = WAN_DASH[i % WAN_DASH.length]; return [d, d]; }) + : [0, 0, 0, 6], }, - fill: { - type: ['gradient', 'gradient', 'gradient', 'solid'], - opacity: [1, 1, 1, 0], - gradient: { - shadeIntensity: 0.4, - opacityFrom: [0.55, 0.45, 0.5, 0], - opacityTo: [0.1, 0.08, 0.05, 0], - stops: [0, 95], + fill: comparing() + ? { + // Flat translucent fills: several overlapping gradients turn the plot into mud. + type: compareWans.flatMap(() => ['solid', 'solid']), + opacity: compareWans.flatMap(() => [0.12, 0.10]), + } + : { + type: ['gradient', 'gradient', 'gradient', 'solid'], + opacity: [1, 1, 1, 0], + gradient: { + shadeIntensity: 0.4, + opacityFrom: [0.55, 0.45, 0.5, 0], + opacityTo: [0.1, 0.08, 0.05, 0], + stops: [0, 95], + }, }, - }, markers: { size: 0 }, dataLabels: { enabled: false }, xaxis: { @@ -256,7 +285,23 @@ function buildOpts() { axisBorder: { show: false }, axisTicks: { show: false }, }, - yaxis: [ + // ONE axis for every throughput series while comparing - not one per series. A yaxis + // ARRAY is laid out entry by entry even where show is false, so 2N entries stole the plot + // width and pushed the chart past its container. A single object is also what lets the + // WANs be read against the same scale. + yaxis: comparing() + ? { + min: 0, + max: v => v * 1.1, + labels: { + style: { colors: '#9ca3af', fontSize: '10px' }, + formatter: v => formatBps(v), + offsetX: -10, + }, + axisBorder: { show: false }, + axisTicks: { show: false }, + } + : [ { seriesName: 'Download', min: 0, @@ -296,33 +341,73 @@ function buildOpts() { borderColor: '#374151', strokeDashArray: 3, // Bottom padding holds the strip below the axis where the - // annotation time labels render. - padding: { left: 3, right: 0, top: -8, bottom: 12 }, + // annotation time labels render. Comparing has no opposite RTT axis holding the right + // edge open, so it pads its own or the newest sample sits on the container's edge. + padding: comparing() + ? { left: 3, right: 26, top: -8, bottom: 12 } + : { left: 3, right: 0, top: -8, bottom: 12 }, xaxis: { lines: { show: false } }, }, responsive: [{ breakpoint: 1024, options: { - yaxis: [ - { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, - { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, - { seriesName: 'Loss', opposite: true, show: false, min: 0, max: v => Math.max(v * 1.2, 10) }, - { seriesName: 'RTT', opposite: true, show: false, min: 0 }, - ], + // One entry per series in BOTH modes: a mismatched length leaves ApexCharts + // holding axes for series that do not exist, and the plot escapes its container. + yaxis: comparing() + ? { show: false, min: 0, max: v => v * 1.1 } + : [ + { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, + { seriesName: 'Download', show: false, min: 0, max: v => v * 1.1 }, + { seriesName: 'Loss', opposite: true, show: false, min: 0, max: v => Math.max(v * 1.2, 10) }, + { seriesName: 'RTT', opposite: true, show: false, min: 0 }, + ], grid: { padding: { left: -5, right: -5, top: -8, bottom: 12 } }, }, }], legend: { show: false }, tooltip: { theme: 'dark', + // Shared, so every line's value is stacked at the cursor's instant. There is no way to + // hover one line out of 2N overlapping ones, so a per-series tooltip would be unusable + // here - and the stack is how a WAN is told from its neighbour, since the chart has no + // legend and the series names carry the WAN. shared: true, x: { format: 'HH:mm:ss', formatter: (val) => new Date(val).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }, - y: [ - { formatter: v => formatBps(v) }, - { formatter: v => formatBps(v) }, - { formatter: v => v != null ? v.toFixed(2) + '%' : '-' }, - { formatter: v => v != null ? v.toFixed(1) + ' ms' : '-' }, - ], + // Comparing uses the same custom tooltip as the rest of the Monitoring charts: it + // stacks every series at the hovered instant sorted by value, and paints its own + // hover dots (the library's markers are the flaky ones, and any non-zero size puts a + // permanent dot on every sample). An explicit formatter because the throughput axis + // is an object here, not the array the helper reads by default. + // The same custom tooltip either way - it stacks every series at the hovered instant + // and paints its own small hover dots. What differs is the ordering: comparing puts + // the WANs on ONE axis, so their values rank and the biggest belongs on top; the + // single-WAN chart spreads four series across four axes, where bits per second, a + // percentage and milliseconds cannot be ranked against each other, so they keep a + // fixed order instead. + custom: comparing() + ? (ctx) => valueSortedTooltip(ctx, { format: v => formatBps(v) }) + : (ctx) => valueSortedTooltip(ctx, { + sort: false, + // order is how they READ; format is indexed by SERIES position (Loss is + // series 2, RTT series 3) - the two lists are deliberately not parallel. + order: ['Download', 'Upload', 'RTT', 'Loss'], + format: [ + v => formatBps(v), + v => formatBps(v), + v => v != null ? v.toFixed(2) + '%' : '-', + v => v != null ? v.toFixed(1) + ' ms' : '-', + ], + }), + // Positional, one per series: a short array leaves the rest of the series with no + // formatter at all, which renders raw bits per second. + y: comparing() + ? compareWans.flatMap(() => [{ formatter: v => formatBps(v) }, { formatter: v => formatBps(v) }]) + : [ + { formatter: v => formatBps(v) }, + { formatter: v => formatBps(v) }, + { formatter: v => v != null ? v.toFixed(2) + '%' : '-' }, + { formatter: v => v != null ? v.toFixed(1) + ' ms' : '-' }, + ], }, noData: { text: 'Loading...', style: { color: '#64748b', fontSize: '13px' } }, }; @@ -335,6 +420,49 @@ function buildOpts() { // the viewport, so half-view panels and mobile - which constrain the chart // below full width while the viewport stays wide - step out to a sparser // grid instead of colliding. Full width keeps the dense 20s grid. +// Comparison series on ONE time grid, the union of every WAN's timestamps, with a null wherever a +// WAN has no reading for an instant. +// +// Each WAN's history is fetched separately and comes back on its own timestamps, so series built +// straight from those buffers share no x values. ApexCharts addresses series by data-point INDEX, +// so a shared tooltip then has nothing to print for the WANs that lack a point at the hovered +// instant - the reading you want appears only when the pointer happens to find that WAN's own line, +// which is hunting rather than reading. A common grid gives every series the same indices. +// +// Nulls rather than dropped points, for the reason alignedPoints exists in chart-tooltip.js: a +// missing point makes its neighbours adjacent and the stroke spans a gap that is really there, +// while a null ends one segment and starts another. valueSortedTooltip skips nulls, so a WAN with +// no reading costs no row. +function compareSeries() { + const times = [...new Set(compareWans.flatMap(w => + (compareBuffers.get(w.key) || []).map(p => p.time)))].sort((a, b) => a - b); + return compareWans.flatMap(w => { + const pts = (compareBuffers.get(w.key) || []).slice().sort((a, b) => a.time - b.time); + // As-of, not exact. Live, one tick stamps every WAN with the same timestamp, so exact + // matching lined up; a historic window is fetched per WAN and each comes back on its own + // timestamps, so exact matching left every series null at the other WANs' instants. The + // tooltip still read correctly - it skips nulls - but each line became isolated points, + // and with markers.size 0 an isolated point draws nothing. Hence values without lines. + // + // Each grid instant takes that WAN's newest reading at or before it, within a tolerance + // scaled to the WAN's own cadence, so a genuine outage still breaks the line rather than + // carrying a stale value across it. + const gaps = pts.slice(1).map((p, i) => p.time - pts[i].time).sort((a, b) => a - b); + const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0; + const tolerance = Math.max(median * 2.5, 15000); + let i = 0, last = null; + const rows = times.map(t => { + while (i < pts.length && pts[i].time <= t) last = pts[i++]; + return last && t - last.time <= tolerance ? last : null; + }); + const on = key => rows.map((p, idx) => ({ x: times[idx], y: p?.[key] ?? null })); + return [ + { name: `${w.label} down`, data: on('download') }, + { name: `${w.label} up`, data: on('upload') }, + ]; + }); +} + function buildTimeTicks(minMs, maxMs) { const width = document.getElementById(elId)?.clientWidth || 800; // An HH:mm:ss label is ~46px at 10px; budget 64px per slot for breathing @@ -370,11 +498,17 @@ function buildTimeTicks(minMs, maxMs) { return ticks; } +// The RTT axis ceiling. Headroom over the p95 keeps an ordinary chart from filling its pane +// edge to edge, but the p95 alone CLIPPED the thing most worth seeing: a spike is by definition +// above the 95th percentile, so the scale was set from the calm band and the peak was drawn off +// the top of the axis. Taking whichever is greater keeps the roomy scale when nothing is +// happening and lets the axis grow when something is. function rttYMax() { const rtts = buffer.map(p => p.rtt).filter(v => v != null && v > 0).sort((a, b) => a - b); if (rtts.length === 0) return 10; - const p95 = rtts[Math.floor(rtts.length * 0.95)]; - return Math.ceil((p95 * 1.5) / 10) * 10; + const p95 = rtts[Math.min(rtts.length - 1, Math.floor(rtts.length * 0.95))]; + const peak = rtts[rtts.length - 1]; + return Math.ceil(Math.max(p95 * 1.5, peak * 1.1) / 10) * 10; } function buildSeriesData() { @@ -388,15 +522,29 @@ function buildSeriesData() { } function updateChart() { - if (!chart || buffer.length === 0) return; + if (!chart) return; + if (!comparing() && buffer.length === 0) return; if (Date.now() > clickRenderUntil && tooltipShowing()) return; const now = Date.now(); const pts = buildSeriesData(); - chart.updateOptions({ - xaxis: { min: now - HISTORY_MINUTES * 60000, max: now }, - yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], - annotations: { xaxis: buildTimeTicks(now - HISTORY_MINUTES * 60000, now) }, - }, false, false, false); + // Rescaling the RTT axis is a single-WAN concern: there IS no fourth axis while comparing, + // and rebuilding the array to a fixed length of four truncated the axes for three or more + // WANs and stamped an RTT-sized ceiling (~10) onto a throughput axis for two - which is what + // clipped the taller WAN's line. Comparison axes are set at mount and left alone. + chart.updateOptions(comparing() + ? { + xaxis: { min: now - HISTORY_MINUTES * 60000, max: now }, + annotations: { xaxis: buildTimeTicks(now - HISTORY_MINUTES * 60000, now) }, + } + : { + xaxis: { min: now - HISTORY_MINUTES * 60000, max: now }, + yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], + annotations: { xaxis: buildTimeTicks(now - HISTORY_MINUTES * 60000, now) }, + }, false, false, false); + if (comparing()) { + chart.updateSeries(compareSeries(), false); + return; + } chart.updateSeries([ { name: 'Download', data: pts.map(p => ({ x: p.time, y: p.download })) }, { name: 'Upload', data: pts.map(p => ({ x: p.time, y: p.upload })) }, @@ -405,13 +553,21 @@ function updateChart() { ], false); } +// Which WAN's counters this chart is showing. Null is the primary, which is what every caller +// meant before the chart could be pointed at another WAN - so an absent scope has to keep +// producing the exact request it always did. +let wanScope = null; + +function historyUrl(from, to) { + const base = `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`; + return wanScope ? `${base}&wan=${encodeURIComponent(wanScope)}` : base; +} + async function loadHistory() { const to = new Date(); const from = new Date(to.getTime() - HISTORY_MINUTES * 60000); try { - const resp = await fetch( - `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`, - { credentials: 'same-origin' }); + const resp = await fetch(historyUrl(from, to), { credentials: 'same-origin' }); if (!resp.ok) return 0; const data = await resp.json(); applySampleInterval(data); @@ -433,9 +589,75 @@ async function loadHistory() { } catch { } } +/** Pulls each compared WAN's history into its own buffer. */ +/** + * Fills every compared WAN's buffer. `atMs` loads the window a seek is parked on instead of the + * live one - in comparison mode renderHistoric draws straight from these buffers, so without it a + * seek fetched only the single-WAN buffer and the chart kept showing the live window at a historic + * playhead. Same window arithmetic as seekTime, so both modes frame the instant identically. + */ +async function loadCompareHistory(atMs = null) { + const end = atMs ? Math.min(atMs + HISTORY_MINUTES * 60000 / 2, Date.now()) : Date.now(); + const to = new Date(end); + const from = new Date(end - HISTORY_MINUTES * 60000); + for (const w of compareWans) { + try { + const resp = await fetch( + `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}&wan=${encodeURIComponent(w.key)}`, + { credentials: 'same-origin' }); + if (!resp.ok) { compareBuffers.set(w.key, compareBuffers.get(w.key) || []); continue; } + const data = await resp.json(); + applySampleInterval(data); + compareBuffers.set(w.key, (data.points || []).map(p => ({ + time: new Date(p.time).getTime(), + download: p.downloadBps, + upload: p.uploadBps, + }))); + } catch { compareBuffers.set(w.key, compareBuffers.get(w.key) || []); } + } +} + +/** One live tick per compared WAN, appended to that WAN's own buffer. */ +async function pollLiveCompare() { + const cutoff = Date.now() - HISTORY_MINUTES * 60000; + + // Every WAN is read for the same tick, then stamped with ONE timestamp. A shared tooltip + // stacks values that sit at the same x - and each WAN's own SNMP sample time is a few hundred + // milliseconds off its neighbour's, so stamping each with its own left every series on its own + // x and the stack showed one WAN at a time. The reading is still each WAN's own; only the + // instant they are filed under is common, which is what "at this moment" means on one chart. + const results = await Promise.all(compareWans.map(async w => { + try { + const resp = await fetch(`/api/monitoring/live-stats?wan=${encodeURIComponent(w.key)}`, + { credentials: 'same-origin' }); + if (!resp.ok) return null; + const d = await resp.json(); + return { key: w.key, d, sampled: d.sampleTime ? new Date(d.sampleTime).getTime() : 0 }; + } catch { return null; } + })); + + const live = results.filter(Boolean); + if (live.length === 0) return; + // The newest real sample time across the WANs, so the x still tracks the data rather than the + // browser's clock; falls back to now when no WAN reported one. + const tick = Math.max(...live.map(r => r.sampled), 0) || Date.now(); + if (tick <= lastSampleTime) return; // same dedupe as the single-WAN path + lastSampleTime = tick; + + for (const r of live) { + const b = compareBuffers.get(r.key) || []; + b.push({ time: tick, download: r.d.downloadBps, upload: r.d.uploadBps }); + compareBuffers.set(r.key, b.filter(p => p.time >= cutoff)); + } + updateChart(); +} + async function pollLive() { + if (comparing()) return await pollLiveCompare(); try { - const resp = await fetch('/api/monitoring/live-stats', { credentials: 'same-origin' }); + const resp = await fetch( + wanScope ? `/api/monitoring/live-stats?wan=${encodeURIComponent(wanScope)}` : '/api/monitoring/live-stats', + { credentials: 'same-origin' }); if (!resp.ok) return; const d = await resp.json(); // Stamp the point with the server-side SNMP sample time and skip polls @@ -542,9 +764,7 @@ async function backfillHistory() { const from = new Date(to.getTime() - HISTORY_MINUTES * 60000); let points; try { - const resp = await fetch( - `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`, - { credentials: 'same-origin' }); + const resp = await fetch(historyUrl(from, to), { credentials: 'same-origin' }); if (!resp.ok) return; const data = await resp.json(); applySampleInterval(data); @@ -637,12 +857,29 @@ function syncModeUi() { playBtn.setAttribute('aria-label', paused ? 'Play' : 'Pause'); } -export async function mount(containerId, opts) { +async function doMount(containerId, opts) { + // Ride in with the mount rather than in a call behind it: this module is imported + // asynchronously, so a scope pushed separately can land before the import resolves. + if (opts && 'wan' in opts) wanScope = opts.wan || null; + // A selection of several arrives as wans: [{key,label}] and starts the chart in comparison + // mode, so the first paint is already right rather than flipping a moment later. + // + // Keyed on the property being PRESENT, not on it being an array. The primary alone is sent as + // null - it needs no scope - and testing for an array skipped the reset entirely, leaving + // compareWans from the previous mount: this module is imported once and survives leaving the + // tab, so the pills came back reading one WAN while the chart was still comparing every WAN + // it had last been given. + if (opts && 'wans' in opts) { + const list = Array.isArray(opts.wans) ? opts.wans.filter(w => w && w.key) : []; + compareWans = list.length > 1 ? list : []; + if (list.length >= 1) wanScope = list[0].key; + } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (scrollTimer) { clearInterval(scrollTimer); scrollTimer = null; } if (chart) { chart.destroy(); chart = null; } removeMouseTracking(); buffer = []; + compareBuffers.clear(); lastSampleTime = 0; seenLiveSample = false; lastLiveAt = 0; @@ -674,7 +911,8 @@ export async function mount(containerId, opts) { // appended before it is wiped. ensureModeUi(el); - await loadHistory(); + if (comparing()) await loadCompareHistory(); + else await loadHistory(); if (gen !== mountGen) return; await pollLive(); if (gen !== mountGen) return; @@ -710,6 +948,104 @@ export async function mount(containerId, opts) { } } +// mount and setWans both rebuild the chart and reload its history, and they arrive from +// independent Blazor tasks: the mount chain (initial mount, then the settled-scope remount in +// Monitoring.razor) and the LiveWanScope restore's setWans push interleave arbitrarily. Left to +// overlap, a setWans landing mid-mount had its compareWans wiped by the mount's own opts reset, +// and each side destroys the ApexCharts instance the other is awaiting render() on - which +// strands that render promise and takes the caller's interop await (and the settled-scope +// remount behind it) down with it, leaving a comparison chart whose history load never ran. +// Serializing the two entry points makes every arrival order equivalent to a clean sequence, +// and every terminal order ends loaded: a mount running last loads its own window, a setWans +// running last either loads or finds the same WAN list already mounted and stands pat. +let scopeOpChain = Promise.resolve(); + +function queueScopeOp(fn) { + const run = scopeOpChain.then(fn, fn); + // Keep the chain alive past a failed op; the caller still sees its own rejection via run. + scopeOpChain = run.then(() => {}, () => {}); + return run; +} + +/** Queued front door for doMount, so a mount can never interleave with a setWans. */ +export function mount(containerId, opts) { + return queueScopeOp(() => doMount(containerId, opts)); +} + +/** Queued front door for doSetWans, so a scope push can never interleave with a mount. */ +export function setWans(wans) { + return queueScopeOp(() => doSetWans(wans)); +} + +/** + * Points the chart at another WAN and reloads its history. Deliberately does NOT touch the + * paused/scrubbed state: changing which WAN you are looking at should not drag you back to live, + * and a scrubbed position is still a valid position on the new WAN's series. The seek path shares + * historyUrl(), so scrubbing after a WAN change reads that WAN too. + */ +/** + * Shows several WANs together, or falls back to the single-WAN path for one. Rebuilds the chart: + * the series count, their axes and their fills all differ between the two modes, so updating in + * place would leave ApexCharts holding a config for the shape it no longer has. + */ +async function doSetWans(wans) { + const list = (Array.isArray(wans) ? wans : []).filter(w => w && w.key); + if (list.length <= 1) { + const wasComparing = comparing(); + compareWans = []; + compareBuffers.clear(); + if (wasComparing) await remountChart(); + await setWan(list[0]?.key ?? null); + // A swap made while parked has to be drawn at the parked instant too. + if (!pollTimer && histAt > 0) await seekTime(new Date(histAt).toISOString()); + return; + } + if (list.map(w => w.key).join(",") === compareWans.map(w => w.key).join(",")) return; + compareWans = list; + compareBuffers.clear(); + wanScope = list[0].key; + await remountChart(); + await loadCompareHistory(); + await redrawForCurrentTime(); +} + +/** + * Draws the newly loaded scope at whatever instant the chart is showing. + * + * Live, that is now, and updateChart is the whole job. Parked or playing back it is the instant on + * the playhead, and updateChart draws the live edge instead - which is why changing WANs during + * playback appeared to do nothing: the series were replaced correctly, then painted for a time the + * user was not looking at. A full seek rather than a redraw, because the new scope holds no data + * for that instant until it is fetched, which is what seekTime does. + */ +async function redrawForCurrentTime() { + // Historic means no live poller AND a parked instant - either alone is a half-state seen while + // switching modes, and seeking on one of those is what stopped the chart. + if (!pollTimer && histAt > 0) { + await seekTime(new Date(histAt).toISOString()); + return; + } + updateChart(); +} + +/** Rebuilds the chart in place with the current mode's options, keeping the mount and listeners. */ +async function remountChart() { + if (!chart || !elId) return; + const el = document.getElementById(elId); + if (!el) return; + chart.destroy(); + chart = new ApexCharts(el, buildOpts()); + await chart.render(); +} + +export async function setWan(wanKey) { + const next = wanKey || null; + if (next === wanScope) return; + wanScope = next; + await loadHistory(); + updateChart(); +} + export function pause() { stopHistInterpolation(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } @@ -717,11 +1053,52 @@ export function pause() { if (backfillTimer) { clearInterval(backfillTimer); backfillTimer = null; } } -export function resume() { - if (!chart || pollTimer) return; - pollTimer = setInterval(pollLive, pollMsOverride || pollMs); - scrollTimer = setInterval(updateChart, SCROLL_MS); - startBackfillCatchUp(); +// Entering live mode is one indivisible job - reload the 5-minute window, THEN start the +// timers - but it has two independent doors: the map's time sync sends seekTime(null) and its +// playstate sync sends resume(), each a fire-and-forget interop call, so they arrive in +// whichever order the circuit delivers them. When resume() won that race it used to start +// pollTimer without loading anything, and seekTime(null) then bailed on "already live" before +// reaching its history load - in comparison mode nothing else ever refills compareBuffers +// (backfill feeds only the single-WAN buffer), so the window never came back and the chart +// crept forward one live tick at a time. One shared entry makes the order irrelevant: +// whichever door opens first does the whole job, and the other finds it done - or in flight, +// which the flag below turns into a no-op instead of a second set of timers polling forever. +let liveEntryInFlight = false; + +async function enterLive() { + if (!chart || pollTimer || liveEntryInFlight) return; + // Still parked on a historic instant: while returning to live the playstate sync (resume) + // can arrive before the time sync (seekTime(null)), and entering here would clobber the + // parked window with the live one. seekTime(null) clears histAt first, then comes back in. + if (histAt > 0) return; + liveEntryInFlight = true; + try { + const gen = mountGen; + buffer = []; + // Comparison mode draws from the per-WAN buffers, so refilling the single-WAN one leaves + // the chart on the window it was parked at - the live 5 minutes never arrived and the + // series only crept back as fresh ticks came in one at a time. + if (comparing()) await loadCompareHistory(); + else await loadHistory(); + // A remount, a historic seek, or a path that already started polling superseded this + // entry while it was fetching. Deliberately NOT keyed on seekGen: a second return-to-live + // during the fetch bumps that too, and this entry completing is exactly what the second + // return wants - bailing on it left the chart live with no timers running. + if (gen !== mountGen || histAt > 0 || pollTimer) return; + updateChart(); + pollTimer = setInterval(pollLive, pollMsOverride || pollMs); + scrollTimer = setInterval(updateChart, SCROLL_MS); + startBackfillCatchUp(); + } finally { + liveEntryInFlight = false; + } +} + +export async function resume() { + // The same door as returning from historic: any pause leaves a hole the live ticks alone + // cannot fill (and in comparison mode nothing else fills it, since backfill only feeds the + // single-WAN buffer), so resuming reloads history rather than just restarting the timers. + await enterLive(); } // Render the historic view at a given playhead time from the current buffer. @@ -736,7 +1113,9 @@ export function resume() { // hover, kicking the user out of tooltip inspection while the background timeline // advances - the exact behavior the hover-hold exists to preserve. function renderHistoric(at, force = false) { - if (!chart || buffer.length === 0) return; + // The empty-buffer hold is single-WAN only: comparison mode draws from compareBuffers + // and an empty single-WAN buffer must not abort its only paused draw. + if (!chart || (!comparing() && buffer.length === 0)) return; if (!force && Date.now() > clickRenderUntil && tooltipShowing()) return; const halfWindow = HISTORY_MINUTES * 60000 / 2; const maxTime = Math.min(at + halfWindow, Date.now()); @@ -754,11 +1133,25 @@ function renderHistoric(at, force = false) { offsetY: -5, } }; - chart.updateOptions({ + // Same window and playhead either way; only the RTT axis rescale is single-WAN, since there is + // no RTT axis to rescale while comparing. + const histWindow = { xaxis: { min: maxTime - HISTORY_MINUTES * 60000, max: maxTime }, - yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], annotations: { xaxis: [...buildTimeTicks(maxTime - HISTORY_MINUTES * 60000, maxTime), playhead] }, - }, false, false, false); + }; + chart.updateOptions(comparing() + ? histWindow + : { + ...histWindow, + yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }], + }, false, false, false); + + // Scrubbing while comparing draws every WAN at the parked instant - the point of comparing is + // to read them against each other, and freezing the time only makes that easier. + if (comparing()) { + chart.updateSeries(compareSeries(), false); + return; + } chart.updateSeries([ { name: 'Download', data: buffer.map(p => ({ x: p.time, y: p.download })) }, { name: 'Upload', data: buffer.map(p => ({ x: p.time, y: p.upload })) }, @@ -794,15 +1187,15 @@ export async function seekTime(isoTimestamp) { // plain time grid, dropping the playhead. (Mode cluster visibility is // driven by the store's playstate events, not by seeks.) stopHistInterpolation(); - if (pollTimer) return; // already live - const liveGen = seekGen; - buffer = []; - await loadHistory(); - if (liveGen !== seekGen) return; // seeked again while loading - updateChart(); - pollTimer = setInterval(pollLive, pollMsOverride || pollMs); - scrollTimer = setInterval(updateChart, SCROLL_MS); - startBackfillCatchUp(); + // Forget the parked instant. Left set, anything that later asks "what time is the chart + // showing" is told a timestamp from a playback session that has ended - which sent a WAN + // filter change seeking back into history, stopping the live poll on the way, and left the + // chart empty on a window with no data and nothing running to refill it. + histAt = 0; + // enterLive owns the "already live" bail, the history reload (per-WAN buffers while + // comparing), and the timer start - shared with resume(), so the two return-to-live + // callbacks can no longer race each other into skipping the load. + await enterLive(); return; } // Historic mode: stop polling, fetch window centered on timestamp @@ -821,23 +1214,28 @@ export async function seekTime(isoTimestamp) { const maxTime = Math.min(at + halfWindow, Date.now()); const from = new Date(maxTime - HISTORY_MINUTES * 60000); const to = new Date(maxTime); - try { - const resp = await fetch( - `/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`, - { credentials: 'same-origin' }); - if (!resp.ok) return; - const data = await resp.json(); - if (gen !== seekGen) return; // a newer seek (or return to live) superseded this one - applySampleInterval(data); - buffer = (data.points || []).map(p => ({ - time: new Date(p.time).getTime(), - download: p.downloadBps, - upload: p.uploadBps, - rtt: p.rttMs, - loss: p.lossPercent, - })); - } catch { return; } - if (buffer.length === 0) return; + // Comparison mode draws only from the per-WAN buffers, so the single-WAN fetch is + // skipped there - its failure returns were aborting the one draw a paused seek gets. + if (comparing()) { + await loadCompareHistory(at); + if (gen !== seekGen) return; + } else { + try { + const resp = await fetch(historyUrl(from, to), { credentials: 'same-origin' }); + if (!resp.ok) return; + const data = await resp.json(); + if (gen !== seekGen) return; // a newer seek (or return to live) superseded this one + applySampleInterval(data); + buffer = (data.points || []).map(p => ({ + time: new Date(p.time).getTime(), + download: p.downloadBps, + upload: p.uploadBps, + rtt: p.rttMs, + loss: p.lossPercent, + })); + } catch { return; } + if (buffer.length === 0) return; + } // Force the reposition draw only for a discrete/paused seek (deep-link, manual // scrub): it must land even under the cursor or it's never retried while paused. // During active playback leave it unforced so a hover still holds the redraw for @@ -867,6 +1265,8 @@ export async function seekTime(isoTimestamp) { } export function unmount() { + compareWans = []; + compareBuffers.clear(); mountGen++; stopHistInterpolation(); if (unsubFlow) { unsubFlow(); unsubFlow = null; } diff --git a/tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs b/tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs new file mode 100644 index 0000000000..c471d295f8 --- /dev/null +++ b/tests/NetworkOptimizer.AgentProtocol.Tests/AgentHelloCompatibilityTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using Google.Protobuf; +using Xunit; + +namespace NetworkOptimizer.AgentProtocol.Tests; + +/// +/// The hello has to stay readable in both directions across a rollout: agents and servers update +/// on their own schedules, and a capability the server guesses at is worse than one it never +/// offers. Every capability on it is an explicitly optional field so "no" and "did not say" stay +/// distinguishable, and no field number is ever reused. +/// +public class AgentHelloCompatibilityTests +{ + [Fact] + public void OldAgent_SaysNothingAboutSourceBinding() + { + // What an agent predating the field puts on the wire: the field simply is not there. + var oldHello = new AgentHello { AgentKey = "key", Version = "2.5.0", LanIp = "192.0.2.20" }; + + var parsed = AgentHello.Parser.ParseFrom(oldHello.ToByteArray()); + + parsed.HasSupportsSourceBind.Should().BeFalse(); + parsed.SupportsSourceBind.Should().BeFalse(); + } + + [Fact] + public void NewAgent_SayingNo_IsDistinguishableFromSayingNothing() + { + var windowsAgent = new AgentHello { AgentKey = "key", Version = "2.6.0", SupportsSourceBind = false }; + + var parsed = AgentHello.Parser.ParseFrom(windowsAgent.ToByteArray()); + + parsed.HasSupportsSourceBind.Should().BeTrue(); + parsed.SupportsSourceBind.Should().BeFalse(); + } + + [Fact] + public void NewAgent_SayingYes_RoundTrips() + { + var linuxAgent = new AgentHello { AgentKey = "key", Version = "2.6.0", SupportsSourceBind = true }; + + var parsed = AgentHello.Parser.ParseFrom(linuxAgent.ToByteArray()); + + parsed.HasSupportsSourceBind.Should().BeTrue(); + parsed.SupportsSourceBind.Should().BeTrue(); + } + + [Fact] + public void NewFieldDoesNotDisturbTheExistingOnes() + { + // An old SERVER parses a new agent's hello by skipping the unknown field, so everything it + // already reads has to survive alongside it. + var hello = new AgentHello + { + AgentKey = "key", + Version = "2.6.0", + LanIp = "192.0.2.20", + SpeedTestPort = 24443, + ServesSpeedTest = true, + SupportsSourceBind = true, + }; + + var parsed = AgentHello.Parser.ParseFrom(hello.ToByteArray()); + + parsed.AgentKey.Should().Be("key"); + parsed.LanIp.Should().Be("192.0.2.20"); + parsed.SpeedTestPort.Should().Be(24443); + parsed.HasServesSpeedTest.Should().BeTrue(); + parsed.ServesSpeedTest.Should().BeTrue(); + } + + [Fact] + public void ProbeTargetSpec_WithoutASource_LeavesTheAgentOnItsOwnDefault() + { + // Every target on a site with no WAN contexts carries an empty source, which ProbeRunner + // reads as "use the agent's configured default" - the behavior before contexts existed. + var spec = new ProbeTargetSpec { TargetId = "wan-1", Address = "192.0.2.1", ProbeMode = "icmp" }; + + AgentProtocol.ProbeTargetSpec.Parser.ParseFrom(spec.ToByteArray()).SourceIp.Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs new file mode 100644 index 0000000000..22deea820e --- /dev/null +++ b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TcpBindAddressTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using FluentAssertions; +using NetworkOptimizer.Monitoring.Probes; +using Xunit; + +namespace NetworkOptimizer.Monitoring.Tests.Probes; + +/// +/// A TCP probe binds an address, so a WAN context that names an interface has to be resolved to +/// that interface's current address at probe time. Doing it at probe time rather than at push time +/// is what keeps a DHCP or PPPoE WAN working: its address moves, and a stale one binds nothing. +/// +public class TcpBindAddressTests +{ + private static IReadOnlyList NoAddresses(string _) => Array.Empty(); + + [Fact] + public void IpLiteral_IsUsedDirectly() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress("192.0.2.10", NoAddresses); + + address.Should().Be(IPAddress.Parse("192.0.2.10")); + error.Should().BeNull(); + } + + [Fact] + public void InterfaceName_ResolvesToItsCurrentIPv4Address() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "eth8", _ => new[] { IPAddress.Parse("198.51.100.7") }); + + address.Should().Be(IPAddress.Parse("198.51.100.7")); + error.Should().BeNull(); + } + + [Fact] + public void InterfaceName_SkipsIPv6AndTakesTheIPv4Address() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "ppp0", _ => new[] { IPAddress.Parse("2001:db8::1"), IPAddress.Parse("198.51.100.7") }); + + address.Should().Be(IPAddress.Parse("198.51.100.7")); + error.Should().BeNull(); + } + + [Fact] + public void InterfaceWithNoIPv4Address_FailsLoudlyRatherThanProbingUnbound() + { + // An unbound probe leaves by the default route and records another WAN's latency under + // this one's name, which reads as data rather than as a failure. + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "ppp0", _ => new[] { IPAddress.Parse("2001:db8::1") }); + + address.Should().BeNull(); + error.Should().Contain("ppp0").And.Contain("IPv4"); + } + + [Fact] + public void UnknownInterface_Fails() + { + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress("eth9", NoAddresses); + + address.Should().BeNull(); + error.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void UnsafeSourceValue_IsRejectedBeforeAnyLookup() + { + var looked = false; + var (address, error) = LocalProbeExecutor.ResolveTcpBindAddress( + "eth0; rm -rf /", _ => { looked = true; return Array.Empty(); }); + + address.Should().BeNull(); + error.Should().Contain("Invalid probe source"); + looked.Should().BeFalse(); + } +} diff --git a/tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs new file mode 100644 index 0000000000..275b02ce26 --- /dev/null +++ b/tests/NetworkOptimizer.Monitoring.Tests/Probes/TracerouteCommandTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Monitoring.Probes; +using Xunit; + +namespace NetworkOptimizer.Monitoring.Tests.Probes; + +/// +/// Traceroute is what discovers a WAN's upstream path, so on a multi-WAN install it has to leave +/// by the WAN being discovered. These cover the source binding it grew: an interface name becomes +/// -i, an IP becomes -s, and anything that can't be bound - a hostile value, a binary without the +/// options, the Windows managed path - fails instead of tracing out the default route and filing +/// another WAN's upstream under this one. +/// +public class TracerouteCommandTests +{ + private static readonly LocalProbeExecutor.TracerouteBinaryTraits Gnu = + LocalProbeExecutor.TracerouteBinaryTraits.FullyBindable; + + [Fact] + public void NoSource_BuildsTheSameCommandItAlwaysHas() + { + // The single-WAN case: nothing about the command changes. + var (exe, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp), maxHops: 30, perHopTimeout: TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().BeNull(); + exe.Should().Be("traceroute"); + args.Should().Be("-m 30 -q 2 -w 2 -I 192.0.2.1"); + } + + [Fact] + public void InterfaceName_BindsWithDashI() + { + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "eth8"), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().BeNull(); + args.Should().Be("-m 30 -q 2 -w 2 -I -i eth8 192.0.2.1"); + } + + [Fact] + public void IpLiteral_BindsWithDashS() + { + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Udp, null, "198.51.100.7"), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().BeNull(); + args.Should().Contain("-s 198.51.100.7").And.NotContain("-i "); + } + + [Fact] + public void UnsafeSourceValue_FailsInsteadOfReachingTheCommandLine() + { + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "eth0; rm -rf /"), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: false); + + error.Should().Contain("Invalid probe source"); + args.Should().BeEmpty(); + } + + [Fact] + public void BusyBoxWithoutTheOptions_FailsRatherThanTracingUnbound() + { + var stripped = new LocalProbeExecutor.TracerouteBinaryTraits( + IsBusyBox: true, CanBindAddress: false, CanBindInterface: false); + + var iface = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "eth8"), 30, TimeSpan.FromSeconds(2), stripped, isWindows: false); + var address = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "198.51.100.7"), 30, TimeSpan.FromSeconds(2), stripped, isWindows: false); + + iface.Error.Should().Contain("source interface").And.Contain("eth8"); + address.Error.Should().Contain("source address"); + } + + [Fact] + public void BusyBoxWithoutTheOptions_StillTracesWhenNothingAskedForABind() + { + var stripped = new LocalProbeExecutor.TracerouteBinaryTraits( + IsBusyBox: true, CanBindAddress: false, CanBindInterface: false); + + var (_, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp), 30, TimeSpan.FromSeconds(2), stripped, isWindows: false); + + error.Should().BeNull(); + args.Should().Be("-m 30 -q 2 -w 2 -I 192.0.2.1"); + } + + [Fact] + public void Windows_CannotBindAtAllAndSaysSo() + { + // tracert.exe has no source option, and the Windows managed path can't bind either - + // the same loud failure the managed ping path gives rather than a wrong-WAN reading. + var (_, _, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp, null, "198.51.100.7"), 30, TimeSpan.FromSeconds(2), + Gnu, isWindows: true); + + error.Should().Contain("native traceroute binary"); + } + + [Fact] + public void Windows_WithoutASourceBuildsTheTracertCommandItAlwaysHas() + { + var (exe, args, error) = LocalProbeExecutor.BuildTracerouteCommand( + new ProbeTarget("192.0.2.1", ProbeMode.Icmp), 30, TimeSpan.FromSeconds(2), Gnu, isWindows: true); + + error.Should().BeNull(); + exe.Should().Be("tracert.exe"); + args.Should().Be("-h 30 -w 2000 192.0.2.1"); + } + + [Fact] + public void BusyBoxUsageListingBothOptions_ReadsAsBindable() + { + const string usage = + "BusyBox v1.36.1 (2024-01-01) multi-call binary.\n" + + "Usage: traceroute [-46FIlnrv] [-f 1ST_TTL] [-m MAXTTL] [-q PROBES] [-s SRC_IP]\n" + + " [-t TOS] [-w WAIT_SEC] [-G GATEWAY] [-i IFACE] HOST [BYTES]"; + + var traits = LocalProbeExecutor.InterpretTracerouteBanner(usage); + + traits.IsBusyBox.Should().BeTrue(); + traits.CanBindAddress.Should().BeTrue(); + traits.CanBindInterface.Should().BeTrue(); + } + + [Fact] + public void BusyBoxUsageWithoutSourceOptions_ReadsAsUnbindable() + { + const string usage = + "BusyBox v1.36.1 multi-call binary.\n" + + "Usage: traceroute [-46Fln] [-m MAXTTL] [-q PROBES] [-w WAIT_SEC] HOST [BYTES]"; + + var traits = LocalProbeExecutor.InterpretTracerouteBanner(usage); + + traits.IsBusyBox.Should().BeTrue(); + traits.CanBindAddress.Should().BeFalse(); + traits.CanBindInterface.Should().BeFalse(); + } + + [Theory] + [InlineData("Modern traceroute for Linux, version 2.1.0")] + [InlineData("Version 1.4a12")] + [InlineData("")] + [InlineData(null)] + public void AnythingButBusyBox_ReadsAsFullyBindable(string? banner) + { + // GNU traceroute and BSD traceroute both document -s and -i, and a binary that answered + // nothing gets the same benefit of the doubt: an option it doesn't have makes the command + // fail loudly, which is still not a silently unbound probe. + var traits = LocalProbeExecutor.InterpretTracerouteBanner(banner); + + traits.IsBusyBox.Should().BeFalse(); + traits.CanBindAddress.Should().BeTrue(); + traits.CanBindInterface.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs b/tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs new file mode 100644 index 0000000000..60aff79893 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/LegacyWan1KeyNormalizationTests.cs @@ -0,0 +1,185 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// Migration 20260521500000 stamped the rows it found 'wan1'; every writer since uses 'wan'. The +/// two spell the same WAN, and nothing minded until per-WAN reading arrived - at which point a +/// 'wan' discovery run stops recognizing 'wan1' rows as its own and duplicates them, and the 'wan' +/// report stops counting them. NormalizeLegacyWan1Key folds the legacy spelling into the current +/// one, here against a real SQLite database through the real migration pipeline. +/// +public class LegacyWan1KeyNormalizationTests : IDisposable +{ + // The migration applied immediately before the normalization. + private const string PreNormalizeMigration = "20260803210000_BackfillWanContextTargetWan"; + + private readonly string _dbPath; + + public LegacyWan1KeyNormalizationTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"no-wan1-normalize-test-{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = _dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + } + + private NetworkOptimizerDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"DataSource={_dbPath}") + .Options; + return new NetworkOptimizerDbContext(options); + } + + private static MonitoringTarget Target(string targetId, string? wanInterface) => new() + { + TargetId = targetId, + Name = targetId, + Address = "203.0.113.10", + TargetType = MonitoringTargetType.AccessIsp, + ProbeMode = ProbeMode.Icmp, + WanInterface = wanInterface, + }; + + [Fact] + public void Normalize_RenamesTheLegacySpellingEverywhereItIsStored() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext { WanInterface = "wan1", AccessTechnology = AccessTechnology.Gpon }); + context.MonitoringTargets.Add(Target("access-legacy", "wan1")); + context.UpstreamDiscoveries.Add(new UpstreamDiscovery { HopIp = "192.0.2.30", HopNumber = 1, WanInterface = "wan1" }); + context.WanContexts.Add(new WanContext { Id = 1, Name = "legacy-context", WanInterface = "wan1", ProbeSourceIp = "198.51.100.9" }); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().WanInterface.Should().Be("wan"); + context.WanDiscoveryContexts.Single().AccessTechnology.Should().Be(AccessTechnology.Gpon); + context.MonitoringTargets.Single().WanInterface.Should().Be("wan"); + context.UpstreamDiscoveries.Single().WanInterface.Should().Be("wan"); + context.WanContexts.Single().WanInterface.Should().Be("wan"); + } + } + + [Fact] + public void Normalize_KeepsTheNewerDiscoveryContextWhenBothSpellingsExist() + { + // WanDiscoveryContexts is keyed by the WAN, so the two rows cannot both survive the + // rename. The row describing the more recent discovery is the one worth keeping. + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan1", + AccessTechnology = AccessTechnology.Docsis, + LastDiscoveryAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan", + AccessTechnology = AccessTechnology.XgsPon, + LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().Should().Match( + c => c.WanInterface == "wan" && c.AccessTechnology == AccessTechnology.XgsPon); + } + } + + [Fact] + public void Normalize_KeepsTheLegacyRowWhenItIsTheNewerOne() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan1", + AccessTechnology = AccessTechnology.XgsPon, + LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.WanDiscoveryContexts.Add(new WanDiscoveryContext + { + WanInterface = "wan", + AccessTechnology = AccessTechnology.Docsis, + LastDiscoveryAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().Should().Match( + c => c.WanInterface == "wan" && c.AccessTechnology == AccessTechnology.XgsPon); + } + } + + [Fact] + public void Normalize_LeavesEveryOtherWanAlone() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreNormalizeMigration); + + context.WanDiscoveryContexts.Add(new WanDiscoveryContext { WanInterface = "wan2" }); + context.MonitoringTargets.Add(Target("access-wan2", "wan2")); + context.MonitoringTargets.Add(Target("access-unstamped", null)); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.WanDiscoveryContexts.Single().WanInterface.Should().Be("wan2"); + context.MonitoringTargets.Single(t => t.TargetId == "access-wan2").WanInterface.Should().Be("wan2"); + context.MonitoringTargets.Single(t => t.TargetId == "access-unstamped").WanInterface.Should().BeNull(); + } + } + + [Fact] + public void Normalize_OnACleanDatabaseDoesNothingAndDoesNotThrow() + { + using var context = CreateContext(); + + var act = () => MigrationSafety.MigrateWithFriendlyErrors(context); + + act.Should().NotThrow(); + context.Database.GetPendingMigrations().Should().BeEmpty(); + context.WanDiscoveryContexts.Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs b/tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs new file mode 100644 index 0000000000..d808743800 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/WanContextTargetWanBackfillTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// A monitoring target carries two WAN keys: WanContextId (who probes it, assigned by hand) and +/// WanInterface (which WAN its data describes, written by upstream discovery). Contexts predate +/// the WAN column on WanContext, so a target assigned to a secondary WAN's context has been +/// carrying no WAN at all, or the primary's - which puts its data under the wrong WAN for every +/// per-WAN reader. The BackfillWanContextTargetWan migration reconciles the two against a real +/// SQLite database, through the real migration pipeline. +/// +public class WanContextTargetWanBackfillTests : IDisposable +{ + // The migration applied immediately before the backfill. + private const string PreBackfillMigration = "20260803193154_AddWanContextInterfaceBinding"; + + private readonly string _dbPath; + + public WanContextTargetWanBackfillTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"no-wan-backfill-test-{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = _dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + } + + private NetworkOptimizerDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"DataSource={_dbPath}") + .Options; + return new NetworkOptimizerDbContext(options); + } + + private static MonitoringTarget Target(string targetId, string address, int? contextId, string? wanInterface) => new() + { + TargetId = targetId, + Name = targetId, + Address = address, + TargetType = MonitoringTargetType.Custom, + ProbeMode = ProbeMode.Icmp, + WanContextId = contextId, + WanInterface = wanInterface, + }; + + [Fact] + public void Backfill_GivesAContextsTargetsTheContextsWan() + { + using (var context = CreateContext()) + { + context.GetService().Migrate(PreBackfillMigration); + + context.WanContexts.Add(new WanContext { Id = 1, Name = "backup-wan", WanInterface = "wan2", ProbeSourceIp = "198.51.100.7" }); + // Assigned to the context but never given a WAN, and assigned but stamped with the + // primary's WAN by a discovery that predates per-WAN contexts. + context.MonitoringTargets.Add(Target("t-unstamped", "203.0.113.1", contextId: 1, wanInterface: null)); + context.MonitoringTargets.Add(Target("t-wrong-wan", "203.0.113.2", contextId: 1, wanInterface: "wan")); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.MonitoringTargets.OrderBy(t => t.TargetId) + .Select(t => t.WanInterface).ToList() + .Should().Equal("wan2", "wan2"); + } + } + + [Fact] + public void Backfill_LeavesTargetsWithNoContextAlone() + { + // Every target on a single-WAN install: no context, so nothing to reconcile against. + using (var context = CreateContext()) + { + context.GetService().Migrate(PreBackfillMigration); + + context.MonitoringTargets.Add(Target("t-primary", "203.0.113.3", contextId: null, wanInterface: "wan")); + context.MonitoringTargets.Add(Target("t-manual", "203.0.113.4", contextId: null, wanInterface: null)); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.MonitoringTargets.Single(t => t.TargetId == "t-primary").WanInterface.Should().Be("wan"); + context.MonitoringTargets.Single(t => t.TargetId == "t-manual").WanInterface.Should().BeNull(); + } + } + + [Fact] + public void Backfill_LeavesTargetsOfAContextThatNamesNoWanAlone() + { + // A context created before the WAN column exists has nothing to copy down. + using (var context = CreateContext()) + { + context.GetService().Migrate(PreBackfillMigration); + + context.WanContexts.Add(new WanContext { Id = 2, Name = "legacy", ProbeSourceIp = "198.51.100.8" }); + context.MonitoringTargets.Add(Target("t-legacy", "203.0.113.5", contextId: 2, wanInterface: "wan")); + context.SaveChanges(); + } + + using (var context = CreateContext()) + { + MigrationSafety.MigrateWithFriendlyErrors(context); + + context.MonitoringTargets.Single().WanInterface.Should().Be("wan"); + } + } + + [Fact] + public void Backfill_OnACleanDatabaseDoesNothingAndDoesNotThrow() + { + using var context = CreateContext(); + + var act = () => MigrationSafety.MigrateWithFriendlyErrors(context); + + act.Should().NotThrow(); + context.Database.GetPendingMigrations().Should().BeEmpty(); + context.MonitoringTargets.Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs b/tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs new file mode 100644 index 0000000000..5223c01c9c --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/WanScopeFilterTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Services; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// The Flux filter stage a latency wan-scope emits. The shapes are a correctness AND +/// performance contract (see BuildWanScopeFilter's remarks): tag ABSENCE for the primary - +/// never an empty-string equality, which matches nothing against a series that has no wan +/// column - and plain pushdown-safe tag equality for a scoped WAN. +/// +public class WanScopeFilterTests +{ + [Fact] + public void NoScope_EmitsNoFilterStage() + { + MonitoringInfluxClient.BuildWanScopeFilter(null).Should().BeEmpty(); + } + + [Fact] + public void PrimaryWithNoContexts_FiltersOnTagAbsenceOnly() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.Primary()); + + filter.Should().Be("\n |> filter(fn: (r) => not exists r.wan)"); + } + + [Fact] + public void PrimaryWithAPrimaryBoundContext_KeepsBothShapesInOnePredicate() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.Primary(new[] { "wan" })); + + filter.Should().Be(@" + |> filter(fn: (r) => not exists r.wan or r.wan == ""wan"")"); + } + + [Fact] + public void ScopedWan_IsAPlainTagEqualityChain() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "wan2", "starlink-backup" })); + + filter.Should().Be(@" + |> filter(fn: (r) => r.wan == ""wan2"" or r.wan == ""starlink-backup"")"); + } + + [Fact] + public void ScopedWan_DeduplicatesTagValues() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "wan2", "wan2" })); + + filter.Should().Be("\n |> filter(fn: (r) => r.wan == \"wan2\")"); + } + + [Fact] + public void ScopedWanWithNoUsableTags_MatchesNothingRatherThanEveryWan() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "" })); + + filter.Should().Contain("exists r.wan and not exists r.wan"); + } + + [Fact] + public void TagValues_AreFluxSanitized() + { + var filter = MonitoringInfluxClient.BuildWanScopeFilter( + MonitoringInfluxClient.LatencyWanScope.ForWan(new[] { "a\"b" })); + + filter.Should().NotContain("a\"b"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs b/tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs new file mode 100644 index 0000000000..29e850f269 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/AutoEnableBudgetTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +public class AutoEnableBudgetTests +{ + private static UpstreamTracerState BuildState(int accessHops, int transitRouters, int pathEndpoints) + { + var state = new UpstreamTracerState(); + for (var i = 1; i <= accessHops; i++) + state.AccessHops.Add(new AccessHopCandidate + { + TargetId = $"access-{i}", + Label = $"Access {i}", + Address = $"192.0.2.{i}", + HopNumber = i, + Enabled = true, + }); + for (var i = 1; i <= transitRouters; i++) + state.TransitAsns.Add(new TransitAsnCandidate + { + AsnNumber = 64500 + i, + AsnName = $"Transit{i}", + Method = DiscoveryMethod.DirectRouter, + HopAddress = $"198.51.100.{i}", + Enabled = true, + }); + for (var i = 1; i <= pathEndpoints; i++) + state.TransitAsns.Add(new TransitAsnCandidate + { + AsnNumber = 64600 + i, + AsnName = $"Endpoint{i}", + Method = DiscoveryMethod.PathProxy, + PathProxyTarget = $"203.0.113.{i}", + Enabled = true, + }); + return state; + } + + private static int EnabledOf(UpstreamTracerState state, DiscoveryMethod method) => + state.TransitAsns.Count(t => t.Method == method && t.Enabled); + + [Fact] + public void No_budget_leaves_every_candidate_ticked() + { + var state = BuildState(accessHops: 6, transitRouters: 6, pathEndpoints: 6); + + UpstreamTracerService.ApplyAutoEnableBudget(state, null); + + state.AccessHops.Should().OnlyContain(h => h.Enabled); + state.TransitAsns.Should().OnlyContain(t => t.Enabled); + } + + [Fact] + public void Every_bucket_keeps_a_share_of_a_tight_budget() + { + // The failure this guards: access hops taken first and in full left one endpoint ticked, + // so the site could see its first mile and not whether anything it reaches was up. + var state = BuildState(accessHops: 12, transitRouters: 6, pathEndpoints: 9); + + UpstreamTracerService.ApplyAutoEnableBudget(state, 8); + + state.AccessHops.Count(h => h.Enabled).Should().Be(3); + EnabledOf(state, DiscoveryMethod.DirectRouter).Should().Be(3); + EnabledOf(state, DiscoveryMethod.PathProxy).Should().Be(2); + } + + [Fact] + public void A_bucket_that_runs_out_hands_its_share_to_the_others() + { + var state = BuildState(accessHops: 1, transitRouters: 6, pathEndpoints: 6); + + UpstreamTracerService.ApplyAutoEnableBudget(state, 7); + + state.AccessHops.Count(h => h.Enabled).Should().Be(1); + EnabledOf(state, DiscoveryMethod.DirectRouter).Should().Be(3); + EnabledOf(state, DiscoveryMethod.PathProxy).Should().Be(3); + } + + [Fact] + public void Unreachable_candidates_are_neither_ticked_nor_charged_to_the_budget() + { + // The reachability gate runs BEFORE this and turns them off. Switching one back on because + // it fell inside the budget hands over a target known not to answer, and spends one of the + // few slots a metered WAN gets doing it. + var state = BuildState(accessHops: 4, transitRouters: 0, pathEndpoints: 0); + foreach (var hop in state.AccessHops.Take(2)) + { + hop.Unreachable = true; + hop.Enabled = false; + } + + UpstreamTracerService.ApplyAutoEnableBudget(state, 2); + + state.AccessHops.Where(h => h.Unreachable).Should().OnlyContain(h => !h.Enabled); + state.AccessHops.Where(h => !h.Unreachable).Should().OnlyContain(h => h.Enabled); + } + + [Fact] + public void Candidates_beyond_the_budget_are_turned_off() + { + var state = BuildState(accessHops: 4, transitRouters: 0, pathEndpoints: 0); + + UpstreamTracerService.ApplyAutoEnableBudget(state, 2); + + state.AccessHops.Count(h => h.Enabled).Should().Be(2); + state.AccessHops.Where(h => h.Enabled).Should().OnlyContain(h => h.HopNumber <= 2); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs new file mode 100644 index 0000000000..75d5e78d85 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/CrossHopAgreementTests.cs @@ -0,0 +1,136 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// Congestion on a link is common to everything crossing it. One hop rising while the hops beside +/// it stay flat AT THE SAME SECOND is that hop's own responder deprioritizing ICMP, and the flat +/// readings taken alongside it are the proof - proof the old flat pooling threw away, because the +/// noise floor discarded the clean samples before the median ever saw them. +/// +public class CrossHopAgreementTests +{ + private static readonly DateTime T0 = new(2026, 8, 5, 16, 23, 0, DateTimeKind.Utc); + private static readonly TimeSpan Tolerance = TimeSpan.FromSeconds(1); + private const double Floor = 0.5; + + private static (DateTime, double, int) S(double atSecond, double value, int hop) => + (T0.AddSeconds(atSecond), value, hop); + + [Fact] + public void One_squealing_hop_is_diluted_by_its_clean_neighbors() + { + var samples = new[] + { + S(0, 0.1, 0), S(0.2, 8.0, 1), S(0.4, 0.2, 2), S(0.6, 0.1, 3), S(0.8, 0.0, 4), + }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + // Not discarded - the hop that saw it sets the magnitude (8.0), scaled by how alone it + // was in seeing it (1 of 5). Collapsing magnitude across the cohort instead would have + // answered a different question: what the AVERAGE target saw, which nothing experiences. + agreed.Should().HaveCount(1); + agreed[0].Value.Should().BeApproximately(8.0 / 5, 0.001); + } + + [Fact] + public void A_link_that_is_genuinely_loaded_carries_every_hop_up_together() + { + var samples = new[] + { + S(0, 21.0, 0), S(0.2, 24.0, 1), S(0.4, 22.0, 2), S(0.6, 23.0, 3), + }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(1); + agreed[0].Value.Should().BeApproximately(22.5, 0.001); + } + + [Fact] + public void The_figure_does_not_shrink_just_because_more_targets_are_monitored() + { + // The bug this replaced: dilution scaled with cohort size, so a WAN watching 28 targets + // reported a third of a millisecond for a genuine 8 ms. Monitoring more scored better. + static IEnumerable<(DateTime, double, int)> Bloat(int targets) => + Enumerable.Range(0, targets).Select(t => S(t * 0.02, 8.0, t)); + + var small = SeriesStats.CommonModeByInstant(Bloat(5).ToList(), Tolerance, 4, Floor); + var large = SeriesStats.CommonModeByInstant(Bloat(28).ToList(), Tolerance, 4, Floor); + + small.Should().ContainSingle().Which.Value.Should().BeApproximately(8.0, 0.001); + large.Should().ContainSingle().Which.Value.Should().BeApproximately(8.0, 0.001); + } + + [Fact] + public void A_target_that_said_nothing_this_instant_does_not_count_as_clean() + { + // Denominator is what reported, not the cohort's size - targets do not share a cadence. + var samples = new[] { S(0, 8.0, 0), S(0.2, 8.0, 1), S(0.4, 8.0, 2), S(0.6, 8.0, 3) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 4, Floor); + + agreed.Should().ContainSingle().Which.Value.Should().BeApproximately(8.0, 0.001); + } + + [Fact] + public void Every_target_reading_clean_reports_nothing_happened() + { + var samples = new[] { S(0, 0.1, 0), S(0.2, 0.0, 1), S(0.4, 0.2, 2), S(0.6, 0.1, 3) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 4, Floor); + + agreed.Should().ContainSingle().Which.Value.Should().Be(0); + } + + [Fact] + public void A_hop_with_nothing_beside_it_is_passed_through_untouched() + { + // Short events where only one hop happened to be probed are still evidence. Uncorroborated + // evidence is not the same as refuted evidence, and dropping it would blind the score to + // exactly the brief spikes it is supposed to notice. + var samples = new[] { S(0, 9.0, 0) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().ContainSingle().Which.Value.Should().Be(9.0); + } + + [Fact] + public void Two_readings_from_the_SAME_hop_do_not_corroborate_each_other() + { + var samples = new[] { S(0, 9.0, 0), S(0.3, 9.2, 0) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(2); + agreed.Select(a => a.Value).Should().BeEquivalentTo(new[] { 9.0, 9.2 }); + } + + [Fact] + public void Samples_further_apart_than_the_tolerance_are_separate_instants() + { + // Not simultaneous, so they say nothing about each other: a hop that was clean five + // seconds later does not testify about the second the spike happened. + var samples = new[] { S(0, 8.0, 0), S(5, 0.1, 1) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(2); + agreed.Select(a => a.Value).Should().BeEquivalentTo(new[] { 8.0, 0.1 }); + } + + [Fact] + public void Instants_are_reported_in_time_order_regardless_of_input_order() + { + var samples = new[] { S(10, 1.0, 0), S(10.2, 1.2, 1), S(0, 5.0, 0), S(0.2, 5.4, 1) }; + + var agreed = SeriesStats.CommonModeByInstant(samples, Tolerance, minCohort: 2, elevationFloor: Floor); + + agreed.Should().HaveCount(2); + agreed[0].Time.Should().BeBefore(agreed[1].Time); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs new file mode 100644 index 0000000000..66424cdbe0 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/ElevationVerdictTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// "Has the elevation stopped" rather than "what is the median", because the noise floor +/// downstream keeps only the elevated samples - so the reported figure is the median of the bad +/// ones, and comparing medians cannot see a fix at all. +/// +/// The bar for calling it over: a run of clean load episodes, and - where the history shows the +/// problem was tied to a time of day - one of them at that hour. Every case here came from being +/// wrong about a real WAN first. +/// +/// +public class ElevationVerdictTests +{ + private const double NoiseFloor = 0.5; + private const double HourDependenceFloor = 3.0; + private const int StaleEpisodes = 3; + private static readonly TimeSpan EpisodeSpan = TimeSpan.FromSeconds(7); + + // Local time, because the rule reasons about the operator's hours. + private static DateTime At(int dayOffset, int hour, int minute = 0) => + TimeZoneInfo.ConvertTimeToUtc( + new DateTime(2026, 8, 5, hour, minute, 0, DateTimeKind.Unspecified).AddDays(-dayOffset), + TimeZoneInfo.Local); + + private static ElevationVerdict.Verdict Judge( + params (DateTime Time, double Value)[] newestFirst) + => ElevationVerdict.For(newestFirst, NoiseFloor, StaleEpisodes, true, EpisodeSpan, HourDependenceFloor); + + [Fact] + public void A_line_still_misbehaving_is_not_over() + { + // Newest episodes are elevated: nothing to declare. + var verdict = Judge( + (At(0, 22), 23), (At(0, 21), 0), (At(0, 20), 0), (At(0, 8), 24), (At(1, 8), 23)); + + verdict.ElevationIsOver.Should().BeFalse(); + verdict.CleanRun.Should().BeEmpty(); + } + + [Fact] + public void A_line_that_was_never_elevated_has_no_elevation_to_declare_over() + { + // Not "cleared" of a problem it never had - but the caller reads ElevatedCount 0 as its own + // answer: every load episode was clean, which is the strongest statement available and the + // reason this line no longer falls through to the median of whichever samples crossed the + // noise floor. That path reported 23 ms on a WAN whose every episode read under 0.5. + var verdict = Judge((At(0, 22), 0.1), (At(0, 21), 0), (At(0, 20), 0.2), (At(1, 8), 0.1)); + + verdict.ElevatedCount.Should().Be(0); + verdict.ElevationIsOver.Should().BeFalse(); + } + + [Fact] + public void A_constant_problem_clears_from_any_hour() + { + // The WAN4 case. Elevated in EVERY episode before the fix, so the hour was never the + // variable - the line misbehaved whenever it was loaded. Three clean saturations at 22:00 + // disprove it without waiting for the 08:00 scheduled test to come round again. + var verdict = Judge( + (At(0, 22, 30), 0.0), (At(0, 22), 0.1), (At(0, 21, 55), 0.0), + (At(0, 20, 37), 23.9), (At(0, 8), 24.4), (At(1, 8), 23.1), (At(2, 8), 24.0)); + + verdict.ProblemHourReTested.Should().BeTrue(); + verdict.ElevationIsOver.Should().BeTrue(); + } + + [Fact] + public void A_nightly_problem_does_not_clear_itself_at_3am() + { + // Bad every evening, clean every night. A run computed at 3 AM sees three clean episodes on + // top of elevated ones - and must NOT call that fixed. + var verdict = Judge( + (At(0, 3), 0.0), (At(0, 2), 0.1), (At(0, 1), 0.0), + (At(1, 20), 22.0), (At(1, 14), 0.2), (At(2, 20), 21.0), (At(2, 14), 0.1)); + + verdict.CleanRun.Should().HaveCount(3); + verdict.ProblemHourReTested.Should().BeFalse(); + verdict.ElevationIsOver.Should().BeFalse(); + } + + [Fact] + public void A_nightly_problem_clears_once_its_own_hour_comes_back_clean() + { + // Same line, but the evening has now been re-tested and was fine. + var verdict = Judge( + (At(0, 20), 0.1), (At(0, 19), 0.0), (At(0, 14), 0.0), + (At(1, 20), 22.0), (At(1, 14), 0.2), (At(2, 20), 21.0)); + + verdict.ProblemHourReTested.Should().BeTrue(); + verdict.ElevationIsOver.Should().BeTrue(); + } + + [Fact] + public void A_short_clean_run_is_not_enough() + { + var verdict = Judge((At(0, 22), 0.0), (At(0, 21), 0.1), (At(0, 8), 24.0), (At(1, 8), 23.0)); + + verdict.CleanRun.Should().HaveCount(2); + verdict.ElevationIsOver.Should().BeFalse(); + } + + [Fact] + public void The_hour_rule_can_be_turned_off() + { + var episodes = new[] + { + (At(0, 3), 0.0), (At(0, 2), 0.1), (At(0, 1), 0.0), + (At(1, 20), 22.0), (At(1, 14), 0.2), (At(2, 20), 21.0), + }; + + ElevationVerdict.For(episodes, NoiseFloor, StaleEpisodes, false, EpisodeSpan, HourDependenceFloor) + .ElevationIsOver.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs index c73869dbce..87c36fcc46 100644 --- a/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/IspHealthScorerTests.cs @@ -41,7 +41,10 @@ private static IspHealthInputs BuildInputs( bool hopOrderKnown = false, List? outages = null, TimeSpan? scoreWindow = null, - HashSet? notTracedTargetIds = null) + HashSet? notTracedTargetIds = null, + double? expectedDownMbps = null, + double? expectedUpMbps = null, + PhysicalLinkInput? physicalLink = null) { // lineIdle: a near-zero, flat WAN with no load bursts (~0% average load), for // exercising the load-calibrated packet-loss ceiling at the idle end. @@ -73,8 +76,9 @@ private static IspHealthInputs BuildInputs( DestinationSeries = destinations ?? new List(), WanRates = rates, InternetMedianDeltaMs = internetDeltaMs, - ExpectedDownloadMbps = withExpectedSpeeds ? 1000 : null, - ExpectedUploadMbps = withExpectedSpeeds ? 500 : null, + PhysicalLink = physicalLink, + ExpectedDownloadMbps = withExpectedSpeeds ? expectedDownMbps ?? 1000 : null, + ExpectedUploadMbps = withExpectedSpeeds ? expectedUpMbps ?? 500 : null, ExpectedSpeedSource = withExpectedSpeeds ? "UniFi Network" : null, WanSpeedTests = speedTests ?? new List { @@ -484,6 +488,140 @@ public void Loaded_latency_surfaces_spiky_far_hop_not_hidden_by_flat_near_hop() withOlt.ValueText.Should().Contain("6.0 ms down"); } + [Fact] + public void A_hop_squealing_while_the_rest_of_the_WAN_reads_clean_is_outvoted() + { + // Same shape as the OLT case above, but this WAN monitors enough targets to have an + // opinion. A queue on the access link sits in front of every one of them, so a single hop + // rising while transit and the internet destinations stay flat AT THE SAME SECOND is that + // responder deprioritizing ICMP - the reading the old flat pooling reported in full, + // because the noise floor discarded the clean samples before the median saw them. + var rates = TestSeries.Throughput(TestSeries.Start, Day, 50, 5) + .Select(r => r.Time >= LoadedDownStart && r.Time < LoadedDownEnd + ? r with { DownloadBps = 800_000_000 } + : r) + .ToList(); + + var nearHop = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3); + var squealer = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3) + .WithSegment(LoadedDownStart, LoadedDownEnd, 8.0, 0.3); + + AsnSeries Clean(string name, double rtt) => new() + { + AsnNumber = 0, + AsnName = name, + Samples = TestSeries.Flat(TestSeries.Start, Day, rtt, 0.3) + }; + + var inputs = new IspHealthInputs + { + WindowStart = TestSeries.Start, + WindowEnd = TestSeries.Start + Day, + FirstHopSeries = nearHop, + AccessHopSeries = new List> { nearHop, squealer }, + TransitAsnSeries = new List { Clean("Transit", 9.0) }, + DestinationSeries = new List { Clean("DNS", 14.0), Clean("CDN", 16.0) }, + LossPoolSeries = new List> { nearHop }, + WanRates = rates, + ExpectedDownloadMbps = 1000, + ExpectedUploadMbps = 500, + ExpectedSpeedSource = "UniFi Network", + WanSpeedTests = new List { new(TestSeries.Start.AddHours(6), 980, 490) } + }; + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Loaded Latency"); + + factor.ValueText.Should().NotContain("6.0 ms down"); + factor.Score.Should().Be(100); + } + + [Fact] + public void A_standby_link_is_graded_on_carrying_traffic_not_on_ratio() + { + // 1 / 1 is the lowest expected speed UniFi Network accepts, so a dish held in standby ends + // up there with nothing real to enter. Scored as a ratio it read 17 - a link doing exactly + // its job in the emergency it exists for, marked as failing. + var inputs = BuildInputs( + expectedDownMbps: 1, expectedUpMbps: 1, + speedTests: new List { new(TestSeries.Start.AddHours(6), 0.6, 0.1) }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().BeGreaterThan(80); + factor.ValueText.Should().Contain("0.6"); + factor.Description.Should().Contain("lowest UniFi Network allows"); + } + + [Fact] + public void A_dish_reporting_a_reduced_speed_tier_is_graded_that_way_against_a_real_plan() + { + // Ground truth beats the inference: the dish says its throughput is capped by the plan + // tier, so the shortfall is not the link - even though a real 1000 / 500 plan is + // configured and the ratio against it would read as a near-total failure. + var inputs = BuildInputs( + speedTests: new List { new(TestSeries.Start.AddHours(6), 0.6, 0.1) }, + physicalLink: new PhysicalLinkInput + { + Medium = PhysicalMedium.Satellite, + SourceName = "Dish", + ReducedSpeedTier = true + }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().BeGreaterThan(80); + factor.Description.Should().Contain("reduced-speed plan tier"); + } + + [Fact] + public void Satellite_idle_latency_is_anchored_on_measured_plans() + { + // Both ends come from real dishes: 23 ms is the best the medium does at all, and 42 ms is + // where a healthy Backup dish sits - the floor of good rather than a fault. + var satellite = IspHealthProfiles.GetProfile(AccessTechnology.Satellite)!; + + int Idle(double rtt) => new IspHealthScorer(Options) + .Score(BuildInputs(idleRtt: rtt), satellite) + .AccessDimension.Factors.Single(f => f.Name == "Idle Latency").Score!.Value; + + Idle(23).Should().Be(100); + Idle(42).Should().Be(80); + Idle(45).Should().BeInRange(70, 75); + } + + [Fact] + public void A_standby_link_carrying_nothing_still_fails() + { + // Forgiving is not blind: the one outcome that would actually fail its owner is a backup + // that carries nothing when called on. + var inputs = BuildInputs( + expectedDownMbps: 1, expectedUpMbps: 1, + speedTests: new List { new(TestSeries.Start.AddHours(6), 0.001, 0) }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().BeLessThan(30); + } + + [Fact] + public void A_real_plan_with_a_1_Mbps_upstream_is_still_graded() + { + // Half a sentinel is still a plan: 100 Mbps down cannot have been typed by someone with + // nothing to enter, so the link keeps its grade. + var inputs = BuildInputs( + expectedDownMbps: 100, expectedUpMbps: 1, + speedTests: new List { new(TestSeries.Start.AddHours(6), 95, 1) }); + + var factor = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Speed vs Plan"); + + factor.Score.Should().NotBeNull(); + } + [Fact] public void Below_band_idle_latency_scores_higher_than_above_band() { @@ -1369,7 +1507,8 @@ public void Transit_health_weights_asns_by_internet_host_involvement() }; var dest = new AsnSeries { - AsnNumber = 64512, AsnName = "Destination", + AsnNumber = 64512, + AsnName = "Destination", TargetIds = { "dest-clean" }, Samples = TestSeries.Flat(TestSeries.Start, Day, 13, 0.4), HopIps = { "30.0.0.1" }, @@ -1399,13 +1538,21 @@ public void Off_path_jittery_isp_hop_is_flagged_for_disable() // with high jitter is flagged SuggestDisable. The graded on-path hop never is. var graded = new AsnSeries { - AsnNumber = 64496, AsnName = "ISP", TargetIds = { "isp-near" }, RoleTargetIds = { "isp-near" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3), HopIps = { "10.0.0.1" } + AsnNumber = 64496, + AsnName = "ISP", + TargetIds = { "isp-near" }, + RoleTargetIds = { "isp-near" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3), + HopIps = { "10.0.0.1" } }; var offPathJittery = new AsnSeries { - AsnNumber = 64496, AsnName = "ISP", TargetIds = { "isp-olt" }, RoleTargetIds = { "isp-olt" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 4.0, 6.0), HopIps = { "10.0.0.9" } + AsnNumber = 64496, + AsnName = "ISP", + TargetIds = { "isp-olt" }, + RoleTargetIds = { "isp-olt" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 4.0, 6.0), + HopIps = { "10.0.0.9" } }; var hops = new List { graded, offPathJittery }; @@ -1436,8 +1583,11 @@ public void Transit_asns_with_no_attributable_hosts_are_floored_and_labeled() }; var peeredDest = new AsnSeries { - AsnNumber = 64512, AsnName = "Destination", TargetIds = { "dest" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 8, 0.4), HopIps = { "30.0.0.1" }, + AsnNumber = 64512, + AsnName = "Destination", + TargetIds = { "dest" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 8, 0.4), + HopIps = { "30.0.0.1" }, AncestorIps = { "9.9.9.9" } // routes through neither transit (peered) }; @@ -1469,20 +1619,29 @@ public void Ix_peering_entry_requires_both_low_delta_and_no_transit_on_path() }; var peered = new AsnSeries { - AsnNumber = 13335, AsnName = "Peered", TargetIds = { "peered" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), HopIps = { "30.0.0.1" }, + AsnNumber = 13335, + AsnName = "Peered", + TargetIds = { "peered" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), + HopIps = { "30.0.0.1" }, AncestorIps = { "10.0.0.1" } // access ISP hop only - crosses no transit }; var viaTransit = new AsnSeries { - AsnNumber = 15169, AsnName = "ViaTransit", TargetIds = { "via" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), HopIps = { "31.0.0.1" }, + AsnNumber = 15169, + AsnName = "ViaTransit", + TargetIds = { "via" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 4, 0.3), + HopIps = { "31.0.0.1" }, AncestorIps = { "10.0.0.1", "20.0.0.1" } // low RTT but routes through the transit ASN }; var farPeer = new AsnSeries { - AsnNumber = 54113, AsnName = "FarPeer", TargetIds = { "far" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 20, 0.3), HopIps = { "32.0.0.1" }, + AsnNumber = 54113, + AsnName = "FarPeer", + TargetIds = { "far" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 20, 0.3), + HopIps = { "32.0.0.1" }, AncestorIps = { "10.0.0.1" } // crosses no transit, but ~18 ms beyond the access hop }; @@ -1508,8 +1667,11 @@ public void Ix_peering_entry_is_absent_when_no_destination_is_directly_peered() }; var viaTransit = new AsnSeries { - AsnNumber = 15169, AsnName = "ViaTransit", TargetIds = { "via" }, - Samples = TestSeries.Flat(TestSeries.Start, Day, 12, 0.3), HopIps = { "31.0.0.1" }, + AsnNumber = 15169, + AsnName = "ViaTransit", + TargetIds = { "via" }, + Samples = TestSeries.Flat(TestSeries.Start, Day, 12, 0.3), + HopIps = { "31.0.0.1" }, AncestorIps = { "10.0.0.1", "20.0.0.1" } }; @@ -1762,14 +1924,19 @@ public void Loaded_latency_uses_thin_single_hop_data() } [Fact] - public void Loaded_latency_filters_sub_half_ms_deltas() + public void Loaded_latency_reports_a_line_that_stays_clean_under_load() { - // Access hops show sub-0.5 ms delta under load (no meaningful bufferbloat). - // All samples filtered out, returns null (falls back to speed tests). + // Access hops show sub-0.5 ms delta under load - no meaningful bufferbloat. + // + // This used to return null and fall through to the speed tests, on the reasoning that the + // noise floor had filtered everything and nothing was left to say. It is the opposite: no + // episode elevated means every time this line was loaded it stayed clean, which is the + // strongest statement the data can make. Returning null here is what left a real WAN + // reporting +23 ms from the median of whichever stray samples crossed the floor. var inputs = BuildInputs( accessHops: new() { LoadedDownHop(2, 0.1), LoadedDownHop(3, 0.2) }); - ResolvedDownDelta(inputs).Should().BeNull(); + ResolvedDownDelta(inputs).Should().BeInRange(0, 0.5); } [Fact] diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs new file mode 100644 index 0000000000..532db1f29c --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadCredibilityTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// Not every loaded moment is equally good evidence about behavior under load. A brief burst is +/// where load CLASSIFICATION goes wrong most often and is too short for buffers to fill; a +/// sustained saturation near plan speed is the best evidence available, better than a speed test, +/// which is itself short and synthetic. +/// +public class LoadCredibilityTests +{ + private const int WindowSeconds = 7; + + private static DateTime W(int index) => new DateTime(2026, 8, 5, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(index * WindowSeconds); + + [Fact] + public void Consecutive_windows_are_one_episode_and_carry_its_full_length() + { + // Three back-to-back windows are one 21-second episode, not three 7-second ones. + var seconds = SeriesStats.LoadEpisodeSeconds(new[] { W(0), W(1), W(2) }, WindowSeconds); + + seconds.Values.Should().AllBeEquivalentTo(21.0); + } + + [Fact] + public void A_gap_starts_a_new_episode() + { + // W(0..1) then a hole then W(5): two episodes, measured separately. + var seconds = SeriesStats.LoadEpisodeSeconds(new[] { W(0), W(1), W(5) }, WindowSeconds); + + seconds[W(0)].Should().Be(14); + seconds[W(1)].Should().Be(14); + seconds[W(5)].Should().Be(7); + } + + [Fact] + public void Order_and_duplicates_do_not_change_an_episode() + { + var seconds = SeriesStats.LoadEpisodeSeconds(new[] { W(2), W(0), W(1), W(1) }, WindowSeconds); + + seconds.Should().HaveCount(3); + seconds.Values.Should().AllBeEquivalentTo(21.0); + } + + [Fact] + public void A_short_burst_counts_for_less_than_a_sustained_saturation() + { + const double fullAt = 60, floor = 0.15; + + var burst = SeriesStats.Credibility(7, fullAt, floor); + var sustained = SeriesStats.Credibility(120, fullAt, floor); + + burst.Should().BeLessThan(sustained); + sustained.Should().Be(1); + // Weak evidence, never absent evidence. + burst.Should().BeGreaterThanOrEqualTo(floor); + } + + [Fact] + public void Utilization_is_judged_across_the_band_where_it_can_discriminate() + { + // Everything here is already classified loaded at 50% of plan, so a ramp from zero would + // score every episode near the top. The band starts above that threshold instead. + const double start = 0.60, full = 0.90, floor = 0.15; + + SeriesStats.CredibilityBetween(0.55, start, full, floor).Should().Be(floor); + SeriesStats.CredibilityBetween(0.75, start, full, floor).Should().BeApproximately(0.5, 0.001); + SeriesStats.CredibilityBetween(0.90, start, full, floor).Should().Be(1); + SeriesStats.CredibilityBetween(1.20, start, full, floor).Should().Be(1); + + // The naive ramp for comparison: 55% and 75% are nearly indistinguishable, which is the + // failure this band exists to avoid. + SeriesStats.Credibility(0.55, full, floor).Should().BeApproximately(0.61, 0.01); + SeriesStats.Credibility(0.75, full, floor).Should().BeApproximately(0.83, 0.01); + } + + [Fact] + public void A_weighted_mean_is_used_for_loss_because_a_median_of_mostly_zeros_is_zero() + { + // Loss is a rate: most samples are zero even on a line dropping traffic under load, so a + // median reports zero however bad the rest are. The mean carries them. + var samples = new[] { (0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (8.0, 1.0), (8.0, 1.0) }; + + SeriesStats.WeightedMedian(samples).Should().Be(0); + SeriesStats.WeightedMean(samples).Should().BeApproximately(3.2, 0.001); + } + + [Fact] + public void Credible_load_outweighs_doubtful_load_in_the_reported_loss() + { + // Same two readings, one from a long saturation and one from a two-second blip: the + // sustained one decides the number. + var trusted = new[] { (6.0, 1.0), (0.0, 0.15) }; + var doubted = new[] { (6.0, 0.15), (0.0, 1.0) }; + + SeriesStats.WeightedMean(trusted).Should().BeApproximately(5.22, 0.01); + SeriesStats.WeightedMean(doubted).Should().BeApproximately(0.78, 0.01); + } + + [Fact] + public void Nothing_credible_is_null_rather_than_zero() + { + SeriesStats.WeightedMean(new[] { (5.0, 0.0) }).Should().BeNull(); + SeriesStats.WeightedMean(Array.Empty<(double, double)>()).Should().BeNull(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs new file mode 100644 index 0000000000..ba0a2ced4f --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/LoadEpisodeTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// A load window is seven seconds; an episode is however long the line actually stayed loaded. +/// Grouping by window made "the newest three" mean the last twenty seconds, so any brief lull +/// inside one bad evening read as a line that had been fixed. +/// +public class LoadEpisodeTests +{ + private const int WindowSeconds = 7; + + private static DateTime W(int index) => new DateTime(2026, 8, 5, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(index * WindowSeconds); + + [Fact] + public void Consecutive_windows_share_one_episode_start() + { + var starts = SeriesStats.LoadEpisodeStarts(new[] { W(0), W(1), W(2) }, WindowSeconds); + + starts.Values.Should().AllBeEquivalentTo(W(0)); + } + + [Fact] + public void A_gap_begins_a_new_episode() + { + var starts = SeriesStats.LoadEpisodeStarts(new[] { W(0), W(1), W(9), W(10) }, WindowSeconds); + + starts[W(0)].Should().Be(W(0)); + starts[W(1)].Should().Be(W(0)); + starts[W(9)].Should().Be(W(9)); + starts[W(10)].Should().Be(W(9)); + starts.Values.Distinct().Should().HaveCount(2); + } + + [Fact] + public void A_long_saturation_is_one_episode_not_many() + { + // Five minutes of continuous load: one event, however many windows it spans. + var windows = Enumerable.Range(0, 43).Select(W).ToArray(); + + var starts = SeriesStats.LoadEpisodeStarts(windows, WindowSeconds); + + starts.Values.Distinct().Should().ContainSingle(); + SeriesStats.LoadEpisodeSeconds(windows, WindowSeconds).Values.Should().AllBeEquivalentTo(43 * 7.0); + } + + [Fact] + public void Unordered_input_still_groups_correctly() + { + var starts = SeriesStats.LoadEpisodeStarts(new[] { W(10), W(1), W(9), W(0) }, WindowSeconds); + + starts[W(1)].Should().Be(W(0)); + starts[W(10)].Should().Be(W(9)); + } + + [Fact] + public void Nothing_loaded_is_an_empty_map_rather_than_a_throw() + { + SeriesStats.LoadEpisodeStarts(Array.Empty(), WindowSeconds).Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs new file mode 100644 index 0000000000..bc403131c3 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/RecencyWeightedMedianTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// Loaded latency is read from WAN speed tests, and a plain median over the window treated a test +/// from an hour ago exactly like one from six days ago - so a line fixed this afternoon went on +/// reporting bufferbloat until the good tests outnumbered the bad, which on a daily schedule takes +/// a week. Weighting by recency answers "is it fixed NOW" without giving up the median's refusal to +/// swing on one sample. +/// +public class RecencyWeightedMedianTests +{ + // The shipped default. Shorter and the newest sample outweighs everything before it on a + // daily test schedule, which stops being a median at all - the last test in this file is what + // pins that down. + private const double HalfLifeHours = 48; + + private static (double Value, double Weight) Sample(double value, double ageHours) => + (value, SeriesStats.RecencyWeight(TimeSpan.FromHours(ageHours), HalfLifeHours)); + + [Fact] + public void With_no_decay_it_is_the_plain_median() + { + var samples = new[] { (1.0, 1.0), (5.0, 1.0), (30.0, 1.0) }; + + SeriesStats.WeightedMedian(samples).Should().Be(5.0); + } + + [Fact] + public void RecencyWeight_halves_every_half_life() + { + SeriesStats.RecencyWeight(TimeSpan.Zero, HalfLifeHours).Should().Be(1); + SeriesStats.RecencyWeight(TimeSpan.FromHours(48), HalfLifeHours).Should().BeApproximately(0.5, 0.001); + SeriesStats.RecencyWeight(TimeSpan.FromHours(96), HalfLifeHours).Should().BeApproximately(0.25, 0.001); + // Opting out restores equal weighting. + SeriesStats.RecencyWeight(TimeSpan.FromDays(30), 0).Should().Be(1); + } + + [Fact] + public void Three_clean_runs_outweigh_a_week_of_bad_ones() + { + // The WAN4 case: ~+23 ms every morning for a week, then the line is fixed and the last + // three runs come back clean. The plain median still reads ~23 and keeps the finding up. + var samples = new List<(double, double)> + { + Sample(0, 1), Sample(0, 5), Sample(0, 7), + }; + for (var day = 1; day <= 7; day++) samples.Add(Sample(23, day * 24)); + + SeriesStats.Median(samples.Select(s => s.Item1).ToList()).Should().Be(23); + SeriesStats.WeightedMedian(samples).Should().Be(0); + } + + [Fact] + public void One_clean_run_does_not_clear_a_standing_finding() + { + // The other half of the bargain: it is still a median, so a single good test among bad + // ones cannot call the fault fixed. + var samples = new List<(double, double)> { Sample(0, 1) }; + for (var day = 1; day <= 7; day++) samples.Add(Sample(23, day * 24)); + + SeriesStats.WeightedMedian(samples).Should().Be(23); + } + + [Fact] + public void One_bad_run_does_not_raise_a_finding_on_its_own() + { + var samples = new List<(double, double)> { Sample(40, 1) }; + for (var day = 1; day <= 5; day++) samples.Add(Sample(2, day * 24)); + + SeriesStats.WeightedMedian(samples).Should().Be(2); + } + + [Fact] + public void Nothing_to_weigh_is_null() + { + SeriesStats.WeightedMedian(Array.Empty<(double, double)>()).Should().BeNull(); + SeriesStats.WeightedMedian(new[] { (5.0, 0.0) }).Should().BeNull(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs b/tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs new file mode 100644 index 0000000000..13ae0d30b1 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/IspHealth/SpeedTestLiftTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.IspHealth; + +/// +/// A WAN speed test measures the same event on purpose and at full saturation, while the latency +/// probes only sample it on their own cadence - so a short event's peak queue can build and drain +/// between two probes unseen. The test stands in only where it read HIGHER, which is the one +/// direction passive sampling fails in. +/// +/// That asymmetry is only fair while neither instrument can over-read, so a test that never filled +/// the pipe is refused: it did not load the buffers, and since the substitution only ever raises +/// the figure there is nothing downstream able to correct it. +/// +/// +/// Distinct from the older wholesale fallback, which takes the speed tests' own deltas when the +/// series yields no loaded figure AT ALL - a path that is no longer reachable while there are +/// loaded windows, since a line whose every episode read clean now answers 0 rather than nothing. +/// +/// +public class SpeedTestLiftTests +{ + private static readonly TimeSpan Day = TimeSpan.FromHours(24); + private static readonly DateTime LoadedStart = TestSeries.Start.AddHours(12); + private static readonly DateTime LoadedEnd = TestSeries.Start.AddHours(18); + private static readonly AccessProfile Gpon = IspHealthProfiles.GetProfile(AccessTechnology.Gpon)!; + private static readonly IspHealthOptions Options = new(); + + /// + /// What the probes themselves saw under load. The idle floor is 2.0, so 3.0 is a measured + /// delta of about 1 ms; passing 2.0 leaves the series flat, which now reads as a clean line + /// rather than as an absent measurement. + /// + private static double? LoadedDown(double loadedHopRtt, params SpeedTestSample[] tests) + { + var rates = TestSeries.Throughput(TestSeries.Start, Day, 50, 5) + .Select(r => r.Time >= LoadedStart && r.Time < LoadedEnd + ? r with { DownloadBps = 800_000_000 } + : r) + .ToList(); + + var hop = TestSeries.Flat(TestSeries.Start, Day, 2.0, 0.3) + .WithSegment(LoadedStart, LoadedEnd, loadedHopRtt, 0.3); + + var inputs = new IspHealthInputs + { + WindowStart = TestSeries.Start, + WindowEnd = TestSeries.Start + Day, + FirstHopSeries = hop, + AccessHopSeries = new List> { hop }, + LossPoolSeries = new List> { hop }, + WanRates = rates, + ExpectedDownloadMbps = 1000, + ExpectedUploadMbps = 500, + ExpectedSpeedSource = "UniFi Network", + WanSpeedTests = tests.ToList() + }; + + var text = new IspHealthScorer(Options).Score(inputs, Gpon) + .AccessDimension.Factors.Single(f => f.Name == "Loaded Latency").ValueText; + + return double.TryParse(text?.Split(" ms down")[0], out var v) ? v : null; + } + + private static SpeedTestSample Test(DateTime at, double downMbps, double loadedMs, double? idleMs = 6) => + new(at, downMbps, 490, PingMs: idleMs, DownloadLatencyMs: loadedMs, UploadLatencyMs: 8); + + [Fact] + public void A_saturating_test_that_saw_more_queue_than_the_probes_did_sets_the_figure() + { + // 980 of a 1000 plan, 31 ms under load against its own 6 ms idle: it filled the pipe and + // measured 25 ms of queue the probes, reading about 1 ms, never sampled. + var measured = LoadedDown(3.0); + var lifted = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 980, 31)); + + // Higher, not 25: the lift is confined to the ONE episode the test overlapped, and the + // factor is the median across every episode in the window. A single test moving the whole + // figure to its own reading would be exactly the unconfined bias this avoids. + lifted.Should().BeGreaterThan(measured!.Value); + } + + [Fact] + public void A_test_that_never_filled_the_pipe_is_refused() + { + // Same 25 ms at a fifth of plan - it never loaded the buffers, so whatever it measured was + // not this link at saturation. This is the case that would otherwise bias every matched + // episode upward with nothing able to pull it back. + var measured = LoadedDown(3.0); + var lifted = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 200, 31)); + + lifted.Should().Be(measured); + } + + [Fact] + public void A_test_reading_lower_than_the_probes_does_not_pull_the_figure_down() + { + var measured = LoadedDown(3.0); + var clean = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 980, 6.1)); + + clean.Should().Be(measured); + } + + [Fact] + public void A_test_from_outside_the_episode_is_not_its_measurement() + { + var measured = LoadedDown(3.0); + var far = LoadedDown(3.0, Test(TestSeries.Start.AddHours(2), 980, 31)); + + far.Should().Be(measured); + } + + [Fact] + public void A_test_without_its_own_idle_reference_is_unusable() + { + // The delta is loaded-minus-idle from the SAME probe seconds apart. With no idle figure + // there is nothing to subtract, and borrowing our baseline would reintroduce every blind + // spot the substitution exists to avoid. + var measured = LoadedDown(3.0); + var noIdle = LoadedDown(3.0, Test(LoadedStart.AddHours(1), 980, 31, idleMs: null)); + + noIdle.Should().Be(measured); + } + + [Fact] + public void Recent_clean_tests_outrank_an_older_bad_one() + { + // The regression this exists for. Taking the highest qualifying test in the window meant + // one bad day outranked every clean test since, so a line whose recent tests are all clean + // kept reporting its worst reading from a week ago - and it walked straight past the + // clean-run verdict that had already decided the line was fixed. + var oldBad = Test(LoadedStart.AddMinutes(10), 980, 31); + var recentClean = new[] + { + Test(LoadedStart.AddHours(3), 980, 6.2), + Test(LoadedStart.AddHours(4), 980, 6.1), + Test(LoadedStart.AddHours(5), 980, 6.3), + }; + + var withHistory = LoadedDown(3.0, new[] { oldBad }.Concat(recentClean).ToArray()); + + withHistory.Should().BeLessThan(10); + } + + [Fact] + public void A_site_whose_probes_saw_nothing_still_gets_what_its_test_measured() + { + // Since load episodes that all read clean became a real answer rather than no-answer, a + // flat series returns 0 instead of null and never reaches the older wholesale fallback. + // The lift covers that hole from the other side: the site is not left blind just because + // its probes never sampled the queue its own test measured. + var flat = LoadedDown(2.0, Test(LoadedStart.AddHours(1), 980, 31)); + + flat.Should().BeApproximately(25, 1); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs b/tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs new file mode 100644 index 0000000000..e89844ea1c --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/MeteredProbePolicyTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +public class MeteredProbePolicyTests +{ + [Theory] + [InlineData(AccessTechnology.Gpon)] + [InlineData(AccessTechnology.XgsPon)] + [InlineData(AccessTechnology.Docsis)] + [InlineData(AccessTechnology.DirectEthernet)] + [InlineData(AccessTechnology.PppoE)] + [InlineData(AccessTechnology.Dsl)] + [InlineData(AccessTechnology.Unknown)] + [InlineData(AccessTechnology.Other)] + public void Wireline_and_unknown_technologies_probe_as_before(AccessTechnology technology) + { + var plan = MeteredProbePolicy.For(technology, dataUsageEnabled: false); + + plan.Rung.Should().Be(0); + plan.MaxAutoEnabled.Should().BeNull(); + plan.PollIntervalSeconds.Should().Be(MeteredProbePolicy.DefaultIntervalSeconds); + } + + [Theory] + [InlineData(AccessTechnology.Satellite)] + [InlineData(AccessTechnology.Cellular)] + [InlineData(AccessTechnology.FixedWireless)] + public void Usually_metered_technologies_drop_a_rung(AccessTechnology technology) + { + var plan = MeteredProbePolicy.For(technology, dataUsageEnabled: false); + + plan.Rung.Should().Be(1); + plan.MaxAutoEnabled.Should().Be(15); + plan.PollIntervalSeconds.Should().Be(30); + } + + [Fact] + public void A_declared_cap_drops_a_rung_on_its_own() + { + // Cable with a cap costs the same per byte as satellite without one. + var plan = MeteredProbePolicy.For(AccessTechnology.Docsis, dataUsageEnabled: true); + + plan.Rung.Should().Be(1); + plan.MaxAutoEnabled.Should().Be(15); + } + + [Fact] + public void The_two_signals_stack() + { + var plan = MeteredProbePolicy.For(AccessTechnology.Satellite, dataUsageEnabled: true); + + plan.Rung.Should().Be(2); + plan.MaxAutoEnabled.Should().Be(8); + plan.PollIntervalSeconds.Should().Be(60); + } + + [Fact] + public void Rungs_land_where_the_traffic_estimate_says_they_should() + { + // The numbers the ladder was chosen against, both directions, 30 days. + MeteredProbePolicy.EstimatedMonthlyGb(25, 10).Should().BeApproximately(5.44, 0.05); + MeteredProbePolicy.EstimatedMonthlyGb(15, 30).Should().BeApproximately(1.09, 0.05); + MeteredProbePolicy.EstimatedMonthlyGb(8, 60).Should().BeApproximately(0.29, 0.02); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs new file mode 100644 index 0000000000..13095f2a08 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanDeepLinkTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Microsoft.AspNetCore.WebUtilities; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Clicking a WAN's live score has to open THAT WAN's report. The live tiles and the analysis +/// pages keep their selections apart on purpose, so the link carries the WAN explicitly rather +/// than the two sharing state. +/// +public class IspHealthWanDeepLinkTests +{ + private static string? LinkedWanKey(string uri) + { + var value = QueryHelpers.ParseQuery(new Uri(uri).Query) + .TryGetValue("wan", out var v) ? v.ToString() : null; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + } + + [Theory] + [InlineData("https://x/monitoring?tab=isp-health&wan=wan2", "wan2")] + [InlineData("https://x/monitoring?tab=isp-health&wan=WAN2", "wan2")] + [InlineData("https://x/monitoring?tab=isp-health", null)] + [InlineData("https://x/monitoring?tab=isp-health&wan=", null)] + [InlineData("https://x/monitoring", null)] + public void TheLinkedWanIsReadFromTheQuery(string uri, string? expected) + { + LinkedWanKey(uri).Should().Be(expected); + } + + [Fact] + public void APrimarySelectionAddsNoParameter() + { + // The primary's report is what the page opens on anyway; a parameter would only be noise + // in the address bar. + var query = (IsPrimary: true, Key: "wan") is { IsPrimary: false } w + ? $"&wan={Uri.EscapeDataString(w.Key)}" : ""; + + query.Should().BeEmpty(); + } + + [Fact] + public void ANonPrimarySelectionTravels() + { + var sel = (IsPrimary: false, Key: "wan2"); + var query = !sel.IsPrimary ? $"&wan={Uri.EscapeDataString(sel.Key)}" : ""; + + query.Should().Be("&wan=wan2"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs new file mode 100644 index 0000000000..c5053547ce --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/IspHealthWanScopingTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// ISP Health now scopes every input to the WAN it grades. These pin the scoping predicates +/// themselves - which targets a WAN owns, which Influx wan-tag filter each scope emits, and +/// how the primary's wan key resolves - plus the single-WAN equivalence bar: with one WAN and +/// no contexts, the scoped selection must be exactly what the old unscoped queries returned. +/// +public class IspHealthWanScopingTests +{ + private static MonitoringTarget Target(string id, string? wan) => new() + { + TargetId = id, + Name = id, + Address = "192.0.2.1", + WanInterface = wan, + }; + + // ─── Target scoping ─── + + [Fact] + public void PrimaryScope_KeepsItsOwnAndUnstampedRows() + { + var targets = new List + { + Target("a", null), // hand-added / legacy - always a primary-path measurement + Target("b", ""), + Target("c", "wan"), + Target("d", "WAN"), // key case is not a different WAN + Target("e", "wan2"), // another WAN's row must never grade the primary + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan", includeUnassigned: true) + .Select(t => t.TargetId).Should().Equal("a", "b", "c", "d"); + } + + [Fact] + public void ScopedWan_OwnsOnlyRowsStampedWithItsKey() + { + var targets = new List + { + Target("a", null), // unstamped belongs to the primary, not to wan2 + Target("b", "wan"), + Target("c", "wan2"), + Target("d", "WAN2"), + Target("e", "wan2"), + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan2", includeUnassigned: false) + .Select(t => t.TargetId).Should().Equal("c", "d", "e"); + } + + [Fact] + public void SingleWanSite_ScopedSelectionIsExactlyTheOldUnscopedOne() + { + // The equivalence bar: a single-WAN site's rows are unstamped (legacy/hand-added) or + // stamped with its one wan key, so the primary scope selects every row the old + // unfiltered query returned - same rows, same order. + var targets = new List + { + Target("legacy", null), + Target("hop", "wan"), + Target("transit", "wan"), + Target("dns", null), + }; + + var scoped = IspHealthService.ScopeTargetsToWan(targets, "wan", includeUnassigned: true); + + scoped.Should().Equal(targets); + } + + // ─── Primary wan key resolution ─── + + [Fact] + public void PrimaryWanKey_FallsBackToTheConventionalWanWithNoContexts() + { + IspHealthService.ResolvePrimaryWanKey(Array.Empty()).Should().Be("wan"); + } + + [Fact] + public void PrimaryWanKey_PrefersTheWanRowOverOthers() + { + var contexts = new[] + { + new WanDiscoveryContext { WanInterface = "wan2" }, + new WanDiscoveryContext { WanInterface = "wan" }, + }; + IspHealthService.ResolvePrimaryWanKey(contexts).Should().Be("wan"); + } + + [Fact] + public void PrimaryWanKey_TakesTheOnlyRowWhenWanIsAbsent() + { + var contexts = new[] { new WanDiscoveryContext { WanInterface = "wan2" } }; + IspHealthService.ResolvePrimaryWanKey(contexts).Should().Be("wan2"); + } + + // ─── Influx wan-tag scope ─── + + [Fact] + public void PrimaryScope_WithNoContextsReadsOnlyUntaggedSeries() + { + var scope = IspHealthService.BuildWanScope(Array.Empty(), "wan", primaryScope: true); + + scope.IncludeUntagged.Should().BeTrue(); + scope.WanTags.Should().BeEmpty(); + } + + [Fact] + public void PrimaryScope_IgnoresContextsBoundToOtherWans() + { + var contexts = new[] { new WanContext { Name = "backup", WanInterface = "wan2" } }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan", primaryScope: true); + + scope.WanTags.Should().BeEmpty(); + } + + [Fact] + public void PrimaryScope_KeepsAPrimaryBoundContextsTaggedPoints() + { + var contexts = new[] { new WanContext { Name = "gw-bound", WanInterface = "wan" } }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan", primaryScope: true); + + scope.IncludeUntagged.Should().BeTrue(); + scope.WanTags.Should().BeEquivalentTo("wan", "gw-bound"); + } + + [Fact] + public void ScopedWan_ReadsItsKeyAndItsContextsNames_NeverUntagged() + { + var contexts = new[] + { + new WanContext { Name = "starlink-backup", WanInterface = "wan2" }, + new WanContext { Name = "other", WanInterface = "wan3" }, + }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan2", primaryScope: false); + + scope.IncludeUntagged.Should().BeFalse(); + scope.WanTags.Should().BeEquivalentTo("wan2", "starlink-backup"); + } + + [Fact] + public void ScopedWan_WithNoContextRowStillFiltersOnItsStableKey() + { + var scope = IspHealthService.BuildWanScope(Array.Empty(), "wan2", primaryScope: false); + + scope.IncludeUntagged.Should().BeFalse(); + scope.WanTags.Should().Equal("wan2"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs new file mode 100644 index 0000000000..4465ae0027 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/OldAgentCompatibilityTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using Google.Protobuf; +using NetworkOptimizer.AgentProtocol; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The new server has to keep working against agent binaries that predate this branch, because +/// that is what every deployed site is running until the agents are rolled out. The rule is that +/// an old agent behaves exactly as it did, and is never handed work it cannot do correctly. +/// +public class OldAgentCompatibilityTests +{ + [Fact] + public void AnOldAgentsHello_ReadsAsDidNotSay_NotAsNo() + { + // No supports_source_bind on the wire at all. Absent has to stay distinguishable from an + // explicit false, because "cannot bind" and "did not say" get treated the same only by + // accident - and the field is what gates offering an interface bind. + var hello = new AgentHello { AgentKey = "k", Version = "2.5.3", LanIp = "192.0.2.10" }; + + hello.HasSupportsSourceBind.Should().BeFalse(); + var stored = hello.HasSupportsSourceBind ? hello.SupportsSourceBind : (bool?)null; + stored.Should().BeNull(); + } + + [Fact] + public void ANewAgentCanSayNo_Distinctly() + { + var hello = new AgentHello { AgentKey = "k", SupportsSourceBind = false }; + + hello.HasSupportsSourceBind.Should().BeTrue(); + var stored = hello.HasSupportsSourceBind ? hello.SupportsSourceBind : (bool?)null; + stored.Should().Be(false); + } + + [Fact] + public void AnOldAgentRoundTripsThroughTheNewProto() + { + // Field 6 is new; nothing else moved. An old agent's bytes still parse, and a new server's + // extra field does not disturb the fields an old agent reads. + var hello = new AgentHello { AgentKey = "k", Version = "2.5.3", LanIp = "192.0.2.10", SpeedTestPort = 3000 }; + + var parsed = AgentHello.Parser.ParseFrom(hello.ToByteArray()); + + parsed.AgentKey.Should().Be("k"); + parsed.LanIp.Should().Be("192.0.2.10"); + parsed.SpeedTestPort.Should().Be(3000); + parsed.HasSupportsSourceBind.Should().BeFalse(); + } + + [Fact] + public void ProbeTargetSpecSourceIp_IsNotNewOnThisBranch() + { + // The field the server now populates predates this work, and old agents already prefer it + // over their own default - which is why per-probe PING binding works before any rollout. + var spec = new ProbeTargetSpec { TargetId = "t", Address = "192.0.2.1", SourceIp = "198.51.100.7" }; + + spec.SourceIp.Should().Be("198.51.100.7"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs new file mode 100644 index 0000000000..34dd6f1ee5 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/PerWanDiscoveryTests.cs @@ -0,0 +1,311 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Upstream discovery used to run for one WAN - the configured primary - so a secondary WAN's +/// context had targets nobody discovered and no hop ancestry to grade. It now runs per context, +/// which puts two things on every target it writes: the WAN the data describes and the context +/// whose agent probes it. These cover that double stamping, the rule that keeps two WANs' runs +/// from fighting over one shared row, and the per-WAN cadence that decides who runs when - each +/// with its no-contexts counterpart, since that is every single-WAN install. +/// +public class PerWanDiscoveryTests +{ + private static NetworkOptimizerDbContext NewDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static AccessHopCandidate Hop(string address) => new() + { + TargetId = $"access-{address}", + Label = "First hop", + Address = address, + AsnNumber = 64500, + AsnName = "Example ISP", + Role = UpstreamRole.AccessHop, + HopNumber = 1, + RespondedTo = ProbeMode.Icmp, + Method = DiscoveryMethod.DirectRouter, + Enabled = true, + }; + + private static TransitAsnCandidate Transit(string address) => new() + { + AsnNumber = 64501, + AsnName = "Example Transit", + Method = DiscoveryMethod.DirectRouter, + TargetId = $"transit-as64501-{address}", + HopAddress = address, + RespondedTo = ProbeMode.Icmp, + Enabled = true, + }; + + [Fact] + public async Task ContextRun_StampsBothTheWanAndTheContextOnANewAccessTarget() + { + await using var db = NewDb(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan2"); + target.WanContextId.Should().Be(4); + } + + [Fact] + public async Task ContextRun_StampsBothOnANewTransitTarget() + { + await using var db = NewDb(); + + await UpstreamTracerService.UpsertTransitTargetAsync(db, Transit("203.0.113.9"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan2"); + target.WanContextId.Should().Be(4); + } + + [Fact] + public async Task PrimaryRun_LeavesTheContextAloneJustAsItAlwaysHas() + { + // No contexts means no context id, and nothing about the written row changes. + await using var db = NewDb(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan", wanContextId: null, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan"); + target.WanContextId.Should().BeNull(); + } + + [Fact] + public async Task PrimaryRun_KeepsAHandAssignedContextOnRevalidation() + { + // The per-target WAN dropdown is the user's own statement about who probes a target; a + // primary re-validation that cleared it would silently move the target back. + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + WanInterface = "wan", + WanContextId = 9, + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan", wanContextId: null, default); + await db.SaveChangesAsync(); + + (await db.MonitoringTargets.SingleAsync()).WanContextId.Should().Be(9); + } + + [Fact] + public async Task ContextRun_CreatesItsOwnTwinForAHostAnotherWanAlreadyClaimed() + { + // A host both WANs reach - a core resolver, a shared ISP hop - is probed from BOTH: + // the claiming WAN keeps the base row untouched (never re-homed, never re-enabled by + // the other run), and the second WAN gets its own WAN-qualified row so the two series + // stay separable and comparable by Address. + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + WanInterface = "wan", + Enabled = false, + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var original = await db.MonitoringTargets.SingleAsync(t => t.TargetId == "access-198.51.100.1"); + original.WanInterface.Should().Be("wan"); + original.WanContextId.Should().BeNull(); + original.Enabled.Should().BeFalse(); + + var twin = await db.MonitoringTargets.SingleAsync(t => t.TargetId == "access-198.51.100.1@wan2"); + twin.WanInterface.Should().Be("wan2"); + twin.WanContextId.Should().Be(4); + twin.Enabled.Should().BeTrue(); + twin.Address.Should().Be(original.Address); + } + + [Fact] + public async Task ContextRun_RevalidatesItsTwinInsteadOfStackingAnother() + { + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + WanInterface = "wan", + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + (await db.MonitoringTargets.CountAsync()).Should().Be(2); + (await db.MonitoringTargets.CountAsync(t => t.WanInterface == "wan2")).Should().Be(1); + } + + [Fact] + public async Task ContextRun_CreatesATransitTwinTheSameWay() + { + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "transit-as64501-203.0.113.9", + Name = "Example Transit", + Address = "203.0.113.9", + TargetType = MonitoringTargetType.Transit, + WanInterface = "wan", + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTransitTargetAsync(db, Transit("203.0.113.9"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var twin = await db.MonitoringTargets.SingleAsync(t => t.TargetId == "transit-as64501-203.0.113.9@wan2"); + twin.WanInterface.Should().Be("wan2"); + (await db.MonitoringTargets.SingleAsync(t => t.TargetId == "transit-as64501-203.0.113.9")) + .WanInterface.Should().Be("wan"); + } + + [Fact] + public void WanQualifiedTargetId_SuffixesTheWanKeyStably() + { + UpstreamTracerService.WanQualifiedTargetId("access-198.51.100.1", "WAN2") + .Should().Be("access-198.51.100.1@wan2"); + } + + [Fact] + public async Task ContextRun_AdoptsARowThatHasNoWanYet() + { + await using var db = NewDb(); + db.MonitoringTargets.Add(new MonitoringTarget + { + TargetId = "access-198.51.100.1", + Name = "First hop", + Address = "198.51.100.1", + TargetType = MonitoringTargetType.AccessIsp, + }); + await db.SaveChangesAsync(); + + await UpstreamTracerService.UpsertTargetAsync(db, Hop("198.51.100.1"), "wan2", wanContextId: 4, default); + await db.SaveChangesAsync(); + + var target = await db.MonitoringTargets.SingleAsync(); + target.WanInterface.Should().Be("wan2"); + target.WanContextId.Should().Be(4); + } + + [Theory] + [InlineData(null, "wan", true)] // never stamped - adoptable, which is every legacy row + [InlineData("", "wan", true)] + [InlineData("wan", "wan", true)] + [InlineData("WAN", "wan", true)] // the WAN key's case is not a different WAN + [InlineData("wan2", "wan", false)] + public void OwnsTargetRow_LetsARunWriteOnlyItsOwnWansRows(string? rowWan, string runWan, bool expected) + { + UpstreamTracerService.OwnsTargetRow(rowWan, runWan).Should().Be(expected); + } + + [Fact] + public void ContextsDueForDiscovery_SkipsAContextThatHasNoWanYet() + { + var contexts = new[] { new WanContext { Id = 1, Name = "backup-wan" } }; + + UpstreamRediscoveryService.ContextsDueForDiscovery( + contexts, new Dictionary(), DateTime.UtcNow, TimeSpan.FromDays(7)) + .Should().BeEmpty(); + } + + [Fact] + public void ContextsDueForDiscovery_RunsAWanThatHasNeverDiscovered() + { + var contexts = new[] { new WanContext { Id = 1, Name = "backup-wan", WanInterface = "wan2" } }; + + UpstreamRediscoveryService.ContextsDueForDiscovery( + contexts, new Dictionary(), DateTime.UtcNow, TimeSpan.FromDays(7)) + .Should().ContainSingle().Which.WanInterface.Should().Be("wan2"); + } + + [Fact] + public void ContextsDueForDiscovery_HoldsAWanDiscoveredRecentlyAndRunsAStaleOne() + { + var now = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc); + var contexts = new[] + { + new WanContext { Id = 1, Name = "backup-wan", WanInterface = "wan2" }, + new WanContext { Id = 2, Name = "lte", WanInterface = "wan3" }, + }; + var last = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["wan2"] = now.AddDays(-1), + ["wan3"] = now.AddDays(-9), + }; + + UpstreamRediscoveryService.ContextsDueForDiscovery(contexts, last, now, TimeSpan.FromDays(7)) + .Select(c => c.WanInterface).Should().Equal("wan3"); + } + + [Fact] + public void ContextsDueForDiscovery_WithNoContextsRunsNothing() + { + UpstreamRediscoveryService.ContextsDueForDiscovery( + Array.Empty(), new Dictionary(), DateTime.UtcNow, TimeSpan.FromDays(7)) + .Should().BeEmpty(); + } + + [Fact] + public void SelectAgent_WithNoAgentAskedForTakesTheSitesFirst() + { + var connections = Connections(1, 2); + + AgentProbeService.SelectAgent(connections, null)!.AgentId.Should().Be(1); + } + + [Fact] + public void SelectAgent_TakesTheAgentAskedFor() + { + var connections = Connections(1, 2); + + AgentProbeService.SelectAgent(connections, 2)!.AgentId.Should().Be(2); + } + + [Fact] + public void SelectAgent_NeverSubstitutesAnotherAgentForTheOneAskedFor() + { + // The named agent sits behind a particular WAN; another one measures a different path. + var connections = Connections(1, 2); + + AgentProbeService.SelectAgent(connections, 99).Should().BeNull(); + } + + private static List Connections(params int[] agentIds) + { + var registry = new AgentTunnelRegistry(new AgentTunnelOptions(Enabled: true, Port: 0)); + return agentIds.Select(id => registry.Register(id, "site1", $"Agent{id}")).ToList(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs new file mode 100644 index 0000000000..d75654c1e7 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/ProbeVantagesTests.cs @@ -0,0 +1,125 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Network Tools offers a choice of where a probe runs from only when there is a choice to make. +/// One origin - which is every single-WAN, single-agent site - leaves the page exactly as it was, +/// and an agent that runs on the gateway stays a separate entry from the gateway's own SSH +/// vantage on purpose: same box, different execution paths, and telling them apart is what +/// separates an agent-side binding problem from a network one. +/// +public class ProbeVantagesTests +{ + private static ProbeVantageAgent Agent( + int id, string name, bool onGateway = false, params ProbeVantageBinding[] vantages) + => new(id, name, onGateway, vantages); + + private static ProbeVantageBinding Vantage( + int id, string name, string? wanLabel = null, string? bind = null) + => new(id, name, wanLabel, bind); + + [Fact] + public void ServerOnly_OffersNoPicker() + { + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", Array.Empty()); + + options.Should().BeEmpty(); + } + + [Fact] + public void SingleAgentSiteWhereTheAgentIsTheServerVantage_OffersNoPicker() + { + // A secondary site with one agent: the "server" vantage already means that agent, so + // listing it twice would be the only thing a picker added. + var options = ProbeVantages.ForPicker(false, "On-site agent", new[] { Agent(1, "Agent1") }); + + options.Should().BeEmpty(); + } + + [Fact] + public void ServerPlusAContextAgent_OffersBoth() + { + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", new[] + { + Agent(7, "Agent1", false, Vantage(4, "backup-wan", "Backup ISP WAN2", "198.51.100.7")) + }); + + options.Select(o => o.Key).Should().Equal("server", "agent:7:4"); + options[0].AgentId.Should().BeNull(); + options[1].Label.Should().Be("Agent1 - Backup ISP WAN2"); + options[1].AgentId.Should().Be(7); + options[1].SourceBind.Should().Be("198.51.100.7"); + } + + [Fact] + public void OnGatewayAgent_IsListedSeparatelyAndSaysSo() + { + // Deliberate: the gateway is also offered as its own SSH vantage elsewhere on the page, + // and these two are never collapsed into one entry. + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", new[] + { + Agent(3, "Agent1", true, Vantage(9, "wan2-context", "Backup ISP WAN2", "eth8")) + }); + + options.Should().HaveCount(2); + options[1].Label.Should().Be("Agent1 - Backup ISP WAN2 (gateway)"); + options[1].SourceBind.Should().Be("eth8"); + } + + [Fact] + public void OnGatewayAgentWithNoContext_StillCarriesTheMarker() + { + var label = ProbeVantages.LabelFor(Agent(4, "Agent2", onGateway: true), null); + + label.Should().Be("Agent2 (gateway)"); + } + + [Fact] + public void PlainAgent_IsJustItsName() + { + ProbeVantages.LabelFor(Agent(5, "Agent3"), null).Should().Be("Agent3"); + } + + [Fact] + public void ContextWithNoKnownWan_LabelsTheContextAlone() + { + // The console can be unreachable when the list is built; the vantage still names itself. + ProbeVantages.LabelFor(Agent(6, "Agent4"), Vantage(2, "backup-wan")) + .Should().Be("Agent4 - backup-wan"); + } + + [Fact] + public void AnAgentWithSeveralVantages_OffersOneEntryEach() + { + // Each vantage binds differently, so each is its own place to probe from. Offered as one + // entry per agent, the picker had to choose a binding and probes left by whichever + // vantage sorted first. + var options = ProbeVantages.ForPicker(true, "Network Optimizer server", new[] + { + Agent(67, "Agent 2", true, + Vantage(11, "Yelcot Cable (WAN4)", "Yelcot Cable WAN4", "eth1"), + Vantage(12, "Starlink (WAN2)", "Starlink WAN2", "eth0")) + }); + + options.Select(o => o.Key).Should().Equal("server", "agent:67:12", "agent:67:11"); + options[1].Label.Should().Be("Agent 2 - Starlink WAN2 (gateway)"); + options[1].SourceBind.Should().Be("eth0"); + options[2].Label.Should().Be("Agent 2 - Yelcot Cable WAN4 (gateway)"); + options[2].SourceBind.Should().Be("eth1"); + } + + [Fact] + public void TwoAgentsWithNoServerVantage_AreBothOffered() + { + var options = ProbeVantages.ForPicker(false, "On-site agent", new[] + { + Agent(2, "Zulu"), Agent(1, "Alpha", false, Vantage(3, "backup-wan")) + }); + + options.Select(o => o.Key).Should().Equal("agent:1:3", "agent:2"); + options.Should().NotContain(o => o.Key == ProbeVantages.ServerKey); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs new file mode 100644 index 0000000000..c25104d497 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/SiteLoadBalanceDetectionTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Whether the site spreads traffic across WANs decides what an unpinned probe is worth: under +/// failover-only every unpinned box leaves by the primary and measures it honestly, while under +/// load balancing the same probe is spread across WANs and attributable to none of them. +/// +public class SiteLoadBalanceDetectionTests +{ + private static NetworkInfo Wan(string group, string? lbType, bool enabled = true) => new() + { + Name = group, + Purpose = "wan", + Enabled = enabled, + WanNetworkgroup = group, + WanLoadBalanceType = lbType, + }; + + [Fact] + public void OneWan_IsNotLoadBalancing() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] { Wan("WAN", null) }).Should().BeFalse(); + } + + [Fact] + public void APrimaryWithAFailoverOnlyBackup_IsNotLoadBalancing() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "failover-only"), + }).Should().BeFalse(); + } + + [Fact] + public void TwoWeightedWans_AreLoadBalancing() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "weighted"), + }).Should().BeTrue(); + } + + [Fact] + public void ADisabledSecondWan_DoesNotCount() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "weighted", enabled: false), + }).Should().BeFalse(); + } + + [Fact] + public void ThreeWansWithOneOnFailover_StillLoadBalanceTheOtherTwo() + { + UniFiConnectionService.ResolveSiteLoadBalances(new[] + { + Wan("WAN", null), + Wan("WAN2", "weighted"), + Wan("WAN3", "failover-only"), + }).Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs new file mode 100644 index 0000000000..642c915244 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/Wan2PrimarySiteTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Services.Monitoring; +using NetworkOptimizer.Web.Services.Monitoring.IspHealth; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Primary is a ROLE, not a name. WAN1/WAN2/WAN3 are arbitrary labels in UniFi Network and any of +/// them can hold the primary role - a site whose WAN2 is primary and WAN1 is the failover is an +/// ordinary configuration, not an exotic one. Every "which WAN is primary" answer therefore has to +/// come from the configured primary network group, never from the conventional "wan"-first +/// ordering. This fixture is that site: WAN2 primary, WAN1 failover, with a context on each. +/// +public class Wan2PrimarySiteTests +{ + private static NetworkInfo Wan(string group) => new() + { + Id = group, + Name = group, + Purpose = "wan", + Enabled = true, + WanNetworkgroup = group, + }; + + private static MonitoringTarget Target(string id, string? wan) => new() + { + TargetId = id, + Name = id, + Address = "192.0.2.1", + WanInterface = wan, + }; + + // ─── The scope key the primary report resolves ─── + + [Theory] + [InlineData("WAN", "wan")] + [InlineData("WAN2", "wan2")] + [InlineData("WAN3", "wan3")] + [InlineData("wan2", "wan2")] + public void ConfiguredPrimaryWanKey_TakesWhicheverGroupHoldsTheRole(string group, string expected) + { + IspHealthService.ConfiguredPrimaryWanKey(Wan(group)).Should().Be(expected); + } + + [Fact] + public void ConfiguredPrimaryWanKey_IsNullWhenTheConsoleCannotSay() + { + // Null is the signal to fall through to the documented offline guess, not "it is wan". + IspHealthService.ConfiguredPrimaryWanKey(null).Should().BeNull(); + IspHealthService.ConfiguredPrimaryWanKey(new NetworkInfo { Purpose = "wan" }).Should().BeNull(); + } + + // ─── Target scoping on that site ─── + + [Fact] + public void PrimaryScope_OnAWan2PrimarySite_KeepsWan2RowsAndTheUnstampedOnes() + { + // Unstamped rows are primary-path measurements wherever the role sits; wan1's rows are + // the FAILOVER's here and must never grade the primary. + var targets = new List + { + Target("legacy", null), + Target("hop-wan2", "wan2"), + Target("hop-wan2-upper", "WAN2"), + Target("hop-wan", "wan"), + Target("hop-wan1", "wan1"), + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan2", includeUnassigned: true) + .Select(t => t.TargetId).Should().Equal("legacy", "hop-wan2", "hop-wan2-upper"); + } + + [Fact] + public void FailoverScope_OnAWan2PrimarySite_OwnsTheWanRowsAndNoUnstampedOnes() + { + var targets = new List + { + Target("legacy", null), + Target("hop-wan", "wan"), + Target("hop-wan1", "wan1"), // the legacy alias is the same WAN as "wan" + Target("hop-wan2", "wan2"), + }; + + IspHealthService.ScopeTargetsToWan(targets, "wan", includeUnassigned: false) + .Select(t => t.TargetId).Should().Equal("hop-wan", "hop-wan1"); + } + + [Fact] + public void PrimaryScope_OnAWan2PrimarySite_ReadsWan2sTagsNeverWan1s() + { + var contexts = new[] + { + new WanContext { Name = "fiber", WanInterface = "wan2" }, + new WanContext { Name = "cable-failover", WanInterface = "wan" }, + }; + + var scope = IspHealthService.BuildWanScope(contexts, "wan2", primaryScope: true); + + scope.IncludeUntagged.Should().BeTrue(); + scope.WanTags.Should().BeEquivalentTo("wan2", "fiber"); + } + + // ─── Upstream discovery rehydrate ─── + + [Fact] + public void PickRehydrateContext_TakesTheConfiguredPrimarysRowNotTheWanOne() + { + // The bug this pins: a WAN2-primary site rehydrating the primary panel from WAN1's row + // presents the failover's hops as the primary's upstream path. + var contexts = new List + { + new() { WanInterface = "wan", LastDiscoveryAt = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc) }, + new() { WanInterface = "wan2", LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }, + }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: null, configuredPrimaryKey: "wan2") + !.WanInterface.Should().Be("wan2"); + } + + [Fact] + public void PickRehydrateContext_StillLetsABoundTracerReadItsOwnWan() + { + var contexts = new List + { + new() { WanInterface = "wan" }, + new() { WanInterface = "wan2" }, + }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: "wan", configuredPrimaryKey: "wan2") + !.WanInterface.Should().Be("wan"); + } + + [Fact] + public void PickRehydrateContext_FallsBackToTheDocumentedGuessOnlyWhenTheConsoleIsSilent() + { + // Offline last resort: the conventional "wan" row, then recency. Wrong on exactly this + // site - which is why the configured key is asked for first and this is a documented guess. + var contexts = new List + { + new() { WanInterface = "wan2", LastDiscoveryAt = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc) }, + new() { WanInterface = "wan", LastDiscoveryAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }, + }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: null, configuredPrimaryKey: null) + !.WanInterface.Should().Be("wan"); + } + + [Fact] + public void PickRehydrateContext_TakesTheOnlyRowWhenTheConfiguredPrimaryHasNoneYet() + { + var contexts = new List { new() { WanInterface = "wan" } }; + + UpstreamTracerService.PickRehydrateContext(contexts, boundWanInterface: null, configuredPrimaryKey: "wan2") + !.WanInterface.Should().Be("wan"); + } + + // ─── The offline guess, stated as a guess ─── + + [Fact] + public void ResolvePrimaryWanKey_IsTheWanFirstGuessAndSaysSoOnAWan2PrimarySite() + { + // Pinned deliberately: with the console silent there is nothing better to ask, so the + // offline answer on a WAN2-primary site is "wan" - wrong, self-correcting on the next + // connected compute, and never reached while ConfiguredPrimaryWanKey can answer. + var contexts = new[] + { + new WanDiscoveryContext { WanInterface = "wan2" }, + new WanDiscoveryContext { WanInterface = "wan" }, + }; + + IspHealthService.ResolvePrimaryWanKey(contexts).Should().Be("wan"); + } + + // ─── WAN speed tests follow the role, not the name ─── + + [Theory] + // A recorded primary is what the primary report matches on. + [InlineData("WAN2", null, false)] // WAN1's test on a WAN2-primary site: the FAILOVER's + [InlineData("WAN2", "WAN2", true)] // the primary's own test + [InlineData("WAN2", "WAN", false)] + // No recorded primary: fall back to the conventional first group, as before. + [InlineData(null, "WAN", true)] + [InlineData(null, "WAN2", false)] + public void PrimarySpeedTestPredicate_MatchesTheWanHoldingTheRole( + string? recordedPrimaryGroup, string? testGroup, bool expected) + { + var primaryGroupLower = recordedPrimaryGroup?.ToLowerInvariant(); + + // The predicate the primary instance applies (unstamped rows are covered separately). + var matches = testGroup != null + && testGroup.ToLowerInvariant() == (primaryGroupLower ?? "wan"); + + matches.Should().Be(expected); + } + + [Fact] + public void UnstampedSpeedTests_StayWithThePrimaryWhicheverWanHoldsIt() + { + // They predate stamping and ran over the default route, which is the primary's. + string? testGroup = null; + var matchesPrimary = testGroup == null; + + matchesPrimary.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs new file mode 100644 index 0000000000..9d0464152e --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextRoutingTests.cs @@ -0,0 +1,416 @@ +using FluentAssertions; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// The routing decisions behind multi-WAN contexts: which agent is pushed which targets, what +/// source each target is bound to, and whose results are written. The three deployment shapes +/// these have to hold for are: +/// +/// A. Main site collecting for itself (no coverage flag), plus a context agent. +/// B. Main site covered by its primary agent, plus a context agent. +/// C. Managed site with a primary agent, plus a context agent. +/// +/// Every case also gets its no-context counterpart: a site with no WAN contexts must behave +/// exactly as it did before contexts existed. +/// +public class WanContextRoutingTests +{ + private const int PrimaryAgent = 1; + private const int ContextAgent = 2; + + // ---- Push composition ------------------------------------------------- + + [Fact] + public void Push_NoContexts_UnassignedTargetsStillGoToEveryAgent() + { + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, ContextAgent, agentIsSteeredToWan: false, unassignedOwnerId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Push_ContextAgent_GetsOnlyItsOwnContextTargets() + { + // Shapes A, B and C alike: everything this agent probes leaves by its WAN, so the site's + // ordinary targets would be measured on the wrong path and filed under the primary. + AgentProbeResultSink.ShouldPushTargetToAgent(true, ContextAgent, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeFalse(); + } + + [Fact] + public void Push_PrimaryAgent_KeepsUnassignedTargetsAndNeverAnotherContexts() + { + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(true, ContextAgent, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + } + + [Fact] + public void Push_ServerBoundContextTargets_GoToNoAgent() + { + // A source-IP context is probed by the server itself, whose prober binds the source IP + // the gateway policy-routes. An ordinary agent would probe the same target over its OWN + // primary route while the result gets tagged with the secondary WAN's key - corrupting + // that WAN's score now that the tag is read - so a context target with no assigned agent + // reaches NO agent at all, on any shape. + AgentProbeResultSink.ShouldPushTargetToAgent(true, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + AgentProbeResultSink.ShouldPushTargetToAgent(true, null, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeFalse(); + } + + [Fact] + public void Push_ContextWhoseRowIsGone_ReachesNoAgentRatherThanFanningOut() + { + // A stale WanContextId (row deleted out from under it) is conservative: pushed nowhere + // until the assignment is cleaned up, never broadcast as if unassigned. + AgentProbeResultSink.ShouldPushTargetToAgent(true, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + } + + // ---- Source binding on the wire --------------------------------------- + + [Fact] + public void SourceIp_NoContext_IsEmptySoTheAgentKeepsItsOwnDefault() + { + AgentProbeResultSink.ResolveSpecSourceIp(null, ContextAgent).Should().BeEmpty(); + } + + [Fact] + public void SourceIp_InterfaceContext_SendsTheInterfaceName() + { + var context = new WanContext { Id = 5, Name = "backup", AgentId = ContextAgent, InterfaceName = "eth8", WanInterface = "wan2" }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, ContextAgent).Should().Be("eth8"); + } + + [Fact] + public void SourceIp_InterfaceWins_OverAStaleSourceIp() + { + var context = new WanContext + { + Id = 5, + Name = "backup", + AgentId = ContextAgent, + InterfaceName = "ppp0", + ProbeSourceIp = "192.0.2.10", + WanInterface = "wan2" + }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, ContextAgent).Should().Be("ppp0"); + } + + [Fact] + public void SourceIp_AnotherAgentsContext_SendsNothing() + { + // The receiving agent is not behind that WAN, so binding it to that context's source would + // either fail or measure the wrong path. + var context = new WanContext { Id = 5, Name = "backup", AgentId = ContextAgent, InterfaceName = "eth8", WanInterface = "wan2" }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, PrimaryAgent).Should().BeEmpty(); + } + + [Fact] + public void SourceIp_ServerBoundContext_SendsNothingToAgents() + { + // The source IP belongs to the server's own host and is policy-routed there; an agent + // binding it would fail. + var context = new WanContext { Id = 5, Name = "backup", ProbeSourceIp = "192.0.2.10", WanInterface = "wan2" }; + + AgentProbeResultSink.ResolveSpecSourceIp(context, ContextAgent).Should().BeEmpty(); + } + + // ---- Result acceptance ------------------------------------------------ + + [Fact] + public void Results_ShapeC_ManagedSite_AllAccepted() + { + // A managed site's agent always covers it: nothing here is conditional on contexts. + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: null, agentId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: ContextAgent, agentId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Results_ShapeB_CoveredMainSite_AllAccepted() + { + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: null, agentId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: true, contextAgentId: ContextAgent, agentId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Results_ShapeA_NonCoveringMainSite_ContextResultsAccepted() + { + // The server cannot probe the secondary WAN, so this agent's results are the only + // measurement of it - coverage governs the primary path, not this. + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: ContextAgent, agentId: ContextAgent) + .Should().BeTrue(); + } + + [Fact] + public void Results_ShapeA_NonCoveringMainSite_NonContextResultsStillDiscarded() + { + // The sawtooth protection: the server is probing these targets too. + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: null, agentId: PrimaryAgent) + .Should().BeFalse(); + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: null, agentId: ContextAgent) + .Should().BeFalse(); + } + + [Fact] + public void Results_ShapeA_AnotherAgentsContext_StillDiscarded() + { + AgentProbeResultSink.ShouldRecordResult(agentCoversPrimary: false, contextAgentId: PrimaryAgent, agentId: ContextAgent) + .Should().BeFalse(); + } + + // ---- SNMP and speed-test recipients ----------------------------------- + + [Fact] + public void SiteCollectionConfig_NoContexts_EveryAgentStillGetsIt() + { + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: false).Should().BeTrue(); + } + + [Fact] + public void SiteCollectionConfig_ContextAgent_IsExcluded() + { + // Otherwise a context agent polls every device a second time on a managed or covered site. + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: true).Should().BeFalse(); + } + + // ---- Influx wan tag --------------------------------------------------- + + [Fact] + public void WanTag_PrefersTheStableWanKey() + { + var context = new WanContext { Id = 1, Name = "Backup circuit", WanInterface = "wan2" }; + + context.InfluxWanTag.Should().Be("wan2"); + } + + [Fact] + public void WanTag_LegacyContextWithoutAWan_FallsBackToItsName() + { + var context = new WanContext { Id = 1, Name = "backup-wan" }; + + context.InfluxWanTag.Should().Be("backup-wan"); + } + + private const int GatewayAgent = 77; + + // ---- A gateway agent can serve contexts AND collect for the site ------- + + [Fact] + public void GatewayAgent_ServingEveryExtraWan_StillCollectsForTheSite() + { + // Its contexts name an interface, so each probe binds to that WAN while the box itself + // still routes out the primary. It is the site's collector as well - on a site whose only + // agent is the one on the gateway, nothing else can be. + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: GatewayAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: false).Should().BeTrue(); + } + + [Fact] + public void GatewayAgent_StillTakesEveryContextItOwnsAndNoOtherAgents() + { + AgentProbeResultSink.ShouldPushTargetToAgent(true, GatewayAgent, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: GatewayAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent(true, ContextAgent, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: GatewayAgent) + .Should().BeFalse(); + } + + [Fact] + public void SteeredProbeBox_TakesItsOwnWanAndNothingElse() + { + // No interface to bind, so the gateway policy-routes the whole box: a primary target + // probed from here would leave by the secondary WAN and be recorded as the primary's. + AgentProbeResultSink.ShouldPushTargetToAgent(false, null, ContextAgent, agentIsSteeredToWan: true, unassignedOwnerId: ContextAgent) + .Should().BeFalse(); + AgentProbeResultSink.ShouldPushSiteCollectionConfig(agentIsSteeredToWan: true).Should().BeFalse(); + } + + [Theory] + [InlineData("eth8", false)] // gateway agent: binds per probe, routes normally + [InlineData(null, true)] // probe box: the whole box sits behind the WAN + [InlineData("", true)] + public void SteeredIsDecidedByWhetherTheContextNamesAnInterface(string? interfaceName, bool expectedSteered) + { + var contexts = new[] { new WanContext { Id = 1, AgentId = ContextAgent, InterfaceName = interfaceName } }; + + var steered = contexts.Any(c => c.AgentId == ContextAgent && string.IsNullOrEmpty(c.InterfaceName)); + + steered.Should().Be(expectedSteered); + } + + // ---- One prober per target ------------------------------------------- + + [Fact] + public void UnassignedTargets_GoToOneAgentOnly() + { + // Two collectors on a site: the primary-WAN targets belong to whichever one owns the + // pool, not to both. Probing them twice produces two series for one number and doubles + // the load on every target the site monitors. + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, PrimaryAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeFalse(); + } + + [Fact] + public void AGatewayAgentOwningTheWansStillTakesThemWhenAnotherAgentHoldsThePool() + { + // Losing the unassigned pool costs it nothing of its own: its contexts are still its. + AgentProbeResultSink.ShouldPushTargetToAgent( + true, GatewayAgent, GatewayAgent, agentIsSteeredToWan: false, unassignedOwnerId: PrimaryAgent) + .Should().BeTrue(); + } + + // ---- Fabric targets follow the collector, not a WAN -------------------- + + [Fact] + public void FabricTargets_GoToTheCollector_WhicheverWansExist() + { + // Nothing inside the LAN crosses a WAN, so a context cannot own it: the agent that polls + // the site's SNMP probes it, and only that one. + AgentProbeResultSink.IsFabricTarget(MonitoringTargetType.Fabric).Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, PrimaryAgent, agentIsSteeredToWan: false, + unassignedOwnerId: PrimaryAgent, targetIsFabric: true).Should().BeTrue(); + AgentProbeResultSink.ShouldPushTargetToAgent( + false, null, GatewayAgent, agentIsSteeredToWan: false, + unassignedOwnerId: PrimaryAgent, targetIsFabric: true).Should().BeFalse(); + } + + [Fact] + public void FabricTarget_InAContext_StillGoesToTheCollector() + { + // A fabric target that somehow carries a context is still a LAN measurement: the context + // says nothing about it, so ownership does not move. + AgentProbeResultSink.ShouldPushTargetToAgent( + true, ContextAgent, ContextAgent, agentIsSteeredToWan: true, + unassignedOwnerId: PrimaryAgent, targetIsFabric: true).Should().BeFalse(); + } + + [Theory] + [InlineData(MonitoringTargetType.AccessIsp)] + [InlineData(MonitoringTargetType.Transit)] + [InlineData(MonitoringTargetType.InternetService)] + public void WanTargets_AreNotFabric(MonitoringTargetType type) + { + AgentProbeResultSink.IsFabricTarget(type).Should().BeFalse(); + } + + // ---- Steering is about the WAN, not the interface field ---------------- + + [Fact] + public void PrimaryWansContext_DoesNotMakeAnAgentSteered() + { + // Reaching the primary needs no steering: on a failover-only site every unpinned box + // already leaves by it. An agent named on the primary's context is still the collector. + var context = new WanContext { AgentId = PrimaryAgent, WanInterface = "wan2" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: "wan2").Should().BeTrue(); + } + + [Fact] + public void SecondaryWansContext_MeansTheAgentIsSteered() + { + var context = new WanContext { AgentId = ContextAgent, WanInterface = "wan3" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: "wan2").Should().BeFalse(); + } + + [Fact] + public void UnknownPrimary_LeavesTheConservativeReading() + { + // No connected compute has recorded the role yet. Guessing the agent is on the primary + // would hand it the site's targets; the safe reading is that it is not. + var context = new WanContext { AgentId = ContextAgent, WanInterface = "wan" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: null).Should().BeFalse(); + } + + [Fact] + public void LegacyWan1Context_MatchesAPrimaryRecordedAsWan() + { + var context = new WanContext { AgentId = PrimaryAgent, WanInterface = "wan1" }; + + AgentProbeResultSink.IsPrimaryWanContext(context, primaryWanKey: "wan").Should().BeTrue(); + } + + // ---- Which agent collects for the site -------------------------------- + + [Fact] + public void TheCollectorIsTheLowestIdConnectedAgent() + { + AgentProbeResultSink.SelectCollectorAgentId( + new[] { GatewayAgent, PrimaryAgent }, Array.Empty(), + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(PrimaryAgent); + } + + [Fact] + public void ASteeredAgentIsNeverTheCollector() + { + // Even as the lowest id: everything it sends leaves by its own WAN. + var contexts = new[] { new WanContext { AgentId = PrimaryAgent, WanInterface = "wan2" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { PrimaryAgent, GatewayAgent }, contexts, + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(GatewayAgent); + } + + [Fact] + public void AGatewayAgentServingWansCanStillCollect() + { + // Its context names an interface, so it binds per probe and routes normally. + var contexts = new[] { new WanContext { AgentId = GatewayAgent, WanInterface = "wan2", InterfaceName = "eth8" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { GatewayAgent }, contexts, + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(GatewayAgent); + } + + [Fact] + public void AnAgentOnThePrimarysContextCanStillCollect() + { + var contexts = new[] { new WanContext { AgentId = PrimaryAgent, WanInterface = "wan2" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { PrimaryAgent }, contexts, + primaryWanKey: "wan2", fallbackAgentId: 0).Should().Be(PrimaryAgent); + } + + [Fact] + public void ALoneSteeredAgentStillCollects_RatherThanLeavingTheSiteDark() + { + var contexts = new[] { new WanContext { AgentId = ContextAgent, WanInterface = "wan2" } }; + + AgentProbeResultSink.SelectCollectorAgentId( + new[] { ContextAgent }, contexts, + primaryWanKey: "wan", fallbackAgentId: ContextAgent).Should().Be(ContextAgent); + } + + [Fact] + public void TheCollectorMovesOnWhenItsAgentDrops() + { + // Taken from the CONNECTED set, so the next push hands the work to whoever is left. + AgentProbeResultSink.SelectCollectorAgentId( + new[] { GatewayAgent }, Array.Empty(), + primaryWanKey: "wan", fallbackAgentId: 0).Should().Be(GatewayAgent); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs new file mode 100644 index 0000000000..423e47b4cb --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanContextTargetStampingTests.cs @@ -0,0 +1,259 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Services; +using NetworkOptimizer.Web.Services; +using NetworkOptimizer.Web.Services.Gates; +using NetworkOptimizer.Web.Services.Monitoring; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// A monitoring target carries two WAN keys that must never drift apart: WanContextId routes the +/// probe, WanInterface says which WAN the resulting data describes - and every per-WAN reader +/// scopes on the latter. The deploy-time backfill only fixed the rows that existed then, so the +/// three runtime paths that can move one key have to move the other: assigning a target to a +/// context, re-pointing a context at another WAN, and deleting a context. A target that kept a +/// dead or stale WAN stamp reads as flatlined in the primary's report and invisible in its own. +/// +public class WanContextTargetStampingTests : IDisposable +{ + private readonly string _dir; + private readonly SiteDbContextFactory _factory; + private readonly SiteContextService _siteContext; + private readonly AuditContext _audit = new(); + + public WanContextTargetStampingTests() + { + _dir = Path.Combine(Path.GetTempPath(), "no-wan-stamping-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_dir); + var paths = new SiteDatabasePaths(Path.Combine(_dir, "network_optimizer.db")); + _factory = new SiteDbContextFactory(paths); + _siteContext = new SiteContextService(new HttpContextAccessor(), paths); + + using var db = Db(); + db.Database.Migrate(); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* temp dir; a leftover is harmless */ } + GC.SuppressFinalize(this); + } + + private NetworkOptimizerDbContext Db() => _factory.CreateForSite(_siteContext.Slug, _siteContext.IsDefault); + + private MonitoringTargetService Targets() => new( + _factory, _siteContext, asnResolution: null!, executorFactory: null!, _audit, + NullLogger.Instance); + + private async Task SeedContextAsync(string name, string? wanInterface) + { + await using var db = Db(); + var context = new WanContext + { + Name = name, + WanInterface = wanInterface, + ProbeSourceIp = "198.51.100.7", + CreatedAt = DateTime.UtcNow, + }; + db.WanContexts.Add(context); + await db.SaveChangesAsync(); + return context.Id; + } + + private async Task SeedTargetAsync(string targetId, int? contextId = null, string? wanInterface = null) + { + await using var db = Db(); + var target = new MonitoringTarget + { + TargetId = targetId, + Name = targetId, + Address = "203.0.113.10", + TargetType = MonitoringTargetType.Custom, + ProbeMode = ProbeMode.Icmp, + WanContextId = contextId, + WanInterface = wanInterface, + CreatedAt = DateTime.UtcNow, + }; + db.MonitoringTargets.Add(target); + await db.SaveChangesAsync(); + return target.Id; + } + + private async Task ReadAsync(int id) + { + await using var db = Db(); + return (await db.MonitoringTargets.FindAsync(id))!; + } + + // ─── Path 1: assigning a target to a context ─── + + [Fact] + public async Task Assigning_a_target_to_a_context_stamps_the_contexts_wan() + { + var contextId = await SeedContextAsync("backup", "wan2"); + var targetId = await SeedTargetAsync("custom-hop"); + + (await Targets().SetWanContextAsync(targetId, contextId)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().Be(contextId); + row.WanInterface.Should().Be("wan2"); + } + + [Fact] + public async Task Reassigning_a_target_to_another_wans_context_moves_its_stamp_too() + { + var backup = await SeedContextAsync("backup", "wan2"); + var lte = await SeedContextAsync("lte", "wan3"); + var targetId = await SeedTargetAsync("custom-hop", backup, "wan2"); + + (await Targets().SetWanContextAsync(targetId, lte)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().Be(lte); + row.WanInterface.Should().Be("wan3"); + } + + [Fact] + public async Task Moving_a_target_back_to_the_primary_clears_both_keys() + { + // An unstamped row IS a primary-path measurement to every scoped reader, so the WAN + // stamp has to go with the routing - a row left saying "wan2" would keep grading the + // secondary's report with data nothing probes over the secondary any more. + var contextId = await SeedContextAsync("backup", "wan2"); + var targetId = await SeedTargetAsync("custom-hop", contextId, "wan2"); + + (await Targets().SetWanContextAsync(targetId, null)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().BeNull(); + row.WanInterface.Should().BeNull(); + } + + [Fact] + public async Task Assigning_to_a_context_that_names_no_wan_leaves_the_stamp_empty() + { + // A context created before the WAN column existed has nothing to copy down. + var contextId = await SeedContextAsync("legacy", null); + var targetId = await SeedTargetAsync("custom-hop"); + + await Targets().SetWanContextAsync(targetId, contextId); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().Be(contextId); + row.WanInterface.Should().BeNull(); + } + + [Fact] + public async Task A_single_wan_target_that_was_never_assigned_stays_untouched() + { + // Every row on a single-WAN install: no context to move to, nothing to stamp, and the + // no-change path must not write an audit event either. + var targetId = await SeedTargetAsync("custom-hop"); + + (await Targets().SetWanContextAsync(targetId, null)).Should().BeTrue(); + + var row = await ReadAsync(targetId); + row.WanContextId.Should().BeNull(); + row.WanInterface.Should().BeNull(); + _audit.Drain().Suppressed.Should().BeTrue(); + } + + // ─── Path 2: a context re-pointed at another WAN ─── + + [Fact] + public async Task Repointing_a_context_restamps_every_target_it_owns() + { + var contextId = await SeedContextAsync("backup", "wan2"); + await SeedTargetAsync("hop-a", contextId, "wan2"); + await SeedTargetAsync("hop-b", contextId, "wan2"); + + await using (var db = Db()) + { + (await WanContextTargetStamping.RestampContextTargetsAsync(db, contextId, "wan3")).Should().Be(2); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + read.MonitoringTargets.Select(t => t.WanInterface).ToList().Should().Equal("wan3", "wan3"); + } + + [Fact] + public async Task Repointing_a_context_leaves_another_contexts_targets_alone() + { + var backup = await SeedContextAsync("backup", "wan2"); + var lte = await SeedContextAsync("lte", "wan3"); + await SeedTargetAsync("hop-a", backup, "wan2"); + await SeedTargetAsync("hop-b", lte, "wan3"); + await SeedTargetAsync("hop-primary"); + + await using (var db = Db()) + { + await WanContextTargetStamping.RestampContextTargetsAsync(db, backup, "wan4"); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + (await read.MonitoringTargets.SingleAsync(t => t.TargetId == "hop-a")).WanInterface.Should().Be("wan4"); + (await read.MonitoringTargets.SingleAsync(t => t.TargetId == "hop-b")).WanInterface.Should().Be("wan3"); + (await read.MonitoringTargets.SingleAsync(t => t.TargetId == "hop-primary")).WanInterface.Should().BeNull(); + } + + // ─── Path 3: deleting a context ─── + + [Fact] + public async Task Deleting_a_context_releases_both_keys_on_its_targets() + { + var contextId = await SeedContextAsync("backup", "wan2"); + await SeedTargetAsync("hop-a", contextId, "wan2"); + + await using (var db = Db()) + { + (await WanContextTargetStamping.ReleaseContextTargetsAsync(db, contextId)).Should().Be(1); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + var row = await read.MonitoringTargets.SingleAsync(); + row.WanContextId.Should().BeNull(); + row.WanInterface.Should().BeNull(); + } + + [Fact] + public async Task Deleting_a_context_touches_nothing_on_a_site_that_has_no_targets_on_it() + { + await SeedTargetAsync("hop-primary", contextId: null, wanInterface: "wan"); + + await using (var db = Db()) + { + (await WanContextTargetStamping.ReleaseContextTargetsAsync(db, 404)).Should().Be(0); + await db.SaveChangesAsync(); + } + + await using var read = Db(); + (await read.MonitoringTargets.SingleAsync()).WanInterface.Should().Be("wan"); + } + + // ─── The rule itself ─── + + [Fact] + public void ApplyAssignment_carries_the_contexts_wan_and_clears_it_on_the_way_back() + { + var target = new MonitoringTarget { TargetId = "t", Name = "t", Address = "203.0.113.10" }; + + WanContextTargetStamping.ApplyAssignment(target, 7, "wan2"); + target.WanContextId.Should().Be(7); + target.WanInterface.Should().Be("wan2"); + + // The context's WAN is irrelevant on the way back to the primary: both keys clear. + WanContextTargetStamping.ApplyAssignment(target, null, "wan2"); + target.WanContextId.Should().BeNull(); + target.WanInterface.Should().BeNull(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs new file mode 100644 index 0000000000..17bbaba67d --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/Monitoring/WanDeepLinkTargetTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using Xunit; + +namespace NetworkOptimizer.Web.Tests.Monitoring; + +/// +/// Where a WAN-scoped report should send someone who has nothing to look at yet. Discovery is not +/// always the answer: a secondary WAN is traced THROUGH its context, so a WAN without one cannot +/// be discovered however many times you run it, and pointing there wastes the trip. +/// +public class WanDeepLinkTargetTests +{ + private static bool NeedsContextFirst(bool isPrimary, bool hasContext) => !isPrimary && !hasContext; + + [Theory] + [InlineData(true, false, false)] // the primary needs no context - discovery is the answer + [InlineData(true, true, false)] + [InlineData(false, true, false)] // secondary WITH a context - discovery is the answer + [InlineData(false, false, true)] // secondary with none - the context comes first + public void ASecondaryWanWithoutAContextIsSentToMakeOne(bool isPrimary, bool hasContext, bool expected) + { + NeedsContextFirst(isPrimary, hasContext).Should().Be(expected); + } + + private static string DiscoveryWanQuery(string? wanKey, int wanCount) => + string.IsNullOrEmpty(wanKey) || wanCount <= 1 ? "" : $"&wan={System.Uri.EscapeDataString(wanKey)}"; + + [Fact] + public void ADiscoveryLinkCarriesTheWanTheReportIsAbout() + { + DiscoveryWanQuery("wan2", 2).Should().Be("&wan=wan2"); + } + + [Fact] + public void ASingleWanSiteAddsNothing() + { + // One WAN means one discovery; a parameter would only be noise in the address bar. + DiscoveryWanQuery("wan", 1).Should().BeEmpty(); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs new file mode 100644 index 0000000000..08f784f490 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/UiHintServiceTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +/// +/// A hint that exists to reveal a gesture is for the first encounter, not the hundredth. These pin +/// the arithmetic of "shown enough"; the storage round-trip needs a full Identity graph and is +/// exercised on a test site instead. +/// +public class UiHintServiceTests +{ + private static bool StillOwed(int timesShown) => timesShown < UiHintService.ShowLimit; + + [Theory] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(3, false)] + public void AHintRetiresOnceItHasBeenShownItsAllowance(int timesShown, bool expected) + { + StillOwed(timesShown).Should().Be(expected); + } + + [Fact] + public void TheAllowanceIsTwoOccasions() + { + // Twice: once to notice it exists, once to remember what it said. A single showing is + // easily missed and a third is nagging. + UiHintService.ShowLimit.Should().Be(2); + } + + [Fact] + public void TheCountStopsClimbingAtTheLimit() + { + // Left to grow, a "shown 400 times" would make any future reset read as absurd - and the + // number past the limit answers no question anyone has. + var shown = 0; + for (var visit = 0; visit < 10; visit++) + if (shown < UiHintService.ShowLimit) shown++; + + shown.Should().Be(UiHintService.ShowLimit); + } + + [Fact] + public void HintKeysAreStableStrings() + { + // Renaming one starts its count over, which is harmless - but it should be a decision, + // not a typo, so the keys live in one place. + UiHintKeys.WanFilterCompare.Should().Be("wan-filter-compare"); + } +} diff --git a/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs index f4fe65def7..2a739ed079 100644 --- a/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs +++ b/tests/NetworkOptimizer.Web.Tests/UpstreamTracerServiceTests.cs @@ -1139,7 +1139,7 @@ private static Dictionary Map(params (string Ip, int Asn)[] e) public void Unannounced_public_hops_before_the_access_border_are_attributed() { // The #984 shape: RFC1918, then unannounced public space, then the announced - // access-ASN border. The public hops are kept; the RFC1918 hop is not. + // access-ASN border. Everything below the border is kept, private included. var traces = new IReadOnlyList[] { new[] { "10.0.0.2", "203.0.113.10", "203.0.113.11", "192.0.2.60" } @@ -1147,7 +1147,7 @@ public void Unannounced_public_hops_before_the_access_border_are_attributed() var map = Map(("192.0.2.60", Bell)); UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell) - .Should().Equal("203.0.113.10", "203.0.113.11"); + .Should().Equal("10.0.0.2", "203.0.113.10", "203.0.113.11"); } [Fact] @@ -1161,13 +1161,102 @@ public void Cgnat_prefix_hops_are_attributed() } [Fact] - public void Rfc1918_hops_are_never_attributed() + public void Rfc1918_hops_are_attributed() { + // An ISP numbering its access network out of private space leaves no other trace of its + // first mile, so every private hop below the ISP's border is a candidate. var traces = new IReadOnlyList[] { new[] { "10.0.0.2", "172.16.0.2", "192.168.1.2", "192.0.2.60" } }; var map = Map(("192.0.2.60", Bell)); UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell) - .Should().BeEmpty(); + .Should().Equal("10.0.0.2", "172.16.0.2", "192.168.1.2"); + } + + [Fact] + public void Our_own_gateway_is_never_attributed() + { + var traces = new IReadOnlyList[] { new[] { "192.168.1.1", "192.168.100.1", "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var gateways = new HashSet(new[] { "192.168.1.1" }, StringComparer.OrdinalIgnoreCase); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, gateways) + .Should().Equal("192.168.100.1", "10.99.2.5"); + } + + [Fact] + public void A_private_hop_answering_from_our_own_side_is_not_attributed() + { + // 192.168.100.1 at 0.3 ms is a bridged CPE on our side of the WAN; the CMTS at 11 ms is a + // WAN crossing away. Distance separates them where position cannot. + var traces = new IReadOnlyList[] { new[] { "192.168.100.1", "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var rtt = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["192.168.100.1"] = 0.3, + ["10.99.2.5"] = 11.087, + }; + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, null, rtt) + .Should().Equal("10.99.2.5"); + } + + [Fact] + public void A_private_hop_with_no_timing_is_still_attributed() + { + var traces = new IReadOnlyList[] { new[] { "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + + UpstreamTracerService.CollectUnannouncedAccessAddresses( + traces, map, Bell, null, new Dictionary()) + .Should().Equal("10.99.2.5"); + } + + [Fact] + public void A_close_public_hop_is_still_attributed() + { + // The distance test is for private space only - carrier space is carrier space. + var traces = new IReadOnlyList[] { new[] { "198.51.100.9", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var rtt = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["198.51.100.9"] = 0.4 }; + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, null, rtt) + .Should().Equal("198.51.100.9"); + } + + [Fact] + public void The_first_responding_hop_is_attributed_when_the_gateway_is_the_vantage() + { + // Probing from the gateway itself: there is no gateway hop to skip, and the first responder + // is already ISP-side. + var traces = new IReadOnlyList[] { new[] { "10.99.2.5", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell) + .Should().Equal("10.99.2.5"); + } + + [Fact] + public void A_cgnat_first_hop_past_our_gateway_is_still_attributed() + { + // The hold-back is for RFC1918 only. On a CGNAT provider the first hop past the gateway is + // the carrier's own first-mile device, and it is usually the only one that answers at all. + var traces = new IReadOnlyList[] { new[] { "192.168.1.1", "100.64.0.1", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var gateways = new HashSet(new[] { "192.168.1.1" }, StringComparer.OrdinalIgnoreCase); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, gateways) + .Should().Equal("100.64.0.1"); + } + + [Fact] + public void A_public_first_hop_past_our_gateway_is_still_attributed() + { + var traces = new IReadOnlyList[] { new[] { "192.168.1.1", "198.51.100.9", "192.0.2.60" } }; + var map = Map(("192.0.2.60", Bell)); + var gateways = new HashSet(new[] { "192.168.1.1" }, StringComparer.OrdinalIgnoreCase); + + UpstreamTracerService.CollectUnannouncedAccessAddresses(traces, map, Bell, gateways) + .Should().Equal("198.51.100.9"); } [Fact] diff --git a/tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs b/tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs new file mode 100644 index 0000000000..ba540cea84 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/WanContextsCardTests.cs @@ -0,0 +1,236 @@ +using FluentAssertions; +using NetworkOptimizer.UniFi; +using NetworkOptimizer.Web.Components.Shared; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +/// +/// This project has no Blazor component-test harness (no bunit), so the WAN context form's rules +/// are covered here through the pure validation function the component calls. The wiring around it +/// - which fields are shown, the interface auto-fill from the selected WAN - still needs manual +/// verification. ValidateContext is exposed internal (see NetworkOptimizer.Web.csproj +/// InternalsVisibleTo). +/// +public class WanContextsCardTests +{ + private static readonly string[] NoOtherContexts = Array.Empty(); + + [Fact] + public void Validate_SourceIpContext_IsAccepted() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_AgentWithInterfaceBind_IsAccepted() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "eth8", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_MissingWan_IsRejected() + { + // A context with no WAN cannot say which WAN its measurements describe. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("WAN"); + } + + [Fact] + public void Validate_SourceIpAndAgentTogether_IsRejected() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("not both"); + } + + [Fact] + public void Validate_SourceIpAndAgentTogether_IsAllowed_WhenTheAgentBindsTheAddress() + { + // A multi-homed agent, one interface per WAN: the address is not a competing answer to + // "where does the probe leave from", it IS the agent's binding. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts, + agentCanBindSource: true); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_AgentBindingAnAddress_SatisfiesASiteTheServerCannotProbe() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "192.0.2.10", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts, + serverProbesThisSite: false, agentCanBindSource: true); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_InterfaceWithoutAgent_IsRejected() + { + // Nothing on this server can bind a name only the gateway resolves. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "", + agentId: null, interfaceName: "eth8", otherNames: NoOtherContexts); + + error.Should().Contain("agent"); + } + + [Fact] + public void Validate_MalformedSourceIp_IsRejected() + { + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "not-an-ip", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("valid IP address"); + } + + [Fact] + public void Validate_DuplicateName_IsRejected_CaseInsensitively() + { + var error = WanContextsCard.ValidateContext( + name: "Backup", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "", otherNames: new[] { "backup" }); + + error.Should().Contain("already exists"); + } + + [Fact] + public void Validate_EditingAContextKeepingItsOwnName_IsAccepted() + { + // The caller passes the OTHER contexts' names, so a rename to itself is not a clash. + var error = WanContextsCard.ValidateContext( + name: "backup", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "eth8", otherNames: new[] { "starlink" }); + + error.Should().BeNull(); + } + + [Fact] + public void Validate_EmptyName_IsRejected() + { + var error = WanContextsCard.ValidateContext( + name: "", wanInterface: "wan2", sourceIp: "", + agentId: 2, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Contain("name"); + } + + [Theory] + [InlineData("wan2")] + [InlineData("WAN2")] + [InlineData("wan")] + [InlineData("wan1")] + public void Validate_NameThatIsAnotherWansKey_IsRejected(string name) + { + // The context's name is written as an Influx wan tag alongside the stable wan key, so a + // context on wan3 named "wan2" would file its points under WAN2's report and swallow that + // WAN's measurements. + var error = WanContextsCard.ValidateContext( + name: name, wanInterface: "wan3", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().Be("A name that looks like a WAN key must match the vantage's own WAN."); + } + + [Theory] + [InlineData("wan2", "wan2")] + [InlineData("WAN2", "wan2")] + [InlineData("wan", "wan")] + [InlineData("wan1", "wan")] // the wan1 alias IS the primary's key, not a rival WAN + [InlineData("wan", "wan1")] + public void Validate_NameThatIsItsOwnWansKey_IsAccepted(string name, string wanInterface) + { + var error = WanContextsCard.ValidateContext( + name: name, wanInterface: wanInterface, sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Theory] + [InlineData("starlink")] + [InlineData("wan backup")] + [InlineData("wan2-backup")] + [InlineData("lte-wan2")] + public void Validate_NameThatMerelyMentionsAWan_IsAccepted(string name) + { + // Only a name that IS a bare wan key can be mistaken for one in the tag chain. + var error = WanContextsCard.ValidateContext( + name: name, wanInterface: "wan3", sourceIp: "192.0.2.10", + agentId: null, interfaceName: "", otherNames: NoOtherContexts); + + error.Should().BeNull(); + } + + [Theory] + [InlineData("wan", 1)] + [InlineData("wan1", 1)] + [InlineData("wan2", 2)] + [InlineData("WAN3", 3)] + [InlineData("", 0)] + [InlineData("eth8", 0)] + public void WanIndexFromKey_FollowsUniFisConvention(string key, int expected) + { + GatewayWanHelper.WanIndexFromKey(key).Should().Be(expected); + } + + [Fact] + public void WanLabel_EchoesUniFisFriendlyNamePlusGroupConvention() + { + // The WAN picker has to read like the one in UniFi Network's policy table so the user can + // match them up: "Internet 1 WAN1" for a default name, "My ISP WAN2" for a renamed one. + GatewayWanHelper.FormatWanLabel("Internet 1", GatewayWanHelper.WanIndexFromKey("wan"), null, null) + .Should().Be("Internet 1 WAN1"); + GatewayWanHelper.FormatWanLabel("My ISP", GatewayWanHelper.WanIndexFromKey("wan2"), null, null) + .Should().Be("My ISP WAN2"); + GatewayWanHelper.FormatWanLabel(null, GatewayWanHelper.WanIndexFromKey("wan2"), null, null) + .Should().Be("WAN2"); + } + + [Fact] + public void ASourceIpContextIsRejectedOnASiteTheServerDoesNotProbe() + { + // Source-IP contexts are probed by the server binding that address, and the server only + // probes the main site. On any other site this would look configured and collect nothing. + WanContextsCard.ValidateContext( + "backup", "wan2", "198.51.100.7", agentId: null, interfaceName: null, + otherNames: Array.Empty(), serverProbesThisSite: false) + .Should().Be("This site is probed by its agent, so assign one to this WAN."); + } + + [Fact] + public void ASourceIpContextIsFineOnTheMainSite() + { + WanContextsCard.ValidateContext( + "backup", "wan2", "198.51.100.7", agentId: null, interfaceName: null, + otherNames: Array.Empty(), serverProbesThisSite: true) + .Should().BeNull(); + } + + [Fact] + public void AnAgentAssignedContextIsFineOnAnySite() + { + WanContextsCard.ValidateContext( + "backup", "wan2", sourceIp: null, agentId: 4, interfaceName: null, + otherNames: Array.Empty(), serverProbesThisSite: false) + .Should().BeNull(); + } +} From 1e85e648e4f3da43f1a202708f53b35a443d9ed0 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:24:02 -0500 Subject: [PATCH 28/63] Config Optimizer: catch Smart Queues that UniFi Network never actually turned on (#1100) * Config Optimizer: catch Smart Queues that UniFi Network never actually turned on UniFi Network regularly accepts the Smart Queues toggle on a WAN without provisioning the queues. The setting reads as on, no shaper is ever created, and the connection runs unshaped with nothing on screen to say so - the user just sees a lopsided upload. Until now we only told anyone about it at the moment an Adaptive SQM deploy failed on the missing IFB device, so nobody who wasn't deploying Adaptive SQM ever heard it. Config Optimizer now finds it on its own. For every WAN with Smart Queues enabled it reads the gateway's traffic control over SSH and raises a Performance Suggestion when the htb root class isn't there, prescribing the QoS-rule workaround that un-wedges it. Interface resolution is the controller's, unchanged: the WAN's uplink_ifname - eth6 plain, eth6.100 VLAN-tagged, ppp0 for PPPoE - plus its ifb companion, exactly the devices Adaptive SQM and Monitoring already use. Egress (upload) rides the WAN interface, ingress (download) rides the ifb, and a WAN shaped in only one direction is reported naming the direction that isn't. Everything goes out in one SSH round trip, and the check stays silent whenever it can't see the answer: no WAN with Smart Queues on (no SSH at all then), gateway SSH off or without credentials, an agent tunnel that isn't up, a failed command, a truncated readout, or a WAN interface the gateway doesn't have. A direction UniFi was told to shape at 0 isn't expected to have a shaper either. Adds SmartqUpRateMbps to WanInterfaceInfo, which is what tells those two apart. Closes #1083 * Cover the shaper probe's preconditions Everything the probe refuses to do is the part that matters: a site with no WAN on Smart Queues, a gateway with SSH off or no credentials, an agent tunnel that isn't up, a failed command, and an interface name that has no business on a command line all have to cost nothing and produce no state. A finding raised from a failed read would accuse a healthy install. * Only read the gateway's shapers when a WAN actually has Smart Queues on Asking which interfaces to read costs a second device-list fetch from the controller, and an install with Smart Queues off everywhere can never produce the finding that would pay for it. The network configs this run already fetches say whether any WAN has it enabled, so waiting on that one small call buys the skip - the rest stays parallel. * Tour step for the Smart Queues check, and one QoS menu path everywhere The step only reaches installs it can mean something to: a new "smart-queues" predicate, which is UniFi's own toggle on some WAN and deliberately not the existing "sqm-enabled" (that one is our Adaptive SQM, and the whole point of this check is the WAN that has UniFi's Smart Queues on with nothing of ours deployed). Nothing stores UniFi's toggle locally so the predicate asks the console, which is affordable only because predicates resolve for a tour that is actually due, never on an ordinary Dashboard visit. The anchor sits on the Performance Suggestions checkbox rather than the results card: the card does not exist until you have run Analyze, so a first-visit tour would spotlight nothing. A step whose "requires" names a predicate that does not exist is silently dropped from every install forever, with nothing in the logs to say a tour lost a step, so the shipped tour JSON is now checked against the predicates that actually exist. Also settles the QoS rule menu path, which the app gave three ways: the two Adaptive SQM and Cellular Data Savings variants now read the same as the new finding. --- .../Analyzers/PerformanceAnalyzer.cs | 97 ++++++++++- .../DiagnosticsEngine.cs | 10 +- .../Models/WanShaperState.cs | 59 +++++++ .../Components/Pages/Optimize.razor | 2 +- src/NetworkOptimizer.Web/Program.cs | 2 + .../Services/DiagnosticsService.cs | 45 ++++- .../Services/SqmDeploymentService.cs | 2 +- .../Services/SqmService.cs | 20 ++- .../Services/Ssh/GatewayShaperProbe.cs | 153 +++++++++++++++++ .../Services/Ssh/GatewayShaperProbeService.cs | 117 +++++++++++++ .../Services/Tours/TourPredicateResolver.cs | 40 +++++ .../wwwroot/data/tours/2.6.0.json | 20 +++ .../Analyzers/PerformanceAnalyzerTests.cs | 128 ++++++++++++++ .../Ssh/GatewayShaperProbeServiceTests.cs | 159 ++++++++++++++++++ .../Ssh/GatewayShaperProbeTests.cs | 157 +++++++++++++++++ .../Tours/TourDefinitionFileTests.cs | 58 +++++++ 16 files changed, 1057 insertions(+), 12 deletions(-) create mode 100644 src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs create mode 100644 src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbe.cs create mode 100644 src/NetworkOptimizer.Web/Services/Ssh/GatewayShaperProbeService.cs create mode 100644 src/NetworkOptimizer.Web/wwwroot/data/tours/2.6.0.json create mode 100644 tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeServiceTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Ssh/GatewayShaperProbeTests.cs create mode 100644 tests/NetworkOptimizer.Web.Tests/Tours/TourDefinitionFileTests.cs diff --git a/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs b/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs index 4f148df636..1e208f53cf 100644 --- a/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs +++ b/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs @@ -44,7 +44,8 @@ public List Analyze( JsonDocument? wanEnrichedData = null, bool runPerformanceChecks = true, bool runCellularChecks = true, - List? portProfiles = null) + List? portProfiles = null, + List? wanShaperStates = null) { var issues = new List(); @@ -54,6 +55,7 @@ public List Analyze( issues.AddRange(CheckJumboFrames(devices, settingsData)); issues.AddRange(CheckFlowControl(devices, networks, clients, settingsData, portProfiles)); issues.AddRange(CheckSqmFirmwareRegression(devices, networks)); + issues.AddRange(CheckSqmNotShaping(devices, wanShaperStates)); } if (runCellularChecks) @@ -490,6 +492,93 @@ internal List CheckSqmFirmwareRegression( return issues; } + /// + /// Check whether the WANs that have Smart Queues enabled are actually being shaped. + /// + /// UniFi Network regularly accepts the Smart Queues toggle without provisioning the queues: + /// the setting reads as on, no shaper is ever created, and the connection runs unshaped with + /// nothing on screen to say so. The gateway's own traffic control is the only place the truth + /// shows, so the states come from an SSH read (GatewayShaperProbeService) and are empty + /// whenever the gateway cannot be reached - a site we cannot see raises nothing. + /// + /// Egress rides the WAN's data-path interface (upload), ingress rides its "ifb" companion + /// (download), and a direction UniFi was explicitly told to shape at 0 is not expected to + /// have a shaper at all. + /// + [VendorSpecific("UniFi", "UniFi Network's Smart Queues provisioning and its ifb ingress device naming")] + internal List CheckSqmNotShaping( + List devices, + List? wanShaperStates) + { + var issues = new List(); + + if (wanShaperStates == null || wanShaperStates.Count == 0) + return issues; + + var gatewayName = devices.FirstOrDefault(d => d.DeviceType == DeviceType.Gateway)?.Name; + + foreach (var state in wanShaperStates) + { + // The WAN's own interface missing means we asked about a device this box does not + // have, so the readout says nothing about UniFi's provisioning. + if (!state.Egress.DeviceFound) + { + _logger?.LogDebug( + "Skipping Smart Queues shaper check for {Wan}: {Interface} not found on the gateway", + state.WanName, state.Interface); + continue; + } + + var uploadExpected = state.UpRateMbps != 0; + var downloadExpected = state.DownRateMbps != 0; + + var uploadShaped = state.Egress.HasRootHtb; + var downloadShaped = state.Ingress.DeviceFound && state.Ingress.HasRootHtb; + + var uploadMissing = uploadExpected && !uploadShaped; + var downloadMissing = downloadExpected && !downloadShaped; + + if (!uploadMissing && !downloadMissing) + continue; + + var preamble = $"Smart Queues is enabled for {state.WanName} in UniFi Network, but the gateway "; + string description; + + if (uploadMissing && downloadMissing) + { + description = preamble + + $"has no shaper on {state.Interface} or {state.IfbInterface}. UniFi Network took the setting " + + "without provisioning the queues, so this connection is running unshaped."; + } + else if (uploadMissing) + { + description = downloadShaped + ? preamble + $"is only shaping download. {state.Interface} has no shaper, so upload traffic is running unshaped." + : preamble + $"has no shaper on {state.Interface}, so upload traffic is running unshaped."; + } + else + { + description = uploadShaped + ? preamble + $"is only shaping upload. {state.IfbInterface} has no shaper, so download traffic is running unshaped." + : preamble + $"has no shaper on {state.IfbInterface}, so download traffic is running unshaped."; + } + + issues.Add(new PerformanceIssue + { + Title = $"Smart Queues Not Shaping on {state.WanName}", + Description = description, + Recommendation = "Add any QoS rule in UniFi Network under Settings > Policy Engine > Policy Table > QoS Rules. " + + "It does not matter what the rule targets - creating one makes UniFi Network provision the queues. " + + "Give it about 45 seconds, then run Analyze again.", + Severity = PerformanceSeverity.Recommendation, + Category = PerformanceCategory.Performance, + DeviceName = gatewayName + }); + } + + return issues; + } + /// /// Check if cellular WAN is present and QoS rules cover bandwidth-heavy app categories. /// @@ -558,7 +647,7 @@ internal List CheckCellularQos( { Title = "Streaming Video Not Rate-Limited", Description = streamingGap, - Recommendation = "Create a QoS Rule under Policy Engine > Policy Table > QoS Rules to limit " + + Recommendation = "Create a QoS Rule under Settings > Policy Engine > Policy Table > QoS Rules to limit " + "streaming video apps when on cellular. " + "
How-To Guide", Severity = severity, @@ -575,7 +664,7 @@ internal List CheckCellularQos( { Title = "Cloud Sync Not Rate-Limited", Description = cloudGap, - Recommendation = "Create a QoS Rule under Policy Engine > Policy Table > QoS Rules to limit cloud storage sync speed when on cellular. " + + Recommendation = "Create a QoS Rule under Settings > Policy Engine > Policy Table > QoS Rules to limit cloud storage sync speed when on cellular. " + "This prevents large uploads/downloads from burning through your data plan. " + "
How-To Guide", Severity = severity, @@ -592,7 +681,7 @@ internal List CheckCellularQos( { Title = "Game/App Downloads Not Rate-Limited", Description = downloadGap, - Recommendation = "Create a QoS Rule under Policy Engine > Policy Table > QoS Rules to limit or block game/app downloads when on cellular. " + + Recommendation = "Create a QoS Rule under Settings > Policy Engine > Policy Table > QoS Rules to limit or block game/app downloads when on cellular. " + "Game updates alone can exceed monthly data caps in a single download. " + "
How-To Guide", Severity = severity, diff --git a/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs b/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs index cfb5f71436..39c48dbd45 100644 --- a/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs +++ b/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs @@ -84,6 +84,10 @@ public DiagnosticsEngine( /// Optional historical clients for offline device detection /// Raw settings JSON for global switch settings /// Raw QoS rules JSON for cellular bandwidth checks + /// + /// Gateway traffic control state for WANs with Smart Queues enabled, read over SSH. Null or + /// empty whenever the gateway could not be read, which simply skips that check. + /// /// Complete diagnostics result public DiagnosticsResult RunDiagnostics( IEnumerable clients, @@ -94,7 +98,8 @@ public DiagnosticsResult RunDiagnostics( IEnumerable? clientHistory = null, JsonDocument? settingsData = null, JsonDocument? qosRulesData = null, - JsonDocument? wanEnrichedData = null) + JsonDocument? wanEnrichedData = null, + List? wanShaperStates = null) { options ??= new DiagnosticsOptions(); var stopwatch = Stopwatch.StartNew(); @@ -190,7 +195,8 @@ public DiagnosticsResult RunDiagnostics( deviceList, networkList, clientList, settingsData, qosRulesData, wanEnrichedData, runPerformanceChecks: options.RunPerformanceAnalyzer, runCellularChecks: options.RunCellularDataSavings, - portProfiles: profileList); + portProfiles: profileList, + wanShaperStates: wanShaperStates); result.CellularWanDetected = _performanceAnalyzer.CellularWanDetected; _logger?.LogDebug("Performance Analyzer found {Count} issues", result.PerformanceIssues.Count); } diff --git a/src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs b/src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs new file mode 100644 index 0000000000..d326b26f75 --- /dev/null +++ b/src/NetworkOptimizer.Diagnostics/Models/WanShaperState.cs @@ -0,0 +1,59 @@ +namespace NetworkOptimizer.Diagnostics.Models; + +/// +/// What the gateway's traffic control actually looks like on one WAN that has UniFi Smart Queues +/// turned on. Read over SSH and handed to the analyzer as plain data, so the check itself stays +/// free of any SSH or controller dependency. +/// +/// Both directions are described because UniFi shapes them on different devices: egress rides the +/// WAN's own data-path interface, ingress rides the mirred "ifb" companion. A WAN can end up with +/// one and not the other. +/// +public class WanShaperState +{ + /// The WAN's display name in UniFi Network, used in the finding. + public string WanName { get; init; } = string.Empty; + + /// + /// The data-path interface: "eth6" plain, "eth6.100" VLAN-tagged, "ppp0" for PPPoE. This is + /// the egress (upload) shaper's device. + /// + public string Interface { get; init; } = string.Empty; + + /// + /// The ingress (download) shaper's device - "ifb" plus . UniFi creates + /// it when it provisions Smart Queues, so its absence is itself the symptom. + /// + public string IfbInterface { get; init; } = string.Empty; + + /// Configured Smart Queue download rate in Mbps, null or 0 when UniFi has none. + public int? DownRateMbps { get; init; } + + /// Configured Smart Queue upload rate in Mbps, null or 0 when UniFi has none. + public int? UpRateMbps { get; init; } + + /// What tc reported for . + public TcDeviceState Egress { get; init; } = new(); + + /// What tc reported for . + public TcDeviceState Ingress { get; init; } = new(); +} + +/// +/// One interface's traffic control state, as read from "tc class show dev <name>". +/// +public class TcDeviceState +{ + /// + /// False when tc could not find the device at all. On the ifb companion that means UniFi never + /// created it; on the WAN's own interface it means we resolved a name this box does not have, + /// which is our problem rather than a finding. + /// + public bool DeviceFound { get; init; } + + /// + /// True when tc reported an htb root class - the shaper actually running. A device with only + /// the kernel's default "mq" classes is not being shaped. + /// + public bool HasRootHtb { get; init; } +} diff --git a/src/NetworkOptimizer.Web/Components/Pages/Optimize.razor b/src/NetworkOptimizer.Web/Components/Pages/Optimize.razor index 15c47aa899..577704ccc0 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Optimize.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Optimize.razor @@ -72,7 +72,7 @@ Ethernet Port Profile Suggestions -
public static class MonitoringStatFormat { - /// Round-trip time as "1.00 ms", or "-" when nothing has been measured. - public static string Rtt(double? ms) => ms.HasValue ? $"{ms.Value:0.00} ms" : "-"; + /// + /// Round-trip time as "1.00 ms", dropping to one decimal at 100 ms and above ("120.5 ms"), or + /// "-" when nothing has been measured. The step keeps the digit count steady rather than + /// breaking it: "99.99" and "100.0" are the same width, so the tile does not jump as a figure + /// crosses a hundred, and the second decimal stops being worth its space once the number is + /// that large. + /// + public static string Rtt(double? ms) => + ms.HasValue ? (ms.Value >= 100 ? $"{ms.Value:0.0} ms" : $"{ms.Value:0.00} ms") : "-"; /// Loss as "0.0%". Zero is shown to the same precision - it is a reading, not an absence. public static string Loss(double percent) => $"{percent:0.0}%"; From 28c455d854bb8b90d37810b0987530fbbaaa171e Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Wed, 5 Aug 2026 11:42:49 -0500 Subject: [PATCH 43/63] ISP Health: stop hand-escaping the ampersand in the Upstream Discovery link Making that href dynamic in the previous commit changed how Blazor writes it. Static markup is emitted verbatim, so the hand-written & was decoded exactly once by the browser and the link worked; as an attribute VALUE it is HTML-encoded on the way out, so the ampersand was encoded a second time and the DOM held the literal text "&discover=1". The site-context script then parsed that to append its site parameter, read a parameter named "amp;discover", and percent-encoded the semicolon on the way back out - which is the &%3Bdiscover=1 seen in the bar. A plain & in the source is correct here: encoding a dynamic attribute is Blazor's job, not the markup's. The other hand-escaped ampersands in the codebase are unaffected because they are still static - the four discovery tooltips on Monitoring, and the map popup HTML built in FloorPlanEditor and SpeedTestMap, which is parsed as HTML once when it is injected. --- .../Components/Shared/Monitoring/IspHealthPanel.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NetworkOptimizer.Web/Components/Shared/Monitoring/IspHealthPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/IspHealthPanel.razor index 8500175417..db2e8237d7 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/Monitoring/IspHealthPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/Monitoring/IspHealthPanel.razor @@ -166,7 +166,7 @@ else if (_report == null)