From 426a5baab0025fdcdacae21982bb2780635fb948 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Sun, 3 May 2026 18:47:19 -0500 Subject: [PATCH 1/5] Flag per-port Flow Control overrides in Config Optimizer (#574) * Flag port profiles and ports with Flow Control disabled when global FC is on When global Flow Control is enabled, the Config Optimizer now checks for Ethernet Port Profiles that explicitly disable FC and flags ports assigned to those profiles - surfacing inconsistencies between global and per-port settings. * Check per-port flow_control_enabled field and prefer profile over direct value Port profile FC setting takes precedence when a profile is assigned; falls back to the port_table's flow_control_enabled field for ports without a profile. * Include uplinks in FC check and use FlexibleBoolConverter on port profile Uplink ports with FC off are now flagged - they're arguably the most important ports for FC consistency. Port profile uses FlexibleBoolConverter for safe deserialization. * Fix port profile FC field name to flow_control_enabled * Skip disabled ports/profiles only, not down ports Down ports still need correct FC config for when a device connects. Only skip ports with forward=disabled or profiles that disable the port. * Fix FC port check: profile takes precedence, fallback to port field Port profiles don't currently save flow_control_enabled (UniFi bug), so the per-port check falls through to the port's own flow_control_enabled. When UniFi fixes this, profile value will take precedence. Also: check all non-disabled ports (not just up ones), use correct field name (flow_control_enabled not flowctrl_enabled), and use bool? to distinguish absent from explicitly false. * Soften FC recommendation to 'if this isn't intentional' * Remove dead fcOffProfileIds set and fix misleading log message --- .../Analyzers/PerformanceAnalyzer.cs | 125 +++++++++- .../DiagnosticsEngine.cs | 3 +- .../Models/UniFiDeviceResponse.cs | 3 + .../Models/UniFiPortProfile.cs | 3 + .../Analyzers/PerformanceAnalyzerTests.cs | 226 ++++++++++++++++++ 5 files changed, 356 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs b/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs index 10416f746..90cd267d4 100644 --- a/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs +++ b/src/NetworkOptimizer.Diagnostics/Analyzers/PerformanceAnalyzer.cs @@ -43,7 +43,8 @@ public List Analyze( JsonDocument? qosRulesData, JsonDocument? wanEnrichedData = null, bool runPerformanceChecks = true, - bool runCellularChecks = true) + bool runCellularChecks = true, + List? portProfiles = null) { var issues = new List(); @@ -51,7 +52,7 @@ public List Analyze( { issues.AddRange(CheckHardwareAcceleration(devices, settingsData)); issues.AddRange(CheckJumboFrames(devices, settingsData)); - issues.AddRange(CheckFlowControl(devices, networks, clients, settingsData)); + issues.AddRange(CheckFlowControl(devices, networks, clients, settingsData, portProfiles)); } if (runCellularChecks) @@ -260,7 +261,8 @@ internal List CheckFlowControl( List devices, List networks, List clients, - JsonDocument? settingsData) + JsonDocument? settingsData, + List? portProfiles = null) { var issues = new List(); var settings = GlobalSwitchSettings.FromSettingsJson(settingsData); @@ -312,6 +314,9 @@ internal List CheckFlowControl( }); } } + + // Check port profiles and per-port overrides + issues.AddRange(CheckFlowControlPortProfiles(devices, portProfiles, settings)); } else { @@ -533,6 +538,120 @@ internal List CheckCellularQos( return issues; } + /// + /// When global Flow Control is ON, check for port profiles and individual switch ports + /// that have Flow Control explicitly disabled - creating inconsistency with the global setting. + /// + [VendorSpecific("UniFi", "Reads FlowControlEnabled from port profiles and flow_control_enabled from switch port_table")] + internal List CheckFlowControlPortProfiles( + List devices, + List? portProfiles, + GlobalSwitchSettings? settings) + { + var issues = new List(); + + // Build profile lookup if available + var profilesById = portProfiles?.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Find profiles with FC explicitly disabled + if (portProfiles != null) + { + var fcOffProfiles = portProfiles + .Where(p => p.FlowControlEnabled == false && p.Forward != "disabled") + .ToList(); + if (fcOffProfiles.Count > 0) + { + _logger?.LogDebug("Found {Count} port profiles with Flow Control disabled", fcOffProfiles.Count); + + foreach (var profile in fcOffProfiles) + { + var profileName = HtmlEncode(profile.Name); + issues.Add(new PerformanceIssue + { + Title = $"Flow Control Disabled in Profile \"{profile.Name}\"", + Description = $"Flow Control is enabled globally, but the Ethernet Port Profile " + + $"\"{profile.Name}\" has Flow Control disabled. Any port assigned to this profile " + + "will not use Flow Control, overriding the global setting.", + Recommendation = $"If this isn't intentional, enable Flow Control in the " + + $"\"{profileName}\" port profile or remove the override so it inherits the global setting.", + Severity = PerformanceSeverity.Info, + Category = PerformanceCategory.Performance + }); + } + } + } + + // Find ports on each switch with FC off (via port_table field or profile override) + foreach (var device in devices) + { + if (device.PortTable == null || device.DeviceType == DeviceType.Gateway) + continue; + + // Only check devices where FC would otherwise be on + bool deviceFcOn = settings?.GetEffectiveFlowControl(device) ?? false; + if (!deviceFcOn) + continue; + + var affectedPorts = new List(); + foreach (var port in device.PortTable) + { + if (port.Forward == "disabled") + continue; + + bool portFcOff; + string? profileName = null; + + if (!string.IsNullOrEmpty(port.PortConfId) && + profilesById.TryGetValue(port.PortConfId, out var profile)) + { + if (profile.Forward == "disabled") + continue; + + // Profile takes precedence when it explicitly sets FC; + // fall back to port's own field when profile doesn't set it + portFcOff = profile.FlowControlEnabled.HasValue + ? profile.FlowControlEnabled == false + : port.FlowControlEnabled == false; + profileName = profile.Name; + } + else + { + portFcOff = port.FlowControlEnabled == false; + } + + if (portFcOff) + { + affectedPorts.Add(profileName != null + ? $"{port.Name} (\"{profileName}\")" + : port.Name); + } + } + + if (affectedPorts.Count > 0) + { + var deviceName = HtmlEncode(device.Name); + var portList = string.Join(", ", affectedPorts); + issues.Add(new PerformanceIssue + { + Title = $"Flow Control Overridden on {device.Name}", + Description = $"Flow Control is enabled globally, but {affectedPorts.Count} " + + $"port(s) on {device.Name} have it disabled: {portList}.", + Recommendation = $"If this isn't intentional, enable Flow Control on these ports " + + $"in UniFi Devices > {deviceName} > Port Manager.", + Severity = PerformanceSeverity.Info, + Category = PerformanceCategory.Performance, + DeviceName = device.Name + }); + + _logger?.LogDebug("Device {Name}: {Count} ports with FC disabled: {Ports}", + device.Name, affectedPorts.Count, portList); + } + } + + return issues; + } + #region Helper Methods /// diff --git a/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs b/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs index ba8dd0acb..cfb5f7143 100644 --- a/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs +++ b/src/NetworkOptimizer.Diagnostics/DiagnosticsEngine.cs @@ -189,7 +189,8 @@ public DiagnosticsResult RunDiagnostics( result.PerformanceIssues = _performanceAnalyzer.Analyze( deviceList, networkList, clientList, settingsData, qosRulesData, wanEnrichedData, runPerformanceChecks: options.RunPerformanceAnalyzer, - runCellularChecks: options.RunCellularDataSavings); + runCellularChecks: options.RunCellularDataSavings, + portProfiles: profileList); result.CellularWanDetected = _performanceAnalyzer.CellularWanDetected; _logger?.LogDebug("Performance Analyzer found {Count} issues", result.PerformanceIssues.Count); } diff --git a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs index 03375ed48..3b3bbd568 100644 --- a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs +++ b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs @@ -475,6 +475,9 @@ public class SwitchPort [JsonPropertyName("native_networkconf_id")] public string? NativeNetworkConfId { get; set; } + [JsonPropertyName("flow_control_enabled")] + public bool? FlowControlEnabled { get; set; } + /// /// Port profile ID if a port profile is assigned to this port. /// When set, the port profile settings override the port's direct settings. diff --git a/src/NetworkOptimizer.UniFi/Models/UniFiPortProfile.cs b/src/NetworkOptimizer.UniFi/Models/UniFiPortProfile.cs index 6f40c2a4f..b464b9572 100644 --- a/src/NetworkOptimizer.UniFi/Models/UniFiPortProfile.cs +++ b/src/NetworkOptimizer.UniFi/Models/UniFiPortProfile.cs @@ -71,6 +71,9 @@ public class UniFiPortProfile [JsonPropertyName("excluded_networkconf_ids")] public List? ExcludedNetworkConfIds { get; set; } + [JsonPropertyName("flow_control_enabled")] + public bool? FlowControlEnabled { get; set; } + [JsonPropertyName("stormctrl_bcast_enabled")] public bool StormCtrlBcastEnabled { get; set; } diff --git a/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs b/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs index 7dd1b5552..8a9b4afcb 100644 --- a/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs +++ b/tests/NetworkOptimizer.Diagnostics.Tests/Analyzers/PerformanceAnalyzerTests.cs @@ -1012,6 +1012,232 @@ public void CheckFlowControl_GlobalOff_ExcludedGatewayOn_IgnoresGateway() result.Should().BeEmpty(); } + [Fact] + public void CheckFlowControl_GlobalOn_ProfileWithFcOff_FlagsProfile() + { + var devices = new List { CreateSwitch("switch1", "Switch 1") }; + var settings = CreateSettings(flowCtrlEnabled: true); + var profiles = new List + { + new() { Id = "profile1", Name = "Custom Profile", FlowControlEnabled = false } + }; + + var result = _analyzer.CheckFlowControl( + devices, CreateWanNetwork(500), new List(), settings, profiles); + + result.Should().Contain(i => i.Title.Contains("Profile") && i.Title.Contains("Custom Profile")); + var profileIssue = result.First(i => i.Title.Contains("Profile")); + profileIssue.Severity.Should().Be(PerformanceSeverity.Info); + } + + [Fact] + public void CheckFlowControl_GlobalOn_ProfileWithFcOn_NoIssue() + { + var devices = new List { CreateSwitch("switch1", "Switch 1") }; + var settings = CreateSettings(flowCtrlEnabled: true); + var profiles = new List + { + new() { Id = "profile1", Name = "Good Profile", FlowControlEnabled = true } + }; + + var result = _analyzer.CheckFlowControl( + devices, CreateWanNetwork(500), new List(), settings, profiles); + + result.Should().NotContain(i => i.Title.Contains("Profile")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_ProfileWithFcNull_NoIssue() + { + var devices = new List { CreateSwitch("switch1", "Switch 1") }; + var settings = CreateSettings(flowCtrlEnabled: true); + var profiles = new List + { + new() { Id = "profile1", Name = "Default Profile", FlowControlEnabled = null } + }; + + var result = _analyzer.CheckFlowControl( + devices, CreateWanNetwork(500), new List(), settings, profiles); + + result.Should().NotContain(i => i.Title.Contains("Profile")); + } + + [Fact] + public void CheckFlowControl_GlobalOff_ProfileWithFcOff_NoProfileIssue() + { + var devices = new List { CreateSwitch("switch1", "Switch 1") }; + var settings = CreateSettings(flowCtrlEnabled: false); + var profiles = new List + { + new() { Id = "profile1", Name = "Custom Profile", FlowControlEnabled = false } + }; + + var result = _analyzer.CheckFlowControl( + devices, CreateWanNetwork(500), new List(), settings, profiles); + + result.Should().NotContain(i => i.Title.Contains("Profile")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_PortWithFcOffProfile_FlagsDevice() + { + var device = CreateSwitch("switch1", "Switch 1"); + device.PortTable = new List + { + new() { PortIdx = 1, Name = "Port 1", Up = true, IsUplink = false, PortConfId = "profile1" }, + new() { PortIdx = 2, Name = "Port 2", Up = true, IsUplink = false, PortConfId = "profile2" } + }; + var settings = CreateSettings(flowCtrlEnabled: true); + var profiles = new List + { + new() { Id = "profile1", Name = "No FC Profile", FlowControlEnabled = false }, + new() { Id = "profile2", Name = "Good Profile", FlowControlEnabled = true } + }; + + var result = _analyzer.CheckFlowControl( + new List { device }, CreateWanNetwork(500), + new List(), settings, profiles); + + var deviceIssue = result.FirstOrDefault(i => i.Title.Contains("Overridden")); + deviceIssue.Should().NotBeNull(); + deviceIssue!.Description.Should().Contain("Port 1"); + deviceIssue.Description.Should().NotContain("Port 2"); + deviceIssue.Description.Should().Contain("1 port"); + deviceIssue.DeviceName.Should().Be("Switch 1"); + } + + [Fact] + public void CheckFlowControl_GlobalOn_PortFcOffDirect_NoProfile_FlagsDevice() + { + var device = CreateSwitch("switch1", "Switch 1"); + device.PortTable = new List + { + new() { PortIdx = 1, Name = "Port 1", Up = true, IsUplink = false, FlowControlEnabled = false }, + new() { PortIdx = 2, Name = "Port 2", Up = true, IsUplink = false, FlowControlEnabled = true } + }; + var settings = CreateSettings(flowCtrlEnabled: true); + + var result = _analyzer.CheckFlowControl( + new List { device }, CreateWanNetwork(500), + new List(), settings, null); + + var deviceIssue = result.FirstOrDefault(i => i.Title.Contains("Overridden")); + deviceIssue.Should().NotBeNull(); + deviceIssue!.Description.Should().Contain("Port 1"); + deviceIssue.Description.Should().NotContain("Port 2"); + } + + [Fact] + public void CheckFlowControl_GlobalOn_PortFcOff_ProfileFcNull_StillFlags() + { + var device = CreateSwitch("switch1", "Switch 1"); + device.PortTable = new List + { + // Port FC is off, profile doesn't set FC (null) - port's value flags it + new() { PortIdx = 1, Name = "Port 1", Up = true, IsUplink = false, + FlowControlEnabled = false, PortConfId = "profile1" } + }; + var settings = CreateSettings(flowCtrlEnabled: true); + var profiles = new List + { + new() { Id = "profile1", Name = "Some Profile", FlowControlEnabled = null } + }; + + var result = _analyzer.CheckFlowControl( + new List { device }, CreateWanNetwork(500), + new List(), settings, profiles); + + result.Should().Contain(i => i.Title.Contains("Overridden")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_UplinkPortWithFcOff_FlagsUplink() + { + var device = CreateSwitch("switch1", "Switch 1"); + device.PortTable = new List + { + new() { PortIdx = 1, Name = "Uplink", Up = true, IsUplink = true, FlowControlEnabled = false } + }; + var settings = CreateSettings(flowCtrlEnabled: true); + + var result = _analyzer.CheckFlowControl( + new List { device }, CreateWanNetwork(500), + new List(), settings, null); + + result.Should().Contain(i => i.Title.Contains("Overridden")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_DownPortWithFcOff_StillFlags() + { + var device = CreateSwitch("switch1", "Switch 1"); + device.PortTable = new List + { + new() { PortIdx = 1, Name = "Port 1", Up = false, IsUplink = false, FlowControlEnabled = false } + }; + var settings = CreateSettings(flowCtrlEnabled: true); + + var result = _analyzer.CheckFlowControl( + new List { device }, CreateWanNetwork(500), + new List(), settings, null); + + result.Should().Contain(i => i.Title.Contains("Overridden")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_ExcludedDeviceFcOff_SkipsPortCheck() + { + var device = CreateSwitch("switch1", "Switch 1"); + device.Mac = "aa:bb:cc:00:00:01"; + device.FlowControlEnabled = false; + device.PortTable = new List + { + new() { PortIdx = 1, Name = "Port 1", Up = true, IsUplink = false, FlowControlEnabled = false } + }; + var settings = CreateSettings(flowCtrlEnabled: true, exclusions: new[] { "aa:bb:cc:00:00:01" }); + + var result = _analyzer.CheckFlowControl( + new List { device }, CreateWanNetwork(500), + new List(), settings, null); + + // Device-level mismatch IS flagged, but port-level is not + // because the device itself already has FC off + result.Should().NotContain(i => i.Title.Contains("Overridden")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_NoProfiles_NoProfileIssues() + { + var devices = new List { CreateSwitch("switch1", "Switch 1") }; + var settings = CreateSettings(flowCtrlEnabled: true); + + var result = _analyzer.CheckFlowControl( + devices, CreateWanNetwork(500), new List(), settings, null); + + result.Should().NotContain(i => i.Title.Contains("Profile") || i.Title.Contains("Overridden")); + } + + [Fact] + public void CheckFlowControl_GlobalOn_MultipleProfilesFcOff_FlagsEach() + { + var devices = new List { CreateSwitch("switch1", "Switch 1") }; + var settings = CreateSettings(flowCtrlEnabled: true); + var profiles = new List + { + new() { Id = "profile1", Name = "Profile A", FlowControlEnabled = false }, + new() { Id = "profile2", Name = "Profile B", FlowControlEnabled = false }, + new() { Id = "profile3", Name = "Profile C", FlowControlEnabled = true } + }; + + var result = _analyzer.CheckFlowControl( + devices, CreateWanNetwork(500), new List(), settings, profiles); + + var profileIssues = result.Where(i => i.Title.Contains("Profile")).ToList(); + profileIssues.Should().HaveCount(2); + profileIssues.Should().Contain(i => i.Title.Contains("Profile A")); + profileIssues.Should().Contain(i => i.Title.Contains("Profile B")); + } + #endregion #region CountHighSpeedAccessPorts From 43a7dcac93cf247b27e4a6aabc2bdc8c450f5e35 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Wed, 6 May 2026 00:08:34 -0500 Subject: [PATCH 2/5] Add configurable speedtest boot delay for Adaptive SQM (#576) * Add configurable boot delay for Adaptive SQM speedtest Adds an optional per-WAN "Boot Delay" setting that controls how many seconds to wait after deploy/boot before running the initial speedtest. Useful for connections that take time to stabilize (Starlink, cellular). When left blank, uses the previous defaults (5s for single WAN, 50s stagger for dual-WAN). * Move boot delay to same row as speedtest times Also add GPON/XGS-PON SFP ONTs to tooltip examples. * Fix BootDelaySeconds not persisting on update The repository's UPDATE path copies fields individually and was missing the new BootDelaySeconds field. * Rename Boot Delay to Speedtest Boot Delay * Remove form-row gap on mobile --- ...20260505000000_AddSqmBootDelay.Designer.cs | 2006 +++++++++++++++++ .../20260505000000_AddSqmBootDelay.cs | 25 + .../NetworkOptimizerDbContextModelSnapshot.cs | 3 + .../Models/SqmWanConfiguration.cs | 3 + .../Repositories/SpeedTestRepository.cs | 1 + .../Components/Pages/Sqm.razor | 64 +- 6 files changed, 2093 insertions(+), 9 deletions(-) create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.cs diff --git a/src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.Designer.cs new file mode 100644 index 000000000..35ae3f5d5 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.Designer.cs @@ -0,0 +1,2006 @@ +// +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("20260505000000_AddSqmBootDelay")] + partial class AddSqmBootDelay + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AuditDate") + .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("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (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("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AgentConfiguration", b => + { + b.Property("AgentId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("AdditionalSettingsJson") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AuditEnabled") + .HasColumnType("INTEGER"); + + b.Property("AuditIntervalHours") + .HasColumnType("INTEGER"); + + b.Property("BatchSize") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FlushIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("MetricsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SqmEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("AgentId"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("LastSeenAt"); + + b.ToTable("AgentConfigurations", (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.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("IsActive"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (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("TcMonitorPort") + .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("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DismissedAt") + .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("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.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("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("ModemConfigurations", (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("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("DeviceSshConfigurations", (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("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + 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("Notes") + .HasMaxLength(2000) + .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("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiRxRateKbps") + .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.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (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.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + 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("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.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .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.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (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.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("CropJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .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.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (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.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + 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.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.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .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("PatternType") + .HasColumnType("INTEGER"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("KillChainStage"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("PatternId"); + + b.ToTable("ThreatEvents", (string)null); + }); + + 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.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskType"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.cs b/src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.cs new file mode 100644 index 000000000..47ef2b432 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260505000000_AddSqmBootDelay.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + public partial class AddSqmBootDelay : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BootDelaySeconds", + table: "SqmWanConfigurations", + type: "INTEGER", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BootDelaySeconds", + table: "SqmWanConfigurations"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs index 3f2fcf5e5..a0395e209 100644 --- a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs @@ -966,6 +966,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LinkSpeedOverrideMbps") .HasColumnType("INTEGER"); + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + b.Property("CreatedAt") .HasColumnType("TEXT"); diff --git a/src/NetworkOptimizer.Storage/Models/SqmWanConfiguration.cs b/src/NetworkOptimizer.Storage/Models/SqmWanConfiguration.cs index 59db18b42..45e47060b 100644 --- a/src/NetworkOptimizer.Storage/Models/SqmWanConfiguration.cs +++ b/src/NetworkOptimizer.Storage/Models/SqmWanConfiguration.cs @@ -67,6 +67,9 @@ public class SqmWanConfiguration /// User-overridden WAN link speed in Mbps. Null = use auto-detected value from gateway port. public int? LinkSpeedOverrideMbps { get; set; } + /// Delay in seconds before running the first speedtest after deploy/boot. Null = use default (5s solo, staggered for dual-WAN). + public int? BootDelaySeconds { get; set; } + /// When this configuration was created public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/NetworkOptimizer.Storage/Repositories/SpeedTestRepository.cs b/src/NetworkOptimizer.Storage/Repositories/SpeedTestRepository.cs index 9ea9aedaa..5ddec1ece 100644 --- a/src/NetworkOptimizer.Storage/Repositories/SpeedTestRepository.cs +++ b/src/NetworkOptimizer.Storage/Repositories/SpeedTestRepository.cs @@ -446,6 +446,7 @@ public async Task SaveSqmWanConfigAsync(SqmWanConfiguration config, Cancellation existing.LatencyThresholdMs = config.LatencyThresholdMs; existing.CongestionSeverity = config.CongestionSeverity; existing.LinkSpeedOverrideMbps = config.LinkSpeedOverrideMbps; + existing.BootDelaySeconds = config.BootDelaySeconds; existing.UpdatedAt = DateTime.UtcNow; } else diff --git a/src/NetworkOptimizer.Web/Components/Pages/Sqm.razor b/src/NetworkOptimizer.Web/Components/Pages/Sqm.razor index dbc826bf1..1401ac270 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Sqm.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Sqm.razor @@ -638,6 +638,17 @@ Daily speedtest time (24h format) +
+ +
+ + seconds +
+
@@ -809,6 +820,17 @@ Staggered from WAN1 to avoid conflicts +
+ +
+ + seconds +
+
@@ -1423,6 +1445,10 @@ } @@media (max-width: 768px) { + .form-row { + gap: 0; + } + .adjustment-buttons { justify-content: center; } @@ -1871,6 +1897,7 @@ w.Interface.Equals(saved.Interface, StringComparison.OrdinalIgnoreCase)); config.WanLinkSpeedMbps = matchingWan?.LinkSpeedMbps; config.LinkSpeedOverrideMbps = saved.LinkSpeedOverrideMbps; + config.BootDelaySeconds = saved.BootDelaySeconds; UpdateCalculatedSpeeds(config); @@ -1927,6 +1954,7 @@ ? wan1Config.LatencyThreshold : null, CongestionSeverity = wan1Config.CongestionSeverity, LinkSpeedOverrideMbps = wan1Config.LinkSpeedOverrideMbps, + BootDelaySeconds = wan1Config.BootDelaySeconds, SpeedtestMorningHour = wan1Config.MorningHour, SpeedtestMorningMinute = wan1Config.MorningMinute, SpeedtestEveningHour = wan1Config.EveningHour, @@ -1955,6 +1983,7 @@ ? wan2Config.LatencyThreshold : null, CongestionSeverity = wan2Config.CongestionSeverity, LinkSpeedOverrideMbps = wan2Config.LinkSpeedOverrideMbps, + BootDelaySeconds = wan2Config.BootDelaySeconds, SpeedtestMorningHour = wan2Config.MorningHour, SpeedtestMorningMinute = wan2Config.MorningMinute, SpeedtestEveningHour = wan2Config.EveningHour, @@ -2159,6 +2188,7 @@ wan1Config.MorningMinute = 0; wan1Config.EveningHour = 19; wan1Config.EveningMinute = 0; + wan1Config.BootDelaySeconds = null; UpdateCalculatedSpeeds(wan1Config); // Reset WAN2 to defaults @@ -2174,6 +2204,7 @@ wan2Config.MorningMinute = 0; wan2Config.EveningHour = 18; wan2Config.EveningMinute = 0; + wan2Config.BootDelaySeconds = null; UpdateCalculatedSpeeds(wan2Config); } @@ -2655,14 +2686,15 @@ deploymentSteps.Add("WAN configurations saved"); await ScrollDeploymentLog(); - // Deploy first WAN (initial speedtest immediately) + // Deploy first WAN if (wan1Config.Enabled) { + var wan1Delay = wan1Config.BootDelaySeconds ?? 5; deploymentSteps.Add($"Deploying SQM for {wan1Config.Name} ({wan1Config.Interface})..."); await ScrollDeploymentLog(); var config = CreateSqmConfiguration(wan1Config); - var result = await DeploymentService.DeployAsync(config, initialDelaySeconds: 5); + var result = await DeploymentService.DeployAsync(config, initialDelaySeconds: wan1Delay); // Always add steps (includes output on failure) deploymentSteps.AddRange(result.Steps); @@ -2673,7 +2705,7 @@ { deploymentWarnings.AddRange(result.Warnings); } - deploymentSteps.Add($"{wan1Config.Name} deployed successfully (speedtest in 5s)"); + deploymentSteps.Add($"{wan1Config.Name} deployed successfully (speedtest in {wan1Delay}s)"); } else { @@ -2681,14 +2713,13 @@ } } - // Deploy second WAN (stagger speedtest only if first WAN is also deployed) + // Deploy second WAN (stagger speedtest only if first WAN is also deployed and no custom delay set) if (wan2Config.Enabled) { + var wan2Delay = wan2Config.BootDelaySeconds ?? (wan1Config.Enabled ? 50 : 5); deploymentSteps.Add($"Deploying SQM for {wan2Config.Name} ({wan2Config.Interface})..."); await ScrollDeploymentLog(); - // If WAN1 is also deployed, stagger the speedtest; otherwise this is the only WAN - var wan2Delay = wan1Config.Enabled ? 50 : 5; var config = CreateSqmConfiguration(wan2Config); var result = await DeploymentService.DeployAsync(config, initialDelaySeconds: wan2Delay); @@ -3090,6 +3121,16 @@ return min; } + private int? ParseNullableInt(string? value, int min, int max) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (int.TryParse(value, out int result)) + { + return Math.Clamp(result, min, max); + } + return null; + } + private async Task<(string? Host, int Port)> GetGatewayConnectionAsync() { try @@ -3173,6 +3214,9 @@ public int EveningHour { get; set; } = 18; public int EveningMinute { get; set; } = 30; + // Delay before first speedtest after deploy/boot (null = use default) + public int? BootDelaySeconds { get; set; } + // Physical WAN port link speed (null if unknown, e.g., GRE tunnels) public int? WanLinkSpeedMbps { get; set; } @@ -3260,7 +3304,8 @@ public WanConfigSnapshot ToSnapshot() => new( Enabled, Name, Interface, ConnectionType, NominalDownload, NominalUpload, PingHost, SpeedtestServerId, MorningHour, MorningMinute, EveningHour, EveningMinute, - BaselineLatency, LatencyThreshold, CongestionSeverity, LinkSpeedOverrideMbps); + BaselineLatency, LatencyThreshold, CongestionSeverity, LinkSpeedOverrideMbps, + BootDelaySeconds); // Check if current config differs from a snapshot public bool DiffersFrom(WanConfigSnapshot? snapshot) @@ -3281,7 +3326,8 @@ Math.Abs(BaselineLatency - snapshot.BaselineLatency) > 0.01 || Math.Abs(LatencyThreshold - snapshot.LatencyThreshold) > 0.01 || Math.Abs(CongestionSeverity - snapshot.CongestionSeverity) > 0.001 || - LinkSpeedOverrideMbps != snapshot.LinkSpeedOverrideMbps; + LinkSpeedOverrideMbps != snapshot.LinkSpeedOverrideMbps || + BootDelaySeconds != snapshot.BootDelaySeconds; } } @@ -3291,7 +3337,7 @@ int NominalDownload, int NominalUpload, string PingHost, string? SpeedtestServerId, int MorningHour, int MorningMinute, int EveningHour, int EveningMinute, double BaselineLatency, double LatencyThreshold, double CongestionSeverity, - int? LinkSpeedOverrideMbps); + int? LinkSpeedOverrideMbps, int? BootDelaySeconds); public void Dispose() { From 73ba60cf3806c5b290342a11ccfc86901005efd8 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Thu, 7 May 2026 15:51:15 -0500 Subject: [PATCH 3/5] Add Performance Tweaks page for gateway optimizations (#577) * Add Performance Tweaks UI prototype under Config Optimizer New page at /perf-tweaks with cards for fan control tuning, MongoDB SSD migration, logging offload, and SFP+ 2.5G SGMII+ patch. Includes top status area for gateway connection, udm-boot, and model detection. Supports manually-deployed mode for monitoring without management. Uses mock data for UI sign-off before backend implementation. * Fix Performance Tweaks container to match other pages' width Remove max-width constraint, use same flex column + gap pattern as SQM and Config Optimizer containers. * Fix SFP description: mention Port 7/eth6 and HSGMII synonym * Implement Performance Tweaks backend with real SSH deployment - PerfTweaksDeploymentService: status checks, deploy, remove, health checks for all 4 tweaks via SSH with delimited output parsing - Embedded resources: boot scripts from unifi-perf-tweaks repo plus the SFP+ kernel module (.ko), bundled as EmbeddedResource in csproj - Database: PerfTweakSetting table + migration for tracking manually deployed tweaks across sessions - Wire up Razor page to real service (replacing mock data) - SFP gate: checks if module already loaded before allowing deploy - MongoDB SSD: auto-deploys backup companion script alongside - SFP card: note about Port 6/eth5 support via GitHub issue - Build-time sync script (scripts/sync-perf-tweaks.ps1) to refresh embedded resources from source repo * Gate MongoDB SSD deploy on SSD volume availability Detect SSD mount (/volume1 or /volume//) during status check. Disable deploy button and show warning when no SSD is detected. This correctly blocks the tweak on UXG-Fiber (no internal SSD) while allowing it on UCG-Fiber and UCG-Max. * Add per-tweak gateway model compatibility filtering Each tweak now declares which gateway models it supports: - Fan Control, Logging Offload: all (UCG-Fiber, UXG-Fiber, UCG-Max) - MongoDB SSD: UCG-Fiber, UCG-Max only (UXG-Fiber has no SSD) - SFP SGMII+: UCG-Fiber, UXG-Fiber only (UCG-Max has no SFP ports) Cards are filtered per-model, and the active/total counter reflects only tweaks compatible with the detected gateway. * Fix model detection newline, proper removal procedures, audit fixes - Fix ubnt-device-info model_short missing trailing newline causing delimiter parsing to concatenate with next section - Fan control removal: restart uhwd to immediately revert PID setpoints - MongoDB SSD removal: stop services, unmount bind mount, clean up backup cron and helper scripts, restart UniFi - Journald volatile removal: restore journald.conf to persistent, uncomment syslog-ng routes, restart both services (overlay changes persist - just deleting boot script does NOT revert them) - Add syslog-ng eMMC route health check (part 2 of journald tweak) - Add IDS/IPS threat pipeline verification in journald health checks - Add qca-ssdk dependency check for SFP (pre-deploy gate + health) - Add MongoDB first-run service interruption warning - Add SFP qca-ssdk missing dependency warning in UI * Add firmware upgrade notes section from open source docs Collapsible card showing what survives UniFi Network upgrades, UniFi OS upgrades, and factory resets. Only shown when at least one tweak is active. Content sourced from the unifi-perf-tweaks prerequisites.md firmware compatibility table. * Fix syslog-ng eMMC route health check false positive, SFP alert spacing The eMMC route check was counting ALL active syslog-ng log routes (11), but most are legitimate non-eMMC routes (remote syslog, console, IDS/IPS, /dev/null). Now checks only uncommented log routes that reference destinations writing to /var/log (excluding /var/log/ulog tmpfs). Returns 0 correctly when the script has done its job. Also adds margin-top to action buttons for spacing after alert boxes. * Bump action buttons top margin to 1rem * Fix SerDes register check for padded hex format busybox devmem returns 0x00000050 (32-bit padded), not 0x50. The Contains("0x50") check fails because "0x50" isn't a contiguous substring of "0x00000050". Use EndsWith("50")/EndsWith("30") instead. * Handle manual deploys with different script names/paths - For manual deploys, downgrade file-existence health checks from Error to Ok since the user may have their own script names or paths - Detect fan control via log file existence (not just boot script name) - Runtime indicators (bind mount, journald.conf, lsmod, clock rate) are already script-name-agnostic and work for any naming convention - Create health check entries for manual tweaks even when our specific boot script filename isn't found on the gateway * Distinguish Detected vs Active status, format gateway model names Active now means deployed via our boot scripts. Detected means the tweak's runtime effects are present (module loaded, bind mount active, journald volatile) but not deployed our way. Detected state shows health checks and prompts "Mark as Manually Deployed" to opt into monitoring. Format gateway model via UniFiProductDatabase.GetProductNameFromShortname so UXG-FIBER displays as UXG-Fiber. Added missing shortname aliases (UXG-FIBER, UCG-FIBER, UCGFIBER, UCG-MAX). * Revert product DB data additions, use hyphen-stripping fallback instead GetProductNameFromShortname now tries stripping hyphens before lookup so UXG-FIBER matches existing UXGFIBER alias. No new data entries needed - leverages the existing alias mappings. * Widen health check labels to 180px, show SFP blurb for Detected state * Add note about upcoming tweaks and community ideas * Remove Config Optimizer breadcrumb * Simplify firmware upgrade table to show effective outcomes Boot scripts in /data/on_boot.d/ persist across OS upgrades and reapply all config changes on boot, so the per-component breakdown was misleading. Replaced with a 3-row scenario table showing what the user actually needs to care about. * Simplify firmware table, add temperature readout for fan control Replace per-component firmware matrix with 3-row scenario table showing effective outcomes (boot scripts handle OS upgrades automatically). Read CPU, HDD/SSD, and switch chip temperatures from hwmon sensors and display alongside fan speed in fan control health checks. * Show CPU die temp and board temps without guessed labels CPU die temp from SoC thermal zones (peak of 13 tsens sensors). Board temps from ctf2302 fan controller's 3 thermistors shown as unlabeled values - we can't map hwmon indices to specific components without the PCB layout. * Fix SFP false positive: 'not-loaded' contains 'loaded' The Contains("loaded") check matched "not-loaded" as true, causing the SFP card to show as detected on gateways where the module is not loaded. Changed to exact string comparison (Trim() == "loaded"). Also fixed qca-ssdk check and deploy pre-checks for the same bug. Renamed "Kernel Module" to "SFP Module" in health checks. * Add boot script version checking via md5sum Compute expected md5 hashes from embedded resources at startup. During status checks, md5sum each deployed boot script on the gateway and compare. When a mismatch is found, show "Boot Script: Update available" warning and an Update button on the card. Line endings normalized to LF before hashing to handle Windows/Linux differences. * Add deploy confirmation modal, firmware gating, and UX fixes Deploy confirmation: 4-checkbox modal before any deploy/update - backup completed, backup downloaded, warranty acknowledgment, risk acknowledgment. All must be checked before Confirm Deploy activates. Firmware gating: check ubnt-device-info firmware, hard block deploys above 5.1.10. Show unsupported firmware warning and disable buttons. Firmware version shown in status metrics. UX fixes: remove stale "requires module on gateway" from SFP description (we deploy it), add status-warning CSS for yellow dots, fix firmware version parsing placement in SSH command. * Add fan control note about setpoint configurability * Code review fixes: Contains false positives, modal guard, UX gaps - Fix "not-mounted".Contains("mounted") false positive for MongoDB - Fix SFP deploy verification using delimited output parsing - Server-side guard on ConfirmDeploy (check _allConfirmed + _canDeploy) - Disable SFP deploy button when qca-ssdk is missing - Include UCG-Max in page description and unsupported gateway text - Remove dead .pt-firmware-note CSS class * Fix fan control removal: write stock PID setpoints back to SDB Restarting uhwd alone does NOT clear tuned values from the SDB. The removal now writes stock defaults (cpu:100, hdd:68, rtl8372:109, rtl8261:103, standby:20) back to config.fan via the SDB Python API before restarting uhwd. Uses base64-encoded Python script to avoid shell quoting issues over SSH. * Clean up fan control log file on removal to clear Detected state * Gate fan SDB reset to UCG-Fiber/UXG-Fiber, reboot message for others Stock PID setpoints are only confirmed on UCG-Fiber and UXG-Fiber. On UCG-Max (or other models), removal removes the boot script and shows a message to reboot to restore stock fan behavior, rather than writing potentially-wrong stock values to the SDB. * Fix MongoDB SSD removal: stop UniFi first, wait for mongod, copy back Stop unifi, then unifi-mongodb (clean WiredTiger shutdown), wait for mongod to actually exit, copy SSD data back to eMMC before unmounting so next boot has current data. Previous version just deleted scripts without migrating data back. * Fix MongoDB removal: find SSD path directly instead of findmnt findmnt returns device notation (/dev/md3[/unifi-db]) not a filesystem path. Detect the SSD copy at /volume1/unifi-db or /volume/*/unifi-db directly by checking for WiredTiger file, then copy back to eMMC after unmounting. * Clean up SFP log file on removal to clear Detected state * Fix fan SDB reset model check to use normalized GatewayModel * Revert "Fix fan SDB reset model check to use normalized GatewayModel" This reverts commit 2aab6e6fcfda5b5938eb1e43adc6ad10451efca7. * Add spinner on Remove buttons, bump deploy/remove timeout to 5 min Remove buttons now show "Removing..." spinner while operation runs. Timeout increased from 2-3 min to 5 min for both deploy and removal to handle MongoDB SSD operations (stop, copy, restart). * Disable Mark as Manually Deployed during deploy/remove operations * Fix empty deploy tooltip, fix controller terminology Render data-tooltip as null (not empty string) when deploy is allowed, so Tippy.js doesn't show an empty bubble. Still shows tooltip with reason when deploy is blocked. Changed "UniFi Network controller" to "UniFi Network" in MongoDB first-run warning. * Sync 06-mongodb-ssd-offload.sh: detect stale SSD copy on redeploy Upstream fix: compares WiredTiger timestamps between eMMC and SSD. If eMMC is newer (after a removal + eMMC writes), re-migrates instead of reusing the stale SSD data that caused unclean shutdown on redeploy. * Improve MongoDB SSD description with bulk deletion context and app responsiveness * Show 'No link' instead of 'Unknown!' for eth6 speed when no SFP plugged in * Rename 'SFP Module' to 'SFP Kernel Module' in health checks --- scripts/sync-perf-tweaks.ps1 | 47 + ...507000000_AddPerfTweakSettings.Designer.cs | 2028 +++++++++++++++++ .../20260507000000_AddPerfTweakSettings.cs | 37 + .../NetworkOptimizerDbContextModelSnapshot.cs | 22 + .../Models/NetworkOptimizerDbContext.cs | 8 + .../Models/PerfTweakSetting.cs | 15 + .../UniFiProductDatabase.cs | 9 +- .../Components/Layout/NavMenu.razor | 6 + .../Components/Pages/PerformanceTweaks.razor | 968 ++++++++ .../NetworkOptimizer.Web.csproj | 5 + src/NetworkOptimizer.Web/Program.cs | 1 + .../PerfTweaks/06-mongodb-ssd-offload.sh | 228 ++ .../PerfTweaks/07-mongodb-ssd-backup.sh | 165 ++ .../PerfTweaks/10-journald-volatile.sh | 94 + .../PerfTweaks/15-fan-control-tuning.sh | 131 ++ .../Resources/PerfTweaks/20-sfp-sgmiiplus.sh | 88 + .../PerfTweaks/force_uniphy1_sgmiiplus.ko | Bin 0 -> 8216 bytes .../Services/PerfTweaksDeploymentService.cs | 760 ++++++ 18 files changed, 4610 insertions(+), 2 deletions(-) create mode 100644 scripts/sync-perf-tweaks.ps1 create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.Designer.cs create mode 100644 src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.cs create mode 100644 src/NetworkOptimizer.Storage/Models/PerfTweakSetting.cs create mode 100644 src/NetworkOptimizer.Web/Components/Pages/PerformanceTweaks.razor create mode 100644 src/NetworkOptimizer.Web/Resources/PerfTweaks/06-mongodb-ssd-offload.sh create mode 100644 src/NetworkOptimizer.Web/Resources/PerfTweaks/07-mongodb-ssd-backup.sh create mode 100644 src/NetworkOptimizer.Web/Resources/PerfTweaks/10-journald-volatile.sh create mode 100644 src/NetworkOptimizer.Web/Resources/PerfTweaks/15-fan-control-tuning.sh create mode 100644 src/NetworkOptimizer.Web/Resources/PerfTweaks/20-sfp-sgmiiplus.sh create mode 100644 src/NetworkOptimizer.Web/Resources/PerfTweaks/force_uniphy1_sgmiiplus.ko create mode 100644 src/NetworkOptimizer.Web/Services/PerfTweaksDeploymentService.cs diff --git a/scripts/sync-perf-tweaks.ps1 b/scripts/sync-perf-tweaks.ps1 new file mode 100644 index 000000000..ecc45326a --- /dev/null +++ b/scripts/sync-perf-tweaks.ps1 @@ -0,0 +1,47 @@ +# Syncs performance tweak scripts from the unifi-perf-tweaks source repo +# into the NetworkOptimizer embedded resources directory. +# +# Run before building to pick up the latest scripts: +# pwsh scripts/sync-perf-tweaks.ps1 +# +# Source repo: https://github.com/tvancott42/unifi-perf-tweaks (private) + +param( + [string]$SourceRepo = "$env:USERPROFILE\OneDrive\PersonalProjects\OpenSource\unifi-perf-tweaks" +) + +$DestDir = Join-Path $PSScriptRoot "..\src\NetworkOptimizer.Web\Resources\PerfTweaks" + +if (-not (Test-Path $SourceRepo)) { + Write-Error "Source repo not found at: $SourceRepo" + exit 1 +} + +if (-not (Test-Path $DestDir)) { + New-Item -ItemType Directory -Path $DestDir -Force | Out-Null +} + +$scripts = @( + "scripts/06-mongodb-ssd-offload.sh", + "scripts/07-mongodb-ssd-backup.sh", + "scripts/10-journald-volatile.sh", + "scripts/15-fan-control-tuning.sh", + "scripts/20-sfp-sgmiiplus.sh" +) + +$binaries = @( + "modules/force-uniphy1-sgmiiplus/force_uniphy1_sgmiiplus.ko" +) + +foreach ($file in $scripts + $binaries) { + $src = Join-Path $SourceRepo $file + $dest = Join-Path $DestDir (Split-Path $file -Leaf) + if (Test-Path $src) { + Copy-Item $src $dest -Force + Write-Host " Copied: $(Split-Path $file -Leaf)" + } else { + Write-Warning " Missing: $file" + } +} + +Write-Host "Sync complete." diff --git a/src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.Designer.cs new file mode 100644 index 000000000..80d763ecc --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.Designer.cs @@ -0,0 +1,2028 @@ +// +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("20260507000000_AddPerfTweakSettings")] + partial class AddPerfTweakSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AuditDate") + .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("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (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("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AgentConfiguration", b => + { + b.Property("AgentId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("AdditionalSettingsJson") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AuditEnabled") + .HasColumnType("INTEGER"); + + b.Property("AuditIntervalHours") + .HasColumnType("INTEGER"); + + b.Property("BatchSize") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FlushIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("MetricsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SqmEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("AgentId"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("LastSeenAt"); + + b.ToTable("AgentConfigurations", (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.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("IsActive"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (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("TcMonitorPort") + .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("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DismissedAt") + .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("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.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("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("ModemConfigurations", (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("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("DeviceSshConfigurations", (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("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + 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("Notes") + .HasMaxLength(2000) + .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("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiRxRateKbps") + .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.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (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.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + 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("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.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .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.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (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.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("CropJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .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.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"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (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.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + 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.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.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .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("PatternType") + .HasColumnType("INTEGER"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("KillChainStage"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("PatternId"); + + b.ToTable("ThreatEvents", (string)null); + }); + + 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.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskType"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.cs b/src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.cs new file mode 100644 index 000000000..55202a6cb --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260507000000_AddPerfTweakSettings.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + public partial class AddPerfTweakSettings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PerfTweakSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TweakId = table.Column(type: "TEXT", maxLength: 50, nullable: false), + IsManuallyDeployed = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PerfTweakSettings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_PerfTweakSettings_TweakId", + table: "PerfTweakSettings", + column: "TweakId", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "PerfTweakSettings"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs index a0395e209..8fb4bfac8 100644 --- a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs @@ -1265,6 +1265,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("FloorPlanImages", (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"); + }); + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => { b.Property("Id") diff --git a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs index d642dd426..5c29d862d 100644 --- a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs +++ b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs @@ -48,6 +48,7 @@ public NetworkOptimizerDbContext(DbContextOptions opt public DbSet WanDataUsageSnapshots { get; set; } public DbSet WanSteerTrafficClasses { get; set; } public DbSet ExternalSpeedTestServers { get; set; } + public DbSet PerfTweakSettings { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -153,6 +154,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.ServerId).IsUnique(); }); + // PerfTweakSetting configuration (tracks manually-deployed tweaks) + modelBuilder.Entity(entity => + { + entity.ToTable("PerfTweakSettings"); + entity.HasIndex(e => e.TweakId).IsUnique(); + }); + // UniFiConnectionSettings configuration (singleton - only one row) modelBuilder.Entity(entity => { diff --git a/src/NetworkOptimizer.Storage/Models/PerfTweakSetting.cs b/src/NetworkOptimizer.Storage/Models/PerfTweakSetting.cs new file mode 100644 index 000000000..358140ad2 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Models/PerfTweakSetting.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace NetworkOptimizer.Storage.Models; + +public class PerfTweakSetting +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(50)] + public string TweakId { get; set; } = ""; + + public bool IsManuallyDeployed { get; set; } +} diff --git a/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs b/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs index 7505ddb0f..520c76752 100644 --- a/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs +++ b/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs @@ -632,11 +632,16 @@ public static string GetProductNameFromShortname(string? shortname) if (string.IsNullOrEmpty(shortname)) return "Unknown"; - // Try legacy shortname alias lookup + // Try legacy shortname alias lookup (case-insensitive) if (LegacyShortnameAliases.TryGetValue(shortname, out var name)) return name; - // Return original if not found + // ubnt-device-info model_short can return hyphenated forms (e.g. UXG-FIBER) + // while aliases store condensed forms (e.g. UXGFIBER). Try without hyphens. + var condensed = shortname.Replace("-", ""); + if (condensed != shortname && LegacyShortnameAliases.TryGetValue(condensed, out var condensedName)) + return condensedName; + return shortname; } diff --git a/src/NetworkOptimizer.Web/Components/Layout/NavMenu.razor b/src/NetworkOptimizer.Web/Components/Layout/NavMenu.razor index 560e6238e..66452ca5c 100644 --- a/src/NetworkOptimizer.Web/Components/Layout/NavMenu.razor +++ b/src/NetworkOptimizer.Web/Components/Layout/NavMenu.razor @@ -15,6 +15,12 @@ Config Optimizer +
public static readonly string[] AllRanges = [.. IPv4Ranges, .. IPv6Ranges]; + private const int MaxNonCloudflareHosts = 10; + /// - /// Check if a list of IP addresses/CIDRs represents a Cloudflare-only restriction. - /// Returns true if every entry in the list is a known Cloudflare range. + /// Check if a list of IP addresses/CIDRs represents a Cloudflare-restricted configuration. + /// Returns true if the list is primarily Cloudflare ranges, tolerating a small number of + /// individual host IPs (e.g., management/home IPs) that people commonly add alongside CF ranges. + /// Non-Cloudflare entries must be single hosts (no CIDR or /32 or /128) - large non-CF subnets + /// indicate a different restriction strategy, not a Cloudflare list with extras. /// /// List of IPs or CIDRs from a firewall group or source restriction - /// True if all addresses match known Cloudflare ranges + /// True if the list is a Cloudflare IP restriction (possibly with a few management IPs) public static bool IsCloudflareOnly(IEnumerable? addresses) { if (addresses == null) @@ -64,13 +69,31 @@ public static bool IsCloudflareOnly(IEnumerable? addresses) if (list.Count == 0) return false; + int cloudflareCount = 0; + int nonCloudflareHostCount = 0; + foreach (var address in list) { - if (!IsCloudflareAddress(address)) + if (IsCloudflareAddress(address)) + { + cloudflareCount++; + continue; + } + + var trimmed = address.Trim(); + bool isSingleHost = !trimmed.Contains('/') + || trimmed.EndsWith("/32") + || trimmed.EndsWith("/128"); + + if (!isSingleHost) + return false; + + nonCloudflareHostCount++; + if (nonCloudflareHostCount > MaxNonCloudflareHosts) return false; } - return true; + return cloudflareCount > 0; } /// diff --git a/src/NetworkOptimizer.Core/Models/ProtectCamera.cs b/src/NetworkOptimizer.Core/Models/ProtectCamera.cs index 0c08a6248..680dd8375 100644 --- a/src/NetworkOptimizer.Core/Models/ProtectCamera.cs +++ b/src/NetworkOptimizer.Core/Models/ProtectCamera.cs @@ -157,6 +157,24 @@ public bool IsNvr(string? mac) /// public IEnumerable GetAll() => _cameras.Values; + /// + /// MACs of known UNAS/Drive devices (from drive_devices in V2 API). + /// These share Ubiquiti OUI prefixes with cameras but are NOT cameras. + /// + private readonly HashSet _driveDeviceMacs = new(StringComparer.OrdinalIgnoreCase); + + public void AddDriveDevice(string mac) + { + _driveDeviceMacs.Add(mac.ToLowerInvariant()); + } + + public bool IsDriveDevice(string? mac) + { + return !string.IsNullOrEmpty(mac) && _driveDeviceMacs.Contains(mac); + } + + public int DriveDeviceCount => _driveDeviceMacs.Count; + /// /// Create an empty collection /// diff --git a/src/NetworkOptimizer.UniFi/Models/UniFiProtectDeviceResponse.cs b/src/NetworkOptimizer.UniFi/Models/UniFiProtectDeviceResponse.cs index 3c5722e99..b60df2a85 100644 --- a/src/NetworkOptimizer.UniFi/Models/UniFiProtectDeviceResponse.cs +++ b/src/NetworkOptimizer.UniFi/Models/UniFiProtectDeviceResponse.cs @@ -22,6 +22,9 @@ public class UniFiAllDevicesResponse [JsonPropertyName("led_devices")] public List? LedDevices { get; set; } + + [JsonPropertyName("drive_devices")] + public List? DriveDevices { get; set; } } /// diff --git a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs index c88259e58..ab7f461f3 100644 --- a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs +++ b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs @@ -782,6 +782,25 @@ public async Task GetProtectCamerasAsync(CancellationTo } _logger.LogInformation("Found {Count} Protect devices requiring Security VLAN", result.Count); + + if (allDevices.DriveDevices != null) + { + foreach (var drive in allDevices.DriveDevices) + { + if (!string.IsNullOrEmpty(drive.Mac)) + { + result.AddDriveDevice(drive.Mac); + _logger.LogDebug("Found Drive device: {Name} ({Model}) - MAC: {Mac}", + drive.Name, drive.Model, drive.Mac); + } + } + + if (result.DriveDeviceCount > 0) + { + _logger.LogInformation("Found {Count} Drive (UNAS) devices excluded from camera detection", result.DriveDeviceCount); + } + } + return result; } diff --git a/src/NetworkOptimizer.Web/Services/SponsorshipService.cs b/src/NetworkOptimizer.Web/Services/SponsorshipService.cs index 8fafcee25..c6f5ec6af 100644 --- a/src/NetworkOptimizer.Web/Services/SponsorshipService.cs +++ b/src/NetworkOptimizer.Web/Services/SponsorshipService.cs @@ -38,7 +38,7 @@ private static readonly (string Quip, string ActionText)[] Tiers = ("Still free. Still no VC funding. Still powered by coffee and spite.", "Fund the spite"), // Level 6: 31-40 uses - stats flex - ("167,000 lines of code. 5,600 tests. One guy on 2 acres in Arkansas. Still cheaper than UI Ground shipping.", "Buy him lunch"), + ("217,000 lines of code. 6,100 tests. One guy on 2 acres in Arkansas. Still cheaper than UI Ground shipping.", "Buy him lunch"), // Level 7: 41-50 uses - former employer dig ("You've used this more than some employers used my code. Just saying.", "Money me"), diff --git a/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs b/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs index c412571e5..ca8038876 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs @@ -2710,4 +2710,174 @@ public void ProtectCameraCollection_IsNvr_ReturnsFalseForNull() } #endregion + + #region UNAS / Drive Device Tests (Issue #561) + + [Fact] + public void DetectDeviceType_UnasWithCameraOui_ClassifiedAsNasWhenDriveDeviceKnown() + { + // Arrange - UNAS Pro 4 shares OUI A8:9C:6C with Protect cameras + var service = new DeviceTypeDetectionService(); + var cameras = new ProtectCameraCollection(); + cameras.AddDriveDevice("a8:9c:6c:06:0c:cc"); + service.SetProtectCameras(cameras); + + var client = new UniFiClientResponse + { + Mac = "a8:9c:6c:06:0c:cc", + Name = "storage-core" + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert - should be NAS, not Camera + result.Category.Should().Be(ClientDeviceCategory.NAS); + result.ConfidenceScore.Should().Be(100); + result.VendorName.Should().Be("Ubiquiti"); + result.Metadata!["detection_method"].Should().Be("unifi_network_api"); + } + + [Fact] + public void DetectDeviceType_UnasSecondNic_ClassifiedAsNasWhenDriveDeviceKnown() + { + // Arrange - UNAS with 10G NIC (second MAC, unnamed) + var service = new DeviceTypeDetectionService(); + var cameras = new ProtectCameraCollection(); + cameras.AddDriveDevice("a8:9c:6c:06:0c:cd"); + service.SetProtectCameras(cameras); + + var client = new UniFiClientResponse + { + Mac = "a8:9c:6c:06:0c:cd", + Name = "a8:9c:6c:06:0c:cd" + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert + result.Category.Should().Be(ClientDeviceCategory.NAS); + result.ConfidenceScore.Should().Be(100); + } + + [Fact] + public void DetectFromMac_UnasWithCameraOui_ClassifiedAsNasWhenDriveDeviceKnown() + { + // Arrange - DetectFromMac is a separate code path used for offline detection + var service = new DeviceTypeDetectionService(); + var cameras = new ProtectCameraCollection(); + cameras.AddDriveDevice("a8:9c:6c:06:0c:cc"); + service.SetProtectCameras(cameras); + + // Act + var result = service.DetectFromMac("a8:9c:6c:06:0c:cc"); + + // Assert + result.Category.Should().Be(ClientDeviceCategory.NAS); + result.ConfidenceScore.Should().Be(100); + } + + [Fact] + public void DetectDeviceType_ActualProtectCamera_StillDetectedAsCamera() + { + // Arrange - real camera with same OUI prefix should still be detected + var service = new DeviceTypeDetectionService(); + var cameras = new ProtectCameraCollection(); + cameras.Add("a8:9c:6c:1e:76:e4", "G4 Bullet", null, isNvr: false); + cameras.AddDriveDevice("a8:9c:6c:06:0c:cc"); + service.SetProtectCameras(cameras); + + var client = new UniFiClientResponse + { + Mac = "a8:9c:6c:1e:76:e4", + Name = "cam-frontdoor" + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert - camera should still be detected via ProtectCameraCollection + result.Category.Should().Be(ClientDeviceCategory.Camera); + result.ConfidenceScore.Should().Be(100); + result.Metadata!["detection_method"].Should().Be("unifi_protect_api"); + } + + [Fact] + public void DetectDeviceType_UnasWithoutDriveData_FallsBackToMacOui() + { + // Arrange - if drive_devices isn't available, MAC OUI still fires (backward compat) + var service = new DeviceTypeDetectionService(); + // No SetProtectCameras call - simulates API failure or no V2 data + + var client = new UniFiClientResponse + { + Mac = "a8:9c:6c:06:0c:cc", + Name = "storage-core" + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert - falls back to MAC OUI (Camera) since we have no drive data + // This is expected - without the V2 API data, we can't distinguish + result.Category.Should().Be(ClientDeviceCategory.Camera); + } + + [Fact] + public void ProtectCameraCollection_IsDriveDevice_ReturnsTrueForKnownMac() + { + var collection = new ProtectCameraCollection(); + collection.AddDriveDevice("a8:9c:6c:06:0c:cc"); + + collection.IsDriveDevice("a8:9c:6c:06:0c:cc").Should().BeTrue(); + collection.IsDriveDevice("A8:9C:6C:06:0C:CC").Should().BeTrue(); + } + + [Fact] + public void ProtectCameraCollection_IsDriveDevice_ReturnsFalseForUnknownMac() + { + var collection = new ProtectCameraCollection(); + collection.AddDriveDevice("a8:9c:6c:06:0c:cc"); + + collection.IsDriveDevice("aa:bb:cc:dd:ee:ff").Should().BeFalse(); + collection.IsDriveDevice(null).Should().BeFalse(); + collection.IsDriveDevice("").Should().BeFalse(); + } + + [Fact] + public void ProtectCameraCollection_DriveDeviceCount_ReturnsCorrectCount() + { + var collection = new ProtectCameraCollection(); + collection.DriveDeviceCount.Should().Be(0); + + collection.AddDriveDevice("a8:9c:6c:06:0c:cc"); + collection.AddDriveDevice("a8:9c:6c:06:0c:cd"); + collection.DriveDeviceCount.Should().Be(2); + } + + [Fact] + public void DetectDeviceType_DriveDeviceTakesPriorityOverNamePattern() + { + // Arrange - even if name matches a pattern, drive device detection should win + var service = new DeviceTypeDetectionService(); + var cameras = new ProtectCameraCollection(); + cameras.AddDriveDevice("a8:9c:6c:06:0c:cc"); + service.SetProtectCameras(cameras); + + var client = new UniFiClientResponse + { + Mac = "a8:9c:6c:06:0c:cc", + Name = "My Security Camera" // misleading name + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert - drive device classification wins + result.Category.Should().Be(ClientDeviceCategory.NAS); + result.ConfidenceScore.Should().Be(100); + } + + #endregion } diff --git a/tests/NetworkOptimizer.Core.Tests/Helpers/CloudflareIpRangesTests.cs b/tests/NetworkOptimizer.Core.Tests/Helpers/CloudflareIpRangesTests.cs index fb22260d6..a0095ee80 100644 --- a/tests/NetworkOptimizer.Core.Tests/Helpers/CloudflareIpRangesTests.cs +++ b/tests/NetworkOptimizer.Core.Tests/Helpers/CloudflareIpRangesTests.cs @@ -76,6 +76,7 @@ public void IsCloudflareOnly_SingleIpInCloudflareRange_ReturnsTrue() [Fact] public void IsCloudflareOnly_SingleIpNotInCloudflare_ReturnsFalse() { + // A single non-CF IP with NO CF ranges is not a Cloudflare list CloudflareIpRanges.IsCloudflareOnly(["8.8.8.8"]).Should().BeFalse(); } @@ -93,6 +94,49 @@ public void IsCloudflareOnly_SubnetLargerThanCloudflareRange_ReturnsFalse() CloudflareIpRanges.IsCloudflareOnly(["104.0.0.0/8"]).Should().BeFalse(); } + [Fact] + public void IsCloudflareOnly_CloudflarePlusFewManagementIPs_ReturnsTrue() + { + // Real-world case: CF ranges plus a few static IPs for friends/family access + var list = CloudflareIpRanges.IPv4Ranges.ToList(); + list.Add("204.228.140.228"); + list.Add("216.134.237.155"); + list.Add("70.251.208.5"); + CloudflareIpRanges.IsCloudflareOnly(list).Should().BeTrue(); + } + + [Fact] + public void IsCloudflareOnly_CloudflarePlusSlash32_ReturnsTrue() + { + var list = new List(CloudflareIpRanges.IPv4Ranges) { "198.51.100.1/32" }; + CloudflareIpRanges.IsCloudflareOnly(list).Should().BeTrue(); + } + + [Fact] + public void IsCloudflareOnly_CloudflarePlusNonCfSubnet_ReturnsFalse() + { + // A /24 is not a single host - this is a different restriction strategy + var list = new List(CloudflareIpRanges.IPv4Ranges) { "203.0.113.0/24" }; + CloudflareIpRanges.IsCloudflareOnly(list).Should().BeFalse(); + } + + [Fact] + public void IsCloudflareOnly_CloudflarePlusTooManyManagementIPs_ReturnsFalse() + { + var list = CloudflareIpRanges.IPv4Ranges.ToList(); + for (int i = 1; i <= 11; i++) + list.Add($"198.51.100.{i}"); + CloudflareIpRanges.IsCloudflareOnly(list).Should().BeFalse(); + } + + [Fact] + public void IsCloudflareOnly_OnlyManagementIPsNoCf_ReturnsFalse() + { + // Individual IPs without any CF ranges is not a CF list + var list = new[] { "204.228.140.228", "216.134.237.155" }; + CloudflareIpRanges.IsCloudflareOnly(list).Should().BeFalse(); + } + #endregion #region ContainsCloudflareRange Tests From edb4fd43463502e90b13d80a91305a5e7c22544b Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Thu, 7 May 2026 16:30:01 -0500 Subject: [PATCH 5/5] Format SFP health check values for readability (#579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Link speed: 2500Mb/s → 2.5 Gbps, 1000Mb/s → 1 Gbps - SerDes register: 0x00000050 → 0x50 - Clock rate parenthetical: (2.5 G) → (2.5 Gbps) --- .../Services/PerfTweaksDeploymentService.cs | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/PerfTweaksDeploymentService.cs b/src/NetworkOptimizer.Web/Services/PerfTweaksDeploymentService.cs index 255eaebbb..e0d676890 100644 --- a/src/NetworkOptimizer.Web/Services/PerfTweaksDeploymentService.cs +++ b/src/NetworkOptimizer.Web/Services/PerfTweaksDeploymentService.cs @@ -259,24 +259,25 @@ public async Task CheckAllStatusAsync() if (clockRate != "N/A") { - var clockLabel = clockRate == "312500000" ? "312.5 MHz (2.5 G)" : clockRate == "125000000" ? "125 MHz (1 G)" : $"{clockRate} Hz"; + var clockLabel = clockRate == "312500000" ? "312.5 MHz (2.5 Gbps)" : clockRate == "125000000" ? "125 MHz (1 Gbps)" : $"{clockRate} Hz"; sfpStatus.HealthChecks.Add(new("Clock Rate", clockLabel, clockRate == "312500000" ? HealthCheckStatus.Ok : HealthCheckStatus.Error)); } if (serdesReg != "n/a") { - var regLabel = isSgmiiPlus ? $"{serdesReg} (SGMII+)" : isSgmii ? $"{serdesReg} (SGMII)" : serdesReg; + var regDisplay = FormatHexRegister(serdesReg); + var regLabel = isSgmiiPlus ? $"{regDisplay} (SGMII+)" : isSgmii ? $"{regDisplay} (SGMII)" : regDisplay; sfpStatus.HealthChecks.Add(new("SerDes Register", regLabel, isSgmiiPlus ? HealthCheckStatus.Ok : HealthCheckStatus.Error)); } if (ethSpeed != "N/A" && ethSpeed != "Unknown!") - sfpStatus.HealthChecks.Add(new("eth6 Speed", ethSpeed, ethSpeed.Contains("2500") ? HealthCheckStatus.Ok : HealthCheckStatus.Warning)); + sfpStatus.HealthChecks.Add(new("eth6 Speed", FormatLinkSpeed(ethSpeed), ethSpeed.Contains("2500") ? HealthCheckStatus.Ok : HealthCheckStatus.Warning)); else if (ethSpeed == "Unknown!") sfpStatus.HealthChecks.Add(new("eth6 Speed", "No link", HealthCheckStatus.Ok)); if (sfpModuleLoaded && !is25g && clockRate != "N/A") { - sfpStatus.IssueDescription = "Module loaded but clock/register mismatch - link may not be running at 2.5 G."; + sfpStatus.IssueDescription = "Module loaded but clock/register mismatch - link may not be running at 2.5 Gbps."; } } status.Tweaks["sfp-sgmiiplus"] = sfpStatus; @@ -486,7 +487,7 @@ public async Task CheckAllStatusAsync() var clkOk = GetSection(verifySections, "CLK").Trim() == "312500000"; if (modLoaded && clkOk) - Report("Verified: Module loaded, uniphy1 at 312.5 MHz (2.5 G)."); + Report("Verified: Module loaded, uniphy1 at 312.5 MHz (2.5 Gbps)."); else if (modLoaded) Report("Module loaded but clock rate not at expected value. Check logs."); else @@ -725,6 +726,27 @@ private static Dictionary ParseDelimitedOutput(string output) private static string GetSection(Dictionary sections, string key) => sections.TryGetValue(key, out var value) ? value : ""; + + private static string FormatHexRegister(string raw) + { + if (string.IsNullOrEmpty(raw) || !raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return raw; + var hex = raw[2..].TrimStart('0'); + if (hex.Length == 0) hex = "0"; + return "0x" + hex; + } + + private static string FormatLinkSpeed(string ethtoolSpeed) + { + var numeric = ethtoolSpeed.Replace("Mb/s", "").Trim(); + if (int.TryParse(numeric, out var mbps)) + { + return mbps % 1000 == 0 + ? $"{mbps / 1000} Gbps" + : $"{mbps / 1000.0:0.#} Gbps"; + } + return ethtoolSpeed; + } } public class PerfTweaksStatus