diff --git a/docker/Dockerfile b/docker/Dockerfile index c581015b2e..64562478e1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -114,6 +114,7 @@ RUN apt-get update && apt-get install -y \ curl \ iputils-ping \ traceroute \ + dnsutils \ libcap2-bin \ libssl3 \ gosu \ diff --git a/src/NetworkOptimizer.Agent/ProbeRequestRunner.cs b/src/NetworkOptimizer.Agent/ProbeRequestRunner.cs index 2382ba157a..427fe85690 100644 --- a/src/NetworkOptimizer.Agent/ProbeRequestRunner.cs +++ b/src/NetworkOptimizer.Agent/ProbeRequestRunner.cs @@ -39,7 +39,12 @@ public async Task HandleAsync(ProbeRequest request, CancellationToken ct) string.IsNullOrEmpty(request.SourceIp) ? null : request.SourceIp); string json; - if (request.Traceroute) + if (string.Equals(request.Kind, "dns", StringComparison.OrdinalIgnoreCase)) + { + var result = await _executor.LookupAsync(target, request.Reverse, ct); + json = JsonSerializer.Serialize(result); + } + else if (request.Traceroute) { var result = await _executor.TracerouteAsync( target, diff --git a/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto b/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto index 5be6c8e908..ee6482115d 100644 --- a/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto +++ b/src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto @@ -379,6 +379,12 @@ message ProbeRequest { bool traceroute = 6; // false = ping burst, true = traceroute int32 count = 7; // ping count (ping only) int32 max_hops = 8; // traceroute max hops + // Probe verb, added after ping/traceroute. Empty means honor `traceroute` above, so an + // agent predating this field is unaffected. "dns" asks for a lookup; an agent that does + // not know the verb ignores this field and runs a ping, which the server detects from the + // missing Kind marker on the returned result rather than showing ping data as DNS. + string kind = 9; // "" | dns + bool reverse = 10; // dns: PTR lookup rather than forward } message ProbeResponse { diff --git a/src/NetworkOptimizer.Audit/Analyzers/VlanAnalyzer.cs b/src/NetworkOptimizer.Audit/Analyzers/VlanAnalyzer.cs index 374c13d7d1..5e5024431c 100644 --- a/src/NetworkOptimizer.Audit/Analyzers/VlanAnalyzer.cs +++ b/src/NetworkOptimizer.Audit/Analyzers/VlanAnalyzer.cs @@ -1178,7 +1178,10 @@ public List AnalyzeInfrastructureVlanPlacement(JsonElement deviceDat if (string.IsNullOrEmpty(deviceType)) continue; - var parsedType = FromUniFiApiType(deviceType); + var model = device.GetStringOrNull("model"); + var shortname = device.GetStringOrNull("shortname"); + var parsedType = NetworkOptimizer.UniFi.UniFiProductDatabase.ClassifyDeviceType( + deviceType, model, shortname); // Skip gateways - they're typically on VLAN 1 by default and that's OK if (parsedType.IsGateway()) diff --git a/src/NetworkOptimizer.Core/Enums/DeviceType.cs b/src/NetworkOptimizer.Core/Enums/DeviceType.cs index d09da25902..9b58be2549 100644 --- a/src/NetworkOptimizer.Core/Enums/DeviceType.cs +++ b/src/NetworkOptimizer.Core/Enums/DeviceType.cs @@ -213,6 +213,7 @@ public static DeviceType FromUniFiApiType(string? apiType) => /// - ugw/usg/udm/uxg/ucg: Gateway (includes USG, UDM, UXG, Cloud Gateway) /// - utr: Travel Router /// - usw: Switch + /// - usp: Smart Power device /// - umbb: Cellular Modem (Mobile Broadband) /// - ubb: Building Bridge /// - udb/uacc: Device Bridge @@ -250,6 +251,8 @@ public static DeviceType FromUniFiApiType(string? apiType, string? model) "utr" => DeviceType.TravelRouter, // Switches "usw" => DeviceType.Switch, + // Smart Power devices + "usp" => DeviceType.SmartPower, // Modems "umbb" => DeviceType.CellularModem, "uci" => DeviceType.CableModem, diff --git a/src/NetworkOptimizer.Core/Helpers/NetworkFormatHelpers.cs b/src/NetworkOptimizer.Core/Helpers/NetworkFormatHelpers.cs index 2f9c7cd97c..68c8053219 100644 --- a/src/NetworkOptimizer.Core/Helpers/NetworkFormatHelpers.cs +++ b/src/NetworkOptimizer.Core/Helpers/NetworkFormatHelpers.cs @@ -136,15 +136,29 @@ public static string PonVariantLabel(string? sfpPart, string? sfpVendor) "B.V", "N.V", "Pty", "A/S", "AB", "Oy", "AS" }; + /// + /// The last step of the boil-down: what a company is actually called, for the ones whose + /// stripped legal name is not the name anyone knows them by. Exact-match against the fully + /// stripped form, never a substring - the suffix passes have already run by the time this is + /// consulted, so a key is written the way the name comes out of them. + /// + private static readonly Dictionary OrgAliases = new(StringComparer.OrdinalIgnoreCase) + { + // AS14593 registers as "Space Exploration Technologies Corporation", which strips to + // "Space Exploration" - a target list nobody would recognize as Starlink's operator. + ["Space Exploration"] = "SpaceX", + }; + /// /// The storage-time ASN/org-name cleaner: strips industry suffixes (Communications, Telecom, /// Broadband, Networks, Services, Parent, Holdings ...) and legal forms (LLC, Inc, AB ...) off /// the tail ("Hisense Broadband Technologies Co Ltd" -> "Hisense", "Level 3 Parent, LLC" -> - /// "Level 3"). Applied once when a target's AsnName is persisted - auto-discovery - /// (UpstreamTracerService.CleanAsnName) and manual target add (LatencyTargetsCard) both call it, - /// so the two paths store identical names. This is the HEAVIER of the two ASN-name cleaners; - /// the lighter resolve/display pass that also carries brand overrides (e.g. Arelion Sweden -> - /// Arelion, applied without re-discovery) is AsnNameCleanup in AsnResolutionService. + /// "Level 3"), then applies to whatever is left. Applied once when a + /// target's AsnName is persisted - auto-discovery (UpstreamTracerService.CleanAsnName) and + /// manual target add (LatencyTargetsCard) both call it, so the two paths store identical + /// names. This is the HEAVIER of the two ASN-name cleaners; the lighter resolve/display pass + /// that also carries brand overrides (e.g. Arelion Sweden -> Arelion, applied without + /// re-discovery) is AsnNameCleanup in AsnResolutionService. /// public static string CleanOrgName(string? name) { @@ -163,6 +177,7 @@ public static string CleanOrgName(string? name) } } } while (changed && cleaned.Contains(' ')); - return cleaned.Trim(); + cleaned = cleaned.Trim(); + return OrgAliases.TryGetValue(cleaned, out var alias) ? alias : cleaned; } } diff --git a/src/NetworkOptimizer.Monitoring/Models/CellularModemStats.cs b/src/NetworkOptimizer.Monitoring/Models/CellularModemStats.cs index 703e340a9a..ce829acd16 100644 --- a/src/NetworkOptimizer.Monitoring/Models/CellularModemStats.cs +++ b/src/NetworkOptimizer.Monitoring/Models/CellularModemStats.cs @@ -15,6 +15,25 @@ public enum CellularNetworkMode Nr5gSa } +/// +/// EN-DC (5G NSA) state, derived from the modem's 5G NSA availability and DCNR +/// restriction flags. Distinguishes the one case a radio reset can fix - the network +/// permits EN-DC but the serving cell does not offer it - from cases where it cannot. +/// +public enum EnDcState +{ + /// System info was unavailable, so nothing can be concluded. + Unknown, + /// An NR secondary cell is attached - 5G is up. + Attached, + /// The network is withholding EN-DC from this UE. No anchor change can help. + NetworkRestricted, + /// The serving cell offers EN-DC and the NR leg has not attached yet. + AnchorCapable, + /// EN-DC is permitted but the serving cell does not offer it. + AnchorMissing +} + /// /// Comprehensive cellular modem statistics from qmicli commands /// Supports LTE and 5G NR data from UniFi U5G-Max and similar modems @@ -44,6 +63,32 @@ public class CellularModemStats // Band info public BandInfo? ActiveBand { get; set; } + // EN-DC (5G NSA) anchor state, from the modem's system info + /// Serving cell advertises EN-DC support. Null when system info was unavailable. + public bool? Is5gNsaAvailable { get; set; } + + /// Network is withholding EN-DC from this UE. Null when system info was unavailable. + public bool? IsDcnrRestricted { get; set; } + + /// + /// Why 5G is or is not up, from the pair of EN-DC flags. The pair matters: a missing + /// NR leg is only worth acting on when the network permits EN-DC and the serving cell + /// is the one withholding it. + /// + public EnDcState EnDc + { + get + { + if (!Is5gNsaAvailable.HasValue || !IsDcnrRestricted.HasValue) + return EnDcState.Unknown; + if (Nr5g?.Rsrp.HasValue == true) + return EnDcState.Attached; + if (IsDcnrRestricted.Value) + return EnDcState.NetworkRestricted; + return Is5gNsaAvailable.Value ? EnDcState.AnchorCapable : EnDcState.AnchorMissing; + } + } + /// /// Detected network mode (LTE, 5G NSA, 5G SA) /// @@ -252,6 +297,21 @@ public class CellInfo /// Is this the serving cell? public bool IsServing { get; set; } + + /// + /// eNodeB (site) identifier: the upper 20 bits of the 28-bit LTE cell id. + /// Null when is absent or non-numeric. + /// + public int? EnbId => ParsedCellId >> 8; + + /// + /// Sector within the site: the low 8 bits of the LTE cell id. Neighbor cells + /// sharing an are other sectors of the same tower. + /// + public int? SectorId => ParsedCellId & 0xFF; + + private int? ParsedCellId => + int.TryParse(GlobalCellId, out var eci) && eci > 0 ? eci : null; } /// diff --git a/src/NetworkOptimizer.Monitoring/Probes/IProbeExecutor.cs b/src/NetworkOptimizer.Monitoring/Probes/IProbeExecutor.cs index 9df46fe3ec..bec78f9634 100644 --- a/src/NetworkOptimizer.Monitoring/Probes/IProbeExecutor.cs +++ b/src/NetworkOptimizer.Monitoring/Probes/IProbeExecutor.cs @@ -28,6 +28,27 @@ Task PingAsync( TimeSpan? perPingTimeout = null, CancellationToken ct = default); + /// + /// Resolve a name, or a name for an address when is set, using + /// whatever resolver this vantage is configured with. Which resolver answers is the point: + /// two vantages on different WANs can legitimately disagree. + /// + /// + /// Defaults to unsupported so a vantage with no lookup path (and any test double) keeps + /// compiling and says so plainly rather than returning an empty answer. + /// + Task LookupAsync( + ProbeTarget target, + bool reverse = false, + CancellationToken ct = default) + => Task.FromResult(new DnsLookupResult + { + Target = target, + Vantage = Vantage, + Timestamp = DateTime.UtcNow, + ErrorMessage = "This vantage cannot run DNS lookups." + }); + /// Run a TCP-connect probe. Task TcpProbeAsync( ProbeTarget target, diff --git a/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs b/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs index 449940cf84..cb81bf7bc2 100644 --- a/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs +++ b/src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using Microsoft.Extensions.Logging; @@ -285,6 +286,7 @@ private async Task ManagedPingAsync(ProbeTarget target, int cou var rtts = new List(); int received = 0; string? lastError = null; + string? resolvedAddress = null; using var ping = new Ping(); for (int i = 0; i < count; i++) @@ -305,6 +307,8 @@ private async Task ManagedPingAsync(ProbeTarget target, int cou { received++; rtts.Add(sw.Elapsed.TotalMilliseconds); + // Only a successful reply carries a real peer; a failure reports 0.0.0.0. + resolvedAddress ??= reply.Address?.ToString(); } else { @@ -340,11 +344,98 @@ private async Task ManagedPingAsync(ProbeTarget target, int cou RttAvgMs = avg, RttMaxMs = max, JitterMs = jitter, + ResolvedAddress = string.Equals(resolvedAddress, target.Address, StringComparison.OrdinalIgnoreCase) + ? null + : resolvedAddress, ErrorMessage = received == 0 ? lastError : null, Timestamp = DateTime.UtcNow }; } + /// + /// + /// Prefers the nslookup binary, which names the resolver that answered - the field that makes + /// two vantages comparable. Windows and macOS ship it; the Docker image installs it. Where it + /// is missing the managed resolver still answers, minus the resolver identity. + /// + public async Task LookupAsync( + ProbeTarget target, + bool reverse = false, + CancellationToken ct = default) + { + try + { + var psi = new ProcessStartInfo("nslookup", target.Address) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi); + if (proc == null) return await ManagedLookupAsync(target, reverse, ct); + + var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); + var stderrTask = proc.StandardError.ReadToEndAsync(ct); + using var killCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + killCts.CancelAfter(TimeSpan.FromSeconds(10)); + try { await proc.WaitForExitAsync(killCts.Token); } + catch (OperationCanceledException) { try { proc.Kill(entireProcessTree: true); } catch { } } + + var stdout = await SafeReadAsync(stdoutTask); + var stderr = await SafeReadAsync(stderrTask); + var combined = string.Concat(stdout, "\n", stderr); + + if (string.IsNullOrWhiteSpace(combined)) + return await ManagedLookupAsync(target, reverse, ct); + + return NslookupOutputParser.Parse(combined, target, Vantage, reverse); + } + catch (Exception) + { + // No nslookup on this host (Win32Exception) or it misbehaved - the managed + // resolver still answers the question, just without naming the server. + return await ManagedLookupAsync(target, reverse, ct); + } + } + + /// + /// Resolver-less fallback via the .NET resolver. It cannot report which server answered, + /// so Resolver stays null rather than naming something we did not observe. + /// + private async Task ManagedLookupAsync( + ProbeTarget target, bool reverse, CancellationToken ct) + { + var result = new DnsLookupResult + { + Kind = NslookupOutputParser.ResultKind, + Target = target, + Vantage = Vantage, + Timestamp = DateTime.UtcNow + }; + + try + { + if (reverse) + { + var entry = await Dns.GetHostEntryAsync(target.Address, ct); + return result with { CanonicalName = entry.HostName }; + } + + var addresses = await Dns.GetHostAddressesAsync(target.Address, ct); + return result with { Addresses = addresses.Select(a => a.ToString()).ToList() }; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.HostNotFound) + { + return result with { NotFound = true }; + } + catch (Exception ex) + { + return result with { ErrorMessage = ex.Message }; + } + } + public async Task TcpProbeAsync( ProbeTarget target, TimeSpan? timeout = null, diff --git a/src/NetworkOptimizer.Monitoring/Probes/NslookupOutputParser.cs b/src/NetworkOptimizer.Monitoring/Probes/NslookupOutputParser.cs new file mode 100644 index 0000000000..059a9e97cd --- /dev/null +++ b/src/NetworkOptimizer.Monitoring/Probes/NslookupOutputParser.cs @@ -0,0 +1,140 @@ +using System.Text.RegularExpressions; + +namespace NetworkOptimizer.Monitoring.Probes; + +/// +/// Parses nslookup output into a . Tolerant of partial or unusual +/// output - never throws, and returns null fields rather than fabricating. +/// +/// Four dialects were captured across a UniFi estate and the parser is built on what separates +/// them, not on any one format: +/// +/// BIND (gateway): Server:/Address: x#53 header, unnumbered Address: answers. +/// busybox on APs: same shape but x:53, and A and AAAA arrive in two separate blocks. +/// busybox on switches and bridges: no server line at all, numbered Address 1: answers. +/// busybox on XG switches: server line present, numbered answers, and NXDOMAIN exits 0. +/// +/// Hence three rules that are easy to get wrong: the header's Address: is the resolver and +/// must never be collected as an answer; can't resolve '(null)' is printed on SUCCESSFUL +/// lookups by several builds and is not a failure; and exit codes cannot be trusted, so +/// not-found is decided from the text. +/// +public static class NslookupOutputParser +{ + /// Identifies this result as a real lookup. See . + public const string ResultKind = "dns"; + + // "Server:\t192.168.99.1" - the resolver, on the builds that name one. + private static readonly Regex ServerRegex = new( + @"^\s*Server:\s*(?\S+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + // Answers, numbered or not: "Address 1: 1.2.3.4" / "Address: 1.2.3.4". + // A trailing name appears on busybox reverse lookups: "Address 1: 1.1.1.1 one.one.one.one". + private static readonly Regex AddressRegex = new( + @"^\s*Address\s*\d*\s*:\s*(?[0-9A-Fa-f:.]+)(?:\s+(?\S+))?\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + // BIND-style reverse answer: "1.1.1.1.in-addr.arpa\tname = one.one.one.one." + private static readonly Regex PtrRegex = new( + @"name\s*=\s*(?[^\s]+?)\.?\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + // Only meaningful after StripKnownNoise: the (null) form of "can't resolve" is printed on + // successful lookups too, so it must be removed before any of this counts as not-found. + private static readonly Regex NotFoundRegex = new( + @"NXDOMAIN|can't\s+find|can't\s+resolve|does\s+not\s+resolve|No\s+answer|Name\s+or\s+service\s+not\s+known", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Parse nslookup output. tells the parser the query was a PTR + /// lookup, where a returned address is the query echoed back rather than an answer. + /// + public static DnsLookupResult Parse( + string output, + ProbeTarget target, + ProbeVantage vantage, + bool reverse = false, + DateTime? timestamp = null) + { + var result = new DnsLookupResult + { + Kind = ResultKind, + Target = target, + Vantage = vantage, + Timestamp = timestamp ?? DateTime.UtcNow, + RawOutput = output + }; + + if (string.IsNullOrWhiteSpace(output)) + return result with { ErrorMessage = "No output from nslookup" }; + + var server = ServerRegex.Match(output); + var resolver = server.Success ? StripPort(server.Groups["server"].Value) : null; + + var addresses = new List(); + string? ptrName = null; + + // An Address line is an answer only once a Name line has introduced one. The header + // block that restates the resolver never has one, and its address carries a port, so + // matching the resolver by value alone misses it and reports your own DNS server as a + // result. True of every dialect surveyed. + var inAnswers = false; + foreach (var line in output.Split('\n')) + { + if (line.TrimStart().StartsWith("Name", StringComparison.OrdinalIgnoreCase)) + { + inAnswers = true; + continue; + } + + var m = AddressRegex.Match(line); + if (!m.Success || !inAnswers) continue; + + // busybox reverse lookups hang the PTR name off the address line. + if (m.Groups["name"].Success) ptrName ??= m.Groups["name"].Value.TrimEnd('.'); + + var address = StripPort(m.Groups["addr"].Value); + if (!addresses.Contains(address, StringComparer.OrdinalIgnoreCase)) + addresses.Add(address); + } + + if (ptrName == null && PtrRegex.Match(output) is { Success: true } ptr) + ptrName = ptr.Groups["name"].Value.TrimEnd('.'); + + // On a reverse lookup the address is the question, not the answer. + if (reverse) addresses.Clear(); + + var notFound = addresses.Count == 0 + && string.IsNullOrEmpty(ptrName) + && NotFoundRegex.IsMatch(StripKnownNoise(output)); + + return result with + { + Resolver = resolver, + Addresses = addresses, + CanonicalName = ptrName, + NotFound = notFound + }; + } + + /// + /// Drops the line several busybox builds print on every query, including successful ones. + /// Left in, it reads as a not-found on three of the five device classes surveyed. + /// + private static string StripKnownNoise(string output) => + output.Replace("can't resolve '(null)'", string.Empty, StringComparison.OrdinalIgnoreCase); + + /// Server lines carry a port as x#53 (BIND) or x:53 (busybox). + private static string StripPort(string server) + { + var hash = server.IndexOf('#'); + if (hash > 0) return server[..hash]; + + // Only strip a colon port from IPv4; an IPv6 literal is all colons. + var colon = server.LastIndexOf(':'); + if (colon > 0 && server.IndexOf(':') == colon) return server[..colon]; + + return server; + } +} diff --git a/src/NetworkOptimizer.Monitoring/Probes/PingOutputParser.cs b/src/NetworkOptimizer.Monitoring/Probes/PingOutputParser.cs index d729644e13..320d98726c 100644 --- a/src/NetworkOptimizer.Monitoring/Probes/PingOutputParser.cs +++ b/src/NetworkOptimizer.Monitoring/Probes/PingOutputParser.cs @@ -29,6 +29,20 @@ public static class PingOutputParser @"time\s*=\s*(?\d+(?:\.\d+)?)\s*ms", RegexOptions.Compiled | RegexOptions.IgnoreCase); + // What a hostname resolved to, from the header both implementations print: + // iputils: "PING example.com (192.0.2.10) 56(84) bytes of data." + // busybox: "PING example.com (192.0.2.10): 56 data bytes" + private static readonly Regex HeaderAddressRegex = new( + @"^\s*PING\s+\S+\s+\((?[0-9A-Fa-f:.]+)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + // Reply lines carry it too, for builds whose header prints no parenthesized address: + // "64 bytes from 192.0.2.10: icmp_seq=1 ttl=58 time=3.45 ms" + // "64 bytes from example.com (192.0.2.10): icmp_seq=1 ttl=58 time=3.45 ms" + private static readonly Regex ReplyAddressRegex = new( + @"bytes\s+from\s+(?:[^\s(]+\s+\()?(?[0-9A-Fa-f:.]+)\)?\s*:", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static PingProbeResult Parse( string output, ProbeTarget target, @@ -102,11 +116,30 @@ public static PingProbeResult Parse( RttAvgMs = avg, RttMaxMs = max, JitterMs = mdev, + ResolvedAddress = ExtractResolvedAddress(output, target.Address), Timestamp = timestamp ?? DateTime.UtcNow, RawOutput = output }; } + /// + /// What the target resolved to, or null when the output names no address or names the one + /// that was asked for - there is nothing to tell the reader about an address they typed. + /// + private static string? ExtractResolvedAddress(string output, string requestedAddress) + { + var header = HeaderAddressRegex.Match(output); + var address = header.Success + ? header.Groups["addr"].Value + : ReplyAddressRegex.Match(output) is { Success: true } reply + ? reply.Groups["addr"].Value + : null; + + return string.Equals(address, requestedAddress, StringComparison.OrdinalIgnoreCase) + ? null + : address; + } + private static List ExtractPerReplyRtts(string output) { var list = new List(); diff --git a/src/NetworkOptimizer.Monitoring/Probes/ProbeTypes.cs b/src/NetworkOptimizer.Monitoring/Probes/ProbeTypes.cs index 2c37454498..c466558d0a 100644 --- a/src/NetworkOptimizer.Monitoring/Probes/ProbeTypes.cs +++ b/src/NetworkOptimizer.Monitoring/Probes/ProbeTypes.cs @@ -72,6 +72,13 @@ public record PingProbeResult public double? RttMaxMs { get; init; } public double? JitterMs { get; init; } + /// + /// What a hostname target resolved to. Null when the target was already an address, or + /// when the vantage's ping gave no address to read - the same thing a traceroute hop + /// shows alongside its hostname. + /// + public string? ResolvedAddress { get; init; } + public string? ErrorMessage { get; init; } public string? RawOutput { get; init; } @@ -79,6 +86,41 @@ public record PingProbeResult public double LossPercent => Sent == 0 ? 100.0 : Math.Max(0.0, (1.0 - (double)Received / Sent) * 100.0); } +/// +/// Result of a DNS lookup from one vantage. Forward lookups fill ; +/// a reverse lookup fills . +/// +public record DnsLookupResult +{ + /// + /// Marks a result as genuinely produced by a lookup. Null identifies a relayed result from + /// an agent too old to know the verb: it ran a ping instead, and its JSON must not be read + /// as an empty DNS answer. + /// + public string? Kind { get; init; } + + public required ProbeTarget Target { get; init; } + public required ProbeVantage Vantage { get; init; } + public required DateTime Timestamp { get; init; } = DateTime.UtcNow; + + /// Resolver that answered, when the vantage names one. Several device classes do not. + public string? Resolver { get; init; } + + /// Addresses the name resolved to, in the order reported. Order varies by device. + public IReadOnlyList Addresses { get; init; } = Array.Empty(); + + /// The name a reverse lookup resolved to. + public string? CanonicalName { get; init; } + + /// The name does not exist, as opposed to the lookup having failed. + public bool NotFound { get; init; } + + public string? ErrorMessage { get; init; } + public string? RawOutput { get; init; } + + public bool Success => Addresses.Count > 0 || !string.IsNullOrEmpty(CanonicalName); +} + /// One hop on a traceroute. Address may be null for non-responding hops. public record TraceHop { diff --git a/src/NetworkOptimizer.Monitoring/Providers/ICellularModemProvider.cs b/src/NetworkOptimizer.Monitoring/Providers/ICellularModemProvider.cs index 56b5a487e2..a11392bead 100644 --- a/src/NetworkOptimizer.Monitoring/Providers/ICellularModemProvider.cs +++ b/src/NetworkOptimizer.Monitoring/Providers/ICellularModemProvider.cs @@ -44,3 +44,23 @@ public interface ICellularModemProvider ModemPollContext context, CancellationToken cancellationToken = default); } + +/// +/// Optional capability for providers that can power-cycle the modem's radio. +/// Kept separate from so vendors with no +/// equivalent control are not forced to stub it out. +/// +public interface ISupportsRadioReset +{ + /// + /// Take the radio to low power and back online, forcing a fresh cell selection. + /// Drops the cellular connection for the duration, so callers must confirm with + /// the user before invoking it. + /// + /// Provider-agnostic poll context. + /// Optional cancellation. + /// (success, human-readable message). + Task<(bool success, string message)> ResetRadioAsync( + ModemPollContext context, + CancellationToken cancellationToken = default); +} diff --git a/src/NetworkOptimizer.Monitoring/QmicliParser.cs b/src/NetworkOptimizer.Monitoring/QmicliParser.cs index 8966296faa..b1f4aa4bb8 100644 --- a/src/NetworkOptimizer.Monitoring/QmicliParser.cs +++ b/src/NetworkOptimizer.Monitoring/QmicliParser.cs @@ -8,6 +8,30 @@ namespace NetworkOptimizer.Monitoring; /// public static class QmicliParser { + /// + /// Parse the EN-DC flags out of --nas-get-system-info output. Either value is + /// null when the flag is absent, which is the case on a modem with no LTE service. + /// + /// Raw qmicli output. + /// Whether the serving cell offers EN-DC, and whether the network restricts it. + public static (bool? nsaAvailable, bool? dcnrRestricted) ParseSystemInfo(string output) + { + return (MatchFlag(output, "5G NSA Available"), MatchFlag(output, "DCNR Restriction")); + + static bool? MatchFlag(string text, string label) + { + var match = Regex.Match(text, $@"{Regex.Escape(label)}:\s*'(\w+)'"); + if (!match.Success) return null; + + return match.Groups[1].Value.ToLowerInvariant() switch + { + "yes" => true, + "no" => false, + _ => null, + }; + } + } + /// /// Parse --nas-get-signal-info output /// diff --git a/src/NetworkOptimizer.Monitoring/UiwwandParser.cs b/src/NetworkOptimizer.Monitoring/UiwwandParser.cs index 377885e744..e0b02c06c1 100644 --- a/src/NetworkOptimizer.Monitoring/UiwwandParser.cs +++ b/src/NetworkOptimizer.Monitoring/UiwwandParser.cs @@ -6,7 +6,8 @@ namespace NetworkOptimizer.Monitoring; /// /// Parses the JSON output from UniFi's uiwwand radio status command /// (ubus call uiwwand call '{"method":"get-radio-status","params":{}}') -/// into . +/// into , plus the cell tower detail from +/// get-cell-tower-info via . /// /// This command is available on all UniFi cellular modems (U5G-Max, U5G Backup, /// U-LTE) and returns a normalized view of signal, band, carrier, and carrier @@ -61,6 +62,63 @@ public static class UiwwandParser } } + /// + /// Merge the output of get-cell-tower-info into stats already parsed + /// from get-radio-status, adding the timing advance, tracking area code, + /// and neighbor cells that get-radio-status does not report. + /// + /// + /// The payload describes the LTE anchor even when NR is the active carrier, so + /// on an NSA link the timing advance is the distance to the anchor cell. + /// + /// Raw JSON output from the ubus call. + /// Stats to enrich in place. + public static void ParseCellTowerInfo(string json, CellularModemStats stats) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json, JsonOptions); + } + catch (JsonException) + { + return; + } + + using (doc) + { + if (!doc.RootElement.TryGetProperty("result", out var result) || + !result.TryGetProperty("lte", out var lte) || + lte.ValueKind != JsonValueKind.Object) + return; + + var serving = stats.ServingCell ??= new CellInfo { IsServing = true }; + + // get-radio-status reports the NR physical cell id on an NSA link while this + // reports the LTE anchor's, so keep whichever id the caller already set. + serving.TimingAdvance = GetInt(lte, "adv"); + serving.Tac = GetInt(lte, "tac")?.ToString(); + serving.GlobalCellId ??= GetInt(lte, "cell_id")?.ToString(); + serving.Earfcn ??= GetInt(lte, "earfcn"); + + if (!lte.TryGetProperty("neighbor_measurements", out var neighbors) || + neighbors.ValueKind != JsonValueKind.Array) + return; + + // Signal is left unset: rsrp/rsrq here are raw indices, not dBm. Use + // get-cell-tower-info-nrf, which reports converted values, if we ever show them. + stats.NeighborCells = neighbors.EnumerateArray() + .Select(n => new CellInfo + { + PhysicalCellId = GetInt(n, "pci") ?? 0, + GlobalCellId = GetInt(n, "cell_id")?.ToString(), + Earfcn = GetInt(n, "earfcn"), + TimingAdvance = GetInt(n, "adv"), + }) + .ToList(); + } + } + /// /// Populate LTE and/or NR5G signal info from the uiwwand result. /// In 5G SA mode, only Nr5g is populated (no LTE anchor). diff --git a/src/NetworkOptimizer.Storage/Models/Identity/AuditEvent.cs b/src/NetworkOptimizer.Storage/Models/Identity/AuditEvent.cs index cecca11e22..9dfb48639e 100644 --- a/src/NetworkOptimizer.Storage/Models/Identity/AuditEvent.cs +++ b/src/NetworkOptimizer.Storage/Models/Identity/AuditEvent.cs @@ -132,6 +132,9 @@ public static class AuditActions public const string WanSteeringChanged = "wan_steering.changed"; public const string AlertRuleChanged = "alert_rule.changed"; public const string MonitoringSetupChanged = "monitoring_setup.changed"; + + /// A cellular modem's radio was power-cycled to force a fresh tower selection. + public const string CellularRadioReset = "cellular_radio.reset"; public const string DbRestored = "db.restored"; public const string DbExported = "db.exported"; public const string PerfTweakRemoved = "perftweak.removed"; diff --git a/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs b/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs index 80c7ffeb2b..80100f0842 100644 --- a/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs +++ b/src/NetworkOptimizer.Storage/Services/MonitoringInfluxClient.cs @@ -843,7 +843,8 @@ public Task WriteSfpPonAsync( /// /// Write cellular modem signal metrics for time-series charting. - /// Tags identify the modem; fields capture all available signal/band/carrier data. + /// Tags identify the modem; fields capture all available signal/band/carrier + /// data plus the serving cell identity. /// Written to the longterm bucket since cellular trends are useful over weeks/months. /// public Task WriteCellularAsync( @@ -862,7 +863,12 @@ public Task WriteCellularAsync( int? signalQuality, int? signalBars, bool? isRoaming, - DateTime timestamp) + DateTime timestamp, + int? timingAdvanceUs = null, + int? cellId = null, + int? tac = null, + int? neighborCount = null, + bool? nsaAvailable = null) { if (!IsConfigured) return Task.CompletedTask; var point = PointData.Measurement("cellular") @@ -885,6 +891,16 @@ public Task WriteCellularAsync( if (bandwidthMhz.HasValue) point = point.Field("bandwidth_mhz", bandwidthMhz.Value); if (isRoaming.HasValue) point = point.Field("roaming", isRoaming.Value); + // Cell identity as fields, never tags: a handover would otherwise open a new series. + if (timingAdvanceUs.HasValue) point = point.Field("timing_advance", timingAdvanceUs.Value); + if (cellId.HasValue) point = point.Field("cell_id", cellId.Value); + if (tac.HasValue) point = point.Field("tac", tac.Value); + if (neighborCount.HasValue) point = point.Field("neighbor_count", neighborCount.Value); + + // Whether the serving cell offers EN-DC. Charting it dates the moment the anchor + // went bad, rather than leaving it to be noticed days later. + if (nsaAvailable.HasValue) point = point.Field("nsa_available", nsaAvailable.Value); + Enqueue(point, longterm: true); return Task.CompletedTask; } diff --git a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs index 728590fea6..8e336770f2 100644 --- a/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs +++ b/src/NetworkOptimizer.UniFi/Models/UniFiDeviceResponse.cs @@ -27,10 +27,10 @@ public class UniFiDeviceResponse /// /// Normalized device type enum value. - /// Uses model-based filtering to exclude smart power devices (USP-Strip, etc.) - /// from being classified as AccessPoints. + /// Uses product identity to normalize power appliances that the controller reports + /// as switches, access points, or dedicated SmartPower devices. /// - public DeviceType DeviceType => DeviceTypeExtensions.FromUniFiApiType(Type, Model); + public DeviceType DeviceType => UniFiProductDatabase.ClassifyDeviceType(Type, Model, Shortname); [JsonPropertyName("model")] public string Model { get; set; } = string.Empty; diff --git a/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs b/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs index 8572c0deee..56837a7f0b 100644 --- a/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs +++ b/src/NetworkOptimizer.UniFi/UniFiDiscovery.cs @@ -56,7 +56,7 @@ public async Task> DiscoverDevicesAsync(CancellationToken var discoveredDevices = devices.Select(d => { - var hardwareType = DeviceTypeExtensions.FromUniFiApiType(d.Type, d.Model); + var hardwareType = d.DeviceType; var effectiveType = DetermineDeviceType(d, allDeviceMacs, _logger); return new DiscoveredDevice @@ -591,7 +591,7 @@ public static DeviceType DetermineDeviceType( HashSet allDeviceMacs, ILogger logger) { - var baseType = DeviceTypeExtensions.FromUniFiApiType(device.Type, device.Model); + var baseType = device.DeviceType; // Only apply special handling to UDM-family devices (type = udm, uxg, ucg, etc.) if (baseType != DeviceType.Gateway) @@ -649,7 +649,7 @@ public static DeviceType DetermineDeviceType( /// The effective device type public static DeviceType GetEffectiveDeviceType(UniFiDeviceResponse device, IEnumerable allDevices) { - var baseType = DeviceTypeExtensions.FromUniFiApiType(device.Type, device.Model); + var baseType = device.DeviceType; // Only apply special handling to gateway-class devices if (baseType != DeviceType.Gateway) diff --git a/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs b/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs index 58acc42c4d..47c02ef418 100644 --- a/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs +++ b/src/NetworkOptimizer.UniFi/UniFiProductDatabase.cs @@ -1,3 +1,5 @@ +using NetworkOptimizer.Core.Enums; + namespace NetworkOptimizer.UniFi; /// @@ -99,16 +101,6 @@ public static class UniFiProductDatabase "UDB-Pro-Sector", "UDB-IoT", - // UPS and Power devices (no iperf3) - "UPS-Tower", - "UPS-2U", - "USP-PDU-Pro", - "USP-PDU-HD", - "USP-RPS", - "USP-RPS-Pro", - "USP-Plug", - "USP-Strip", - // NAS devices (storage, no iperf3) "UNAS-Pro", "UNAS-Pro-4", @@ -313,6 +305,7 @@ public static class UniFiProductDatabase // ----- Power Distribution ----- { "USPPDUP", "USP-PDU-Pro" }, + { "USPED18", "USP-PDU-Pro" }, { "USPPDUHD", "USP-PDU-HD" }, { "USPRPS", "USP-RPS" }, { "USPRPSP", "USP-RPS-Pro" }, @@ -447,6 +440,8 @@ public static class UniFiProductDatabase { "USWDA24", "UPS-Tower" }, { "USWDA25", "UPS-2U" }, { "USWDA26", "UPS-2U" }, + { "USPDA2B", "UPS-2U-Pro" }, + { "USPDA2C", "UPS-2U-Pro" }, // ----- Official: Building Bridge ----- { "UBB", "UBB" }, @@ -574,7 +569,6 @@ public static class UniFiProductDatabase { "USWF070", "USW-Pro-24" }, { "WRS3", "USW-Pro-24" }, { "WRS3F", "USW-Pro-24" }, - { "UPS2U", "USP-RPS" }, // ===================================================================== // ACCESS POINTS @@ -649,6 +643,23 @@ public static class UniFiProductDatabase { "U5G-Antenna-EU", "U5G-Backup" }, { "UDBPRO", "UDB-Pro" }, { "UDBPROSECTOR", "UDB-Pro-Sector" }, + { "UPS23", "UPS-Tower" }, + { "UPS24", "UPS-Tower" }, + { "UPSTOWERUS", "UPS-Tower" }, + { "UPSTOWEREU", "UPS-Tower" }, + { "UPS25", "UPS-2U" }, + { "UPS26", "UPS-2U" }, + { "UPS2UUS", "UPS-2U" }, + { "UPS2UEU", "UPS-2U" }, + { "UPSPROUS", "UPS-2U-Pro" }, + { "UPS2UPROUS", "UPS-2U-Pro" }, + { "UPS2UPROEU", "UPS-2U-Pro" }, + { "USPPDUPEU", "USP-PDU-Pro" }, + { "USPPDUPROEU", "USP-PDU-Pro" }, + { "USPPDUPAU", "USP-PDU-Pro" }, + { "USPPDUPROAU", "USP-PDU-Pro" }, + { "USPPDUPUK", "USP-PDU-Pro" }, + { "USPPDUPROUK", "USP-PDU-Pro" }, { "USPPLUG", "USP-Plug" }, { "USPSTRIP", "USP-Strip" }, }; @@ -681,6 +692,10 @@ public static string GetProductNameFromShortname(string? shortname) if (string.IsNullOrEmpty(shortname)) return "Unknown"; + // Some controllers put the official model code in shortname. + if (OfficialModelCodes.TryGetValue(shortname, out var officialName)) + return officialName; + // Try legacy shortname alias lookup (case-insensitive) if (LegacyShortnameAliases.TryGetValue(shortname, out var name)) return name; @@ -727,7 +742,8 @@ public static bool CanRunIperf3(string? productName) if (string.IsNullOrEmpty(productName)) return true; - return !DevicesWithoutIperf3.Contains(productName); + return !PowerDeviceProductNames.Contains(productName) && + !DevicesWithoutIperf3.Contains(productName); } /// @@ -756,17 +772,18 @@ public static bool IsFlex25G(string? model, string? shortname) /// /// UniFi power devices (UPS, PDU, redundant power supplies, smart plugs/strips). - /// Ubiquiti reports these with SWITCH device capabilities, so the API exposes a - /// single internal/management port_table row. That port is not a controllable + /// Ubiquiti reports these with power-device capabilities and exposes a single + /// internal/management port_table row. That port is not a controllable /// downstream edge port - it cannot be reconfigured with an access/trunk profile - /// so port-level VLAN/profile audits are not actionable for these devices. /// /// HOW TO MAINTAIN THIS LIST: /// Derive it from Ubiquiti's device database at https://static.ui.com/fingerprint/ui/public.json - /// A true power device reports type "usw", deviceCapabilities containing "SWITCH" together with - /// power capabilities (SMART_POWER / SMART_OUTLET / BATTERY_MANAGEMENT / REDUNDANT_POWER), AND - /// numberOfPorts == 1 (the single internal/management port). Add the product name (the value - /// GetBestProductName resolves to) here. + /// Most power devices report type "usw" and deviceCapabilities containing "SWITCH" together + /// with power capabilities (SMART_POWER / SMART_OUTLET / BATTERY_MANAGEMENT / REDUNDANT_POWER). + /// Newer devices may report the dedicated "usp" type without SWITCH. In both cases, + /// numberOfPorts == 1 identifies the single internal/management port. Add the product name + /// (the value GetBestProductName resolves to) here. /// /// DO NOT exclude on power capabilities alone. Some real, fully manageable switches carry the /// same power caps - e.g. USW-Mission-Critical (USL8MP) reports SWITCH + SMART_POWER + @@ -779,6 +796,7 @@ public static bool IsFlex25G(string? model, string? shortname) { "UPS-Tower", "UPS-2U", + "UPS-2U-Pro", "USP-PDU-Pro", "USP-PDU-HD", "USP-RPS", @@ -801,6 +819,20 @@ public static bool IsPowerDevice(string? model, string? shortname) return !string.IsNullOrEmpty(productName) && PowerDeviceProductNames.Contains(productName); } + /// + /// Classify a UniFi device using product identity before the controller's raw type. + /// Older power appliances report as switches or access points, while newer ones use + /// the dedicated usp type. The product allow-list normalizes all of them without + /// treating real managed switches with power capabilities as SmartPower devices. + /// + public static DeviceType ClassifyDeviceType(string? apiType, string? model, string? shortname) + { + if (IsPowerDevice(model, shortname)) + return DeviceType.SmartPower; + + return DeviceTypeExtensions.FromUniFiApiType(apiType, model); + } + /// /// Check if a model code represents a cellular/LTE modem /// diff --git a/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor b/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor index 82b9667498..946027a4c3 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor @@ -1,4 +1,4 @@ -@* TODO: Continue extracting Monitoring cards into standalone Razor components. +@* TODO: Continue extracting Monitoring cards into standalone Razor components. LatencyTargetsCard and SfpModulesCard are done. Remaining candidates: - Live View stat cards → MonitoringStatCards - Monitoring Status / Setup card → MonitoringSetupCard @@ -40,10 +40,10 @@ @inject NetworkOptimizer.Web.Services.Monitoring.IspHealth.IspHealthRegistry IspHealthRegistry @inject ISystemSettingsService SystemSettings @inject DashboardLayoutService DashboardLayout -@inject CableModemMonitorService CmMonitorService -@inject OntMonitorService OntMonitorService -@inject CellularModemService CellularModemService -@inject StarlinkMonitorService StarlinkMonitor +@inject ICableModemService CmMonitorService +@inject IOntMonitorService OntMonitorService +@inject ICellularModemService CellularModemService +@inject IStarlinkMonitorService StarlinkMonitor @inject MonitoringCollectionAgent CollectionAgent @inject AgentTunnelRegistry TunnelRegistry @inject NetworkOptimizer.Web.Services.IAgentEnrollmentService AgentEnrollment @@ -596,15 +596,17 @@
-
+ @* Anchored on the chart rather than the card: the sync is something the plot does + under the pointer, and it only reads as that with a line to hover. *@ +

Round-trip time

-
+

Packet loss

- -
+

CPU

-
+

Memory

@@ -860,22 +862,22 @@

RX / TX Power

-
+

Temperature

private bool LinkWanScopeSettled => !LinkNarrowsWanFilter || _appliedLinkWanKey == LinkWanKey - || (_wanOptionsLoaded && (!_multiWanUiVisible || _wanOptions.Count <= 1)); + || (_wanOptionsLoaded && _wanOptions.Count == 0); private bool LinkNarrowsWanFilter => !string.IsNullOrWhiteSpace(WanParam) @@ -2768,7 +2795,14 @@ private string? _soloDeviceMac; private string? _preSelectCategory; private long? _preSelectAtMs; + // Set only by a link carrying ?span=; null leaves the framing exactly as it was. + private long? _preSelectSpanMs; private bool _preSelectTrailing; + // The same pair for Device Stats. Kept apart from the ones above rather than shared: those are + // consumed by the latency charts' mount and cleared there, so one tab's jump would eat the + // other's instant on a page that carries both. + private long? _deviceAtMs; + private bool _deviceAtTrailing; private string? _soloTargetId; private bool _mapOrder2dFirst; private bool _liveViewOnDashboard; @@ -2874,8 +2908,14 @@ _pendingExpandMiCard = true; } - if (!string.IsNullOrEmpty(DeviceParam) && _activeTab == "devices") - _soloDeviceMac = DeviceParam; + if (_activeTab == "devices") + { + if (!string.IsNullOrEmpty(DeviceParam)) _soloDeviceMac = DeviceParam; + // Read whether or not a device rode along: a gateway the console has not named yet + // still links here, and the moment is worth framing on its own. + _deviceAtMs = long.TryParse(AtParam, out var deviceAtMs) && deviceAtMs > 0 ? deviceAtMs : null; + _deviceAtTrailing = string.Equals(AtParam, LiveAtToken, StringComparison.OrdinalIgnoreCase); + } // A deep-linked instant is armed by the NAVIGATION that named it, and consumed by the // next map mount. Mount is the wrong place to decide: the map mounts several times per @@ -2899,6 +2939,7 @@ ? analyzeAtMs : null; _preSelectTrailing = string.Equals(AtParam, LiveAtToken, StringComparison.OrdinalIgnoreCase); + _preSelectSpanMs = long.TryParse(SpanParam, out var spanMs) && spanMs > 0 ? spanMs : null; } } @@ -3604,15 +3645,18 @@ /// puts it back on the way out - so following this never costs someone the filter they set /// up over there. ///
- private void JumpToAnalysis(string category) - { - // The one thing that legitimately differs between the two Live surfaces: these tiles can be - // parked on an instant, so they carry it. The dashboard's cannot, and always say live. - var at = _mapHistoricAt is { } parkedAt + /// + /// The moment these tiles are showing, for a link that frames its window on it: the instant the + /// timeline is parked at, or the live marker. The one thing that legitimately differs between + /// the two Live surfaces - the dashboard's tiles cannot be parked, and always say live. + /// + private string CurrentAtToken() => + _mapHistoricAt is { } parkedAt ? new DateTimeOffset(parkedAt.ToUniversalTime()).ToUnixTimeMilliseconds().ToString() : LiveAtToken; - NavigationManager.NavigateTo(Analysis(category, at, LiveWan)); - } + + private void JumpToAnalysis(string category) => + NavigationManager.NavigateTo(Analysis(category, CurrentAtToken(), LiveWan)); private sealed record LatencyChartView(string AtIso, string Category); @@ -3710,7 +3754,10 @@ // Always: the LAN/WAN scope bar needs the WAN even when there is only one of them. // Comparison UI stays gated on _multiWanUiVisible, which one WAN never satisfies. await LoadWanOptionsAsync(); - if (_multiWanUiVisible) await LiveWan.LoadAsync(); + // Always as well, and for the same reason: every live tile's ?wan= is read off this + // scope, so gating it left the Live tab's links naming no WAN on a single-WAN site - + // where the dashboard's copy of those same tiles named it. + await LiveWan.LoadAsync(); _allSfps = await db.MonitoredSfps.AsNoTracking().ToListAsync(); _monitoredSfps = _allSfps.Where(s => s.IsMonitoredOnt).ToList(); @@ -4699,13 +4746,8 @@ private RenderFragment GatewayStatCards => __builder => { var gwStats = GetGatewayStats(); - var gwDeviceUrl = _gatewayMac != null - ? $"/monitoring?tab=devices&device={Uri.EscapeDataString(_gatewayMac)}" - : "/monitoring?tab=devices"; - var gwFabricTargetId = GetGatewayFabricTargetId(); - var gwFabricUrl = gwFabricTargetId != null - ? $"/monitoring?tab=performance&category=Fabric&target={Uri.EscapeDataString(gwFabricTargetId)}" - : "/monitoring?tab=performance&category=Fabric"; + var gwDeviceUrl = DeviceStats(_gatewayMac, CurrentAtToken()); + var gwFabricUrl = FabricTarget(GetGatewayFabricTargetId(), CurrentAtToken(), LiveWan); var gwLatency = GetGatewayLatency();
@@ -5539,7 +5581,11 @@ // parked there, which the window is framed on, or "live" if it was not - and // that lands on a trailing window that keeps updating rather than a frozen one. postMount = _preSelectAtMs is { } atMs - ? $"m.frameMoment('{DateTimeOffset.FromUnixTimeMilliseconds(atMs).UtcDateTime:O}', '{category}');" + ? _preSelectSpanMs is { } spanMs + // A link that states its own width frames that; everything else keeps + // the 15 minutes these charts have always opened a moment at. + ? $"m.frameWindow('{DateTimeOffset.FromUnixTimeMilliseconds(atMs).UtcDateTime:O}', '{category}', {spanMs});" + : $"m.frameMoment('{DateTimeOffset.FromUnixTimeMilliseconds(atMs).UtcDateTime:O}', '{category}');" : _preSelectTrailing ? $"m.frameTrailing('{category}');" : $"m.setCategory('{category}');"; @@ -5548,6 +5594,7 @@ _preSelectCategory = null; _soloTargetId = null; _preSelectAtMs = null; + _preSelectSpanMs = null; _preSelectTrailing = false; } // Drains any scope that arrived while the import was still in flight - see the shim @@ -5615,9 +5662,21 @@ { var healthUrl = VersionedJs("/js/device-health-charts.js"); var postMount = ""; + // Framed before the solo, so the device is picked out of the window the link asked + // for rather than the one it is about to leave. + if (_deviceAtMs is { } deviceAtMs) + { + postMount = $"m.frameMoment('{DateTimeOffset.FromUnixTimeMilliseconds(deviceAtMs).UtcDateTime:O}');"; + _deviceAtMs = null; + } + else if (_deviceAtTrailing) + { + postMount = "m.frameTrailing();"; + _deviceAtTrailing = false; + } if (!string.IsNullOrEmpty(_soloDeviceMac)) { - postMount = $"m.soloDevice('{System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(_soloDeviceMac)}');"; + postMount += $"m.soloDevice('{System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(_soloDeviceMac)}');"; _soloDeviceMac = null; } await JS.InvokeVoidAsync("eval", diff --git a/src/NetworkOptimizer.Web/Components/Pages/MonitoringDevice.razor b/src/NetworkOptimizer.Web/Components/Pages/MonitoringDevice.razor index a4cc552f95..2067640aef 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/MonitoringDevice.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/MonitoringDevice.razor @@ -123,7 +123,7 @@
-
+

Latency (fabric target)

@@ -160,7 +160,7 @@ @if (_healthSeries.Count > 0) { -
+

Device health

diff --git a/src/NetworkOptimizer.Web/Components/Pages/MonitoringTools.razor b/src/NetworkOptimizer.Web/Components/Pages/MonitoringTools.razor index 6bd89cab1f..f98d0b91c8 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/MonitoringTools.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/MonitoringTools.razor @@ -58,21 +58,23 @@
- @if (_probeVantages.Count > 0) { @foreach (var vantage in _probeVantages) { - + } } else { - + } @foreach (var d in _devices) { - + } @if (_probeVantages.Any(v => v.SourceBind != null)) @@ -177,7 +179,7 @@
@_runError
} -
+
+
@@ -208,7 +215,13 @@
-
@_pingResult.Target
+
+ @_pingResult.Target + @if (!string.IsNullOrEmpty(_pingResult.ResolvedAddress)) + { + (@_pingResult.ResolvedAddress) + } +
@if (_pingResult.RttAvgMs.HasValue) { @@ -229,6 +242,62 @@
} +@if (_dnsResult != null) +{ +
+
+

DNS Result

+ + @(_dnsResult.Success + ? (_dnsResult.Addresses.Count > 0 ? $"{_dnsResult.Addresses.Count} address(es)" : "Name found") + : _dnsResult.NotFound ? "Name not found" : "No answer") + +
+
+
+ +
@VantageLabel(_dnsResult.Vantage.Id)
+
+
+ +
@_dnsResult.Target.Address
+
+ @* Several device classes name no resolver at all, so the row is omitted rather + than shown empty or filled with a guess. *@ + @if (!string.IsNullOrEmpty(_dnsResult.Resolver)) + { +
+ +
@_dnsResult.Resolver
+
+ } + @if (_dnsResult.Addresses.Count > 0) + { +
+ +
+ @foreach (var address in _dnsResult.Addresses) + { +
@address
+ } +
+
+ } + @if (!string.IsNullOrEmpty(_dnsResult.CanonicalName)) + { +
+ +
@_dnsResult.CanonicalName
+
+ } + @if (!string.IsNullOrEmpty(_dnsResult.ErrorMessage)) + { +
@_dnsResult.ErrorMessage
+ } +
+
+} + @if (_traceResult != null) {
@@ -574,6 +643,7 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError private string VantageStorageKey => SiteContext.ScopeStorageKey("networkToolsVantage"); private PingProbeResult? _pingResult; + private DnsLookupResult? _dnsResult; private TracerouteResult? _traceResult; // Gateway interface diagnostics. The section only exists when the site has a gateway, @@ -617,6 +687,7 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError _consoleDataLoaded = ConnectionService.IsConnected; await LoadProbeVantagesAsync(); + await ApplyInitialVantageAsync(); await RefreshAgentAvailabilityAsync(); StartAgentStatusPolling(); } @@ -630,32 +701,87 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError [SupplyParameterFromQuery(Name = "from")] public string? FromParam { get; set; } - protected override async Task OnAfterRenderAsync(bool firstRender) + /// + /// Decides the starting vantage once, in OnInitializedAsync after the vantage and device + /// lists have loaded, so no render can race the decision: a from= link first, then the + /// remembered choice, then the list's first entry. During prerender the localStorage read + /// throws and the fallback stands; the interactive circuit re-runs the decision in full. + /// + private async Task ApplyInitialVantageAsync() { - if (!firstRender) return; - // Only worth restoring when there is a choice; with one origin the stored value would - // just be "server" anyway. Interop is unavailable during prerender, hence first render. - if (_probeVantages.Count == 0) return; - if (!string.IsNullOrEmpty(FromParam) && _probeVantages.Any(v => v.Key == FromParam)) + // The link outranks the remembered choice and is never persisted - it overrides this + // load only. server and device keys resolve even when the vantage list is empty, + // since neither comes from it. + if (!string.IsNullOrEmpty(FromParam)) { - ApplyVantage(FromParam); - StateHasChanged(); + var keys = _probeVantages.Select(v => v.Key) + .Concat(new[] { ProbeVantages.ServerKey }) + .Concat(_devices.Select(d => $"device:{d.Mac}")) + .ToList(); + var resolved = ResolveVantageFromLink(FromParam, keys); + if (resolved != null) + { + ApplyVantage(resolved); + return; + } + } + + var stored = await ReadStoredVantageAsync(); + if (stored != null) + { + ApplyVantage(stored); return; } + + EnsureDefaultVantage(); + } + + /// + /// The remembered vantage, or null when nothing valid is stored. A remembered agent that has + /// since been removed (or a device that is gone) must not leave the page pointing at a + /// vantage the select can't show. + /// + private async Task ReadStoredVantageAsync() + { try { var stored = await JS.InvokeAsync("localStorage.getItem", VantageStorageKey); - // A remembered agent that has since been removed (or a device that is gone) must not - // leave the page pointing at a vantage the select can't show. if (!string.IsNullOrEmpty(stored) && (_probeVantages.Any(v => v.Key == stored) || (stored.StartsWith("device:") && _devices.Any(d => $"device:{d.Mac}" == stored)))) - { - ApplyVantage(stored); - StateHasChanged(); - } + return stored; } - catch { /* no localStorage, or the circuit went away - the default vantage is fine */ } + catch { /* prerendering, or no localStorage - the default vantage is fine */ } + return null; + } + + /// + /// With the server not offering itself, the default "server" key names nothing in the + /// vantage list; start on the first origin that does. + /// + private void EnsureDefaultVantage() + { + if (_probeVantages.Count > 0 && _probeVantages.All(v => v.Key != _fromKey) && !_fromKey.StartsWith("device:")) + ApplyVantage(_probeVantages[0].Key); + } + + /// + /// Which offered vantage a "from" link means. Exact key first, then the first vantage + /// belonging to the named agent. + /// + /// + /// Callers can only name an agent (agent:7): a WAN context records the agent that + /// probes it and no vantage id, while the picker keys an agent's entries per vantage + /// (agent:7:wan2). Returns null when nothing on offer matches, leaving the + /// remembered choice alone. + /// + internal static string? ResolveVantageFromLink(string? fromParam, IEnumerable vantageKeys) + { + if (string.IsNullOrEmpty(fromParam)) return null; + + var keys = vantageKeys as IReadOnlyList ?? vantageKeys.ToList(); + return keys.FirstOrDefault(k => string.Equals(k, fromParam, StringComparison.OrdinalIgnoreCase)) + ?? keys.FirstOrDefault(k => k.StartsWith(fromParam + ":", StringComparison.OrdinalIgnoreCase)); } /// @@ -717,11 +843,6 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError serverProbesSite: !ExecutorFactory.ServerVantageIsAgent, serverLabel: "Network Optimizer server", agents); - - // With the server not offering itself, the default "server" key names nothing in the - // list; start on the first origin that does. - if (_probeVantages.Count > 0 && _probeVantages.All(v => v.Key != _fromKey) && !_fromKey.StartsWith("device:")) - ApplyVantage(_probeVantages[0].Key); } catch { @@ -778,7 +899,11 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError // The vantage list is built from live tunnels, so an agent that was down when // the page opened would otherwise never appear in it. - if (changed) await LoadProbeVantagesAsync(); + if (changed) + { + await LoadProbeVantagesAsync(); + EnsureDefaultVantage(); + } if (!_consoleDataLoaded && ConnectionService.IsConnected) { @@ -902,7 +1027,9 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError private async Task OnVantageChanged(ChangeEventArgs e) { ApplyVantage(e.Value?.ToString() ?? "server"); - if (_probeVantages.Count == 0) return; + // Saved whatever the vantage list holds: device and server keys never came from it, so + // gating on it lost a hand-picked device vantage on any site whose list came back empty. + // A deep link deliberately does not reach here - it overrides the load, not the choice. try { await JS.InvokeVoidAsync("localStorage.setItem", VantageStorageKey, _fromKey); } catch { /* remembering the vantage is best-effort */ } } @@ -989,12 +1116,51 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError return ExecutorFactory.GetServer(); } + /// + /// Resolve the target from the chosen vantage. A bare address is treated as a reverse + /// lookup, since asking a resolver to forward-resolve an address answers nothing. + /// + private async Task RunLookup() + { + _running = true; + _runningKind = "dns"; + _pingResult = null; + _traceResult = null; + _dnsResult = null; + _runError = null; + StateHasChanged(); + try + { + var executor = await ResolveExecutorAsync(); + if (executor == null) + { + _runError = "Couldn't resolve the chosen vantage; check SSH credentials."; + return; + } + var address = _targetAddress.Trim(); + var reverse = System.Net.IPAddress.TryParse(address, out _); + var target = new ProbeTarget(address, _probeMode, null, SelectedSourceBind()); + _dnsResult = await executor.LookupAsync(target, reverse); + } + catch (Exception ex) + { + _runError = $"Lookup failed: {ex.Message}"; + } + finally + { + _running = false; + _runningKind = string.Empty; + StateHasChanged(); + } + } + private async Task RunPing() { _running = true; _runningKind = "ping"; _pingResult = null; _traceResult = null; + _dnsResult = null; _runError = null; StateHasChanged(); try @@ -1026,6 +1192,7 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError _runningKind = "trace"; _pingResult = null; _traceResult = null; + _dnsResult = null; _runError = null; StateHasChanged(); try @@ -1077,12 +1244,14 @@ else if (_diagResult != null && !string.IsNullOrEmpty(_diagResult.InterfaceError private static string LabelForDevice(DiscoveredDevice d) { + // The three common types are shortened for a dropdown ("AP", not "Access Point"); + // everything else takes the shared display name rather than a raw enum member. var type = d.Type switch { DeviceType.Gateway => "Gateway", DeviceType.Switch => "Switch", DeviceType.AccessPoint => "AP", - _ => d.Type.ToString() + _ => d.Type.ToDisplayName() }; var name = string.IsNullOrEmpty(d.Name) ? d.Mac : d.Name; return string.IsNullOrEmpty(d.DisplayIpAddress) diff --git a/src/NetworkOptimizer.Web/Components/Pages/PerformanceTweaks.razor b/src/NetworkOptimizer.Web/Components/Pages/PerformanceTweaks.razor index 2e0ff07d5c..19e1a5dac8 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/PerformanceTweaks.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/PerformanceTweaks.razor @@ -122,7 +122,7 @@ @if (_gatewayConnected && _status?.FirmwareSupported == false && !string.IsNullOrEmpty(_status?.FirmwareVersion)) {
- Unsupported Firmware: Performance tweaks are currently tested and supported up to UniFi OS 5.1.27. Your gateway is running @_status.FirmwareVersion. Deploying new tweaks is disabled until we validate compatibility with this version. Existing tweaks will continue to run. + Unsupported Firmware: Performance tweaks are currently tested and supported up to UniFi OS 5.1.29. Your gateway is running @_status.FirmwareVersion. Deploying new tweaks is disabled until we validate compatibility with this version. Existing tweaks will continue to run.
} diff --git a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor index 2a4f382398..517142ddff 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor @@ -23,7 +23,7 @@ @inject ISpeedTestRepository SpeedTestRepository @inject AuditService AuditService @inject ISqmService SqmService -@inject CellularModemService ModemService +@inject ICellularModemService ModemService @inject UniFiSshService SshService @inject IGatewaySpeedTestService GatewayService @inject SystemSettingsService SystemSettings @@ -52,9 +52,9 @@ @inject ISiteConfigurationService SiteConfig @inject SnmpDetectionService SnmpDetection @inject MonitoringInfluxClient InfluxClient -@inject CableModemMonitorService CmMonitorService -@inject OntMonitorService OntMonitorService -@inject StarlinkMonitorService StarlinkMonitor +@inject ICableModemService CmMonitorService +@inject IOntMonitorService OntMonitorService +@inject IStarlinkMonitorService StarlinkMonitor @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject NetworkOptimizer.Web.Services.Identity.IIdentityAdminService IdentityAdmin @inject NetworkOptimizer.Web.Services.Identity.ICurrentUserAccessor CurrentUser @@ -1386,7 +1386,7 @@ - Behind your WAN and unreachable for polling? + Can you only reach the management page of this device when directly connected? Set up a monitoring interface →
@@ -1629,7 +1629,7 @@ - Behind your WAN and unreachable for polling? + Can you only reach the management page of this device when directly connected? Set up a monitoring interface →
@@ -1855,7 +1855,7 @@ - Behind your WAN and unreachable for polling? + Can you only reach the management page of this device when directly connected? Set up a monitoring interface →
@@ -2067,7 +2067,7 @@ - Behind your WAN and unreachable for polling? + Can you only reach the management page of this device when directly connected? Set up a monitoring interface →
@@ -3453,9 +3453,11 @@
- - +
+ + +
} else diff --git a/src/NetworkOptimizer.Web/Components/Shared/CellularStatsPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/CellularStatsPanel.razor index 022157340f..7ec771fb82 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/CellularStatsPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/CellularStatsPanel.razor @@ -1,9 +1,11 @@ @using NetworkOptimizer.Web.Services @using NetworkOptimizer.Monitoring.Models -@inject CellularModemService ModemService +@using Microsoft.JSInterop +@inject ICellularModemService ModemService +@inject IJSRuntime JS @implements IDisposable -
+
@if (isLoading) {
@@ -67,7 +69,7 @@ @if (isRefreshing) { } else { Refresh } - Modem Settings + Modem Settings
} else if (modemStats == null) @@ -79,7 +81,7 @@

No cellular modem configured.

} @@ -136,6 +138,28 @@ { Roaming } + @if (ShowRadioReset) + { + @* Admin, not Operator: this drops the cellular connection for several seconds, + so it sits with the destructive actions rather than the routine ones. *@ + + @* Tooltip lives on the wrapper: a disabled button has pointer-events none and never fires the hover *@ + + + + + }
@modemStats.NetworkModeDescription @@ -164,6 +188,15 @@
} + else if (ShowNr5gPlaceholder) + { +
+
5G NR
+
+

@Nr5gPlaceholderText

+
+
+ } @if (modemStats.Lte != null && (modemStats.Lte.Rsrp.HasValue || modemStats.Lte.Rsrq.HasValue || modemStats.Lte.Snr.HasValue)) {
@@ -210,6 +243,13 @@ @modemStats.ServingCell.BandDescription
} + @if (modemStats.ServingCell.EnbId.HasValue) + { +
+ Site: + @modemStats.ServingCell.EnbId, sector @modemStats.ServingCell.SectorId +
+ } @if (modemStats.ServingCell.TimingAdvance.HasValue) {
@@ -242,7 +282,7 @@ } - Modem Settings + Modem Settings
} @@ -358,6 +398,42 @@ font-weight: 600; } + .metric-empty-text { + margin: 0; + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.35; + } + + .radio-reset { + margin-left: auto; + } + + .radio-reset-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 1rem; + cursor: pointer; + } + + .radio-reset-btn:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); + } + + .radio-reset-btn:disabled { + opacity: 0.4; + cursor: default; + } + .registration-state { color: var(--text-muted, #888); font-size: 0.85rem; @@ -435,6 +511,7 @@ private CellularModemStats? modemStats; private bool isLoading = true; private bool isRefreshing = false; + private bool isResettingRadio = false; private bool hasConfiguredModems = false; private List configuredModems = new(); private int currentModemIndex = 0; @@ -471,7 +548,7 @@ { // Just fetch cached stats for current modem - don't force a poll var modem = configuredModems[currentModemIndex]; - var newStats = ModemService.GetCachedStats(modem.Id); + var newStats = await ModemService.GetCachedStatsAsync(modem.Id); if (newStats != null) modemStats = newStats; } StateHasChanged(); @@ -500,7 +577,7 @@ var modem = configuredModems[currentModemIndex]; // Use cached stats if available, otherwise poll - var cachedStats = ModemService.GetCachedStats(modem.Id); + var cachedStats = await ModemService.GetCachedStatsAsync(modem.Id); if (cachedStats != null) { modemStats = cachedStats; @@ -511,7 +588,7 @@ var (success, _) = await ModemService.PollModemAsync(modem); if (success) { - modemStats = ModemService.GetCachedStats(modem.Id); + modemStats = await ModemService.GetCachedStatsAsync(modem.Id); } } } @@ -542,7 +619,7 @@ var (success, _) = await ModemService.PollModemAsync(modem); if (success) { - modemStats = ModemService.GetCachedStats(modem.Id); + modemStats = await ModemService.GetCachedStatsAsync(modem.Id); } } } @@ -553,6 +630,63 @@ } } + /// + /// A 5G modem with no NR leg otherwise leaves a single box in a two-box grid, which + /// reads as a broken card rather than an absent connection. The EN-DC flags double as + /// the capability test: an LTE-only modem never reports them, so it never gets the box. + /// + private bool ShowNr5gPlaceholder => modemStats?.EnDc + is EnDcState.AnchorMissing or EnDcState.AnchorCapable or EnDcState.NetworkRestricted; + + private string Nr5gPlaceholderText => modemStats?.EnDc switch + { + EnDcState.NetworkRestricted => "Not connected. Your carrier is not offering 5G on this connection.", + EnDcState.AnchorCapable => "Not connected. 5G is available here and should attach with traffic.", + _ => "Not connected. This tower is not offering 5G. Try the Reset Radio icon above.", + }; + + /// + /// Only offered when the EN-DC flags say something about it: hidden when 5G is + /// already attached, and when the modem reported no flags at all. + /// + private bool ShowRadioReset => modemStats?.EnDc + is EnDcState.AnchorMissing or EnDcState.AnchorCapable or EnDcState.NetworkRestricted; + + private string RadioResetTooltip => modemStats?.EnDc switch + { + EnDcState.AnchorCapable => "Anchor is fine and the 5G leg is still connecting. Resetting now would undo it.", + EnDcState.NetworkRestricted => "Your carrier is not offering 5G on this connection right now, so resetting the radio will not bring it back.", + _ => "Reset Radio: cycles the radio off and on for about 7 seconds to force a fresh tower selection. Use when the modem is stuck on LTE and will not pick up 5G again.", + }; + + private async Task ResetRadio() + { + if (modemStats?.EnDc != EnDcState.AnchorMissing) return; + if (configuredModems.Count == 0 || currentModemIndex >= configuredModems.Count) return; + + var confirmed = await JS.InvokeAsync("confirm", + "Resetting the radio drops the cellular connection for about 7 seconds while the modem re-selects a tower.\n\nAre you sure?"); + if (!confirmed) return; + + var modem = configuredModems[currentModemIndex]; + isResettingRadio = true; + StateHasChanged(); + + try + { + var (success, _) = await ModemService.ResetRadioAsync(modem.Id); + if (success) + { + modemStats = await ModemService.GetCachedStatsAsync(modem.Id); + } + } + finally + { + isResettingRadio = false; + StateHasChanged(); + } + } + private async Task PreviousModem() { if (currentModemIndex > 0) @@ -576,7 +710,7 @@ if (configuredModems.Count > 0 && currentModemIndex < configuredModems.Count) { var modem = configuredModems[currentModemIndex]; - var cached = ModemService.GetCachedStats(modem.Id); + var cached = await ModemService.GetCachedStatsAsync(modem.Id); modemStats = cached; StateHasChanged(); @@ -586,7 +720,7 @@ var (success, _) = await ModemService.PollModemAsync(modem); if (success) { - modemStats = ModemService.GetCachedStats(modem.Id); + modemStats = await ModemService.GetCachedStatsAsync(modem.Id); StateHasChanged(); } } diff --git a/src/NetworkOptimizer.Web/Components/Shared/CmStatsPanel.razor b/src/NetworkOptimizer.Web/Components/Shared/CmStatsPanel.razor index 50f3b588d4..e6191736f2 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/CmStatsPanel.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/CmStatsPanel.razor @@ -1,6 +1,6 @@ @using NetworkOptimizer.Web.Services @using NetworkOptimizer.Monitoring.Models -@inject CableModemMonitorService CmService +@inject ICableModemService CmService @implements IDisposable
@@ -61,7 +61,7 @@ @if (_isRefreshing) { } else { Refresh } - CM Settings + CM Settings
} else if (_stats == null) @@ -75,7 +75,7 @@ Monitor your cable modem's downstream power, SNR, upstream power, and FEC error rates. Supports Netgear, ARRIS Surfboard, Motorola, and Xfinity modems.

} @@ -152,7 +152,7 @@ @if (_isRefreshing) { } else { Refresh } - CM Settings + CM Settings } @@ -202,11 +202,10 @@ await LoadStatsAsync(); } - private Task LoadStatsAsync() + private async Task LoadStatsAsync() { - if (_configs.Count == 0) { _stats = null; return Task.CompletedTask; } - _stats = CmService.GetCachedStats(_configs[_currentIndex].Id); - return Task.CompletedTask; + if (_configs.Count == 0) { _stats = null; return; } + _stats = await CmService.GetCachedStatsAsync(_configs[_currentIndex].Id); } /// Whether polling is enabled for the currently shown modem (disabled configs diff --git a/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor b/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor index 5fa68b1eaf..d50ae29f9b 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/LatencyTargetsCard.razor @@ -1,4 +1,4 @@ -@using NetworkOptimizer.Storage.Models +@using NetworkOptimizer.Storage.Models @using Microsoft.EntityFrameworkCore @using NetworkOptimizer.Web.Components.Shared.Monitoring @inject MonitoringLiveStats LiveStats @@ -61,12 +61,12 @@

- Default WAN targets and one fabric target per discovered device. The agent probes - each target at its own interval and writes results to the latency + Default WAN targets and one fabric target per discovered device. The @ProberNoun probes + each target at @ProberPossessive own interval and writes results to the latency measurement in InfluxDB.

- Run Probe + Run Probe