Skip to content

Commit 7645ea0

Browse files
committed
Show LAG in path trace, fix gateway WAN port speed regression (#511)
* Show Link Aggregation info in speed test path trace tooltip Adds LAG membership detection as a post-processing step after path building. When a link between two devices uses a LAG, the connector tooltip now shows "Link Aggregation: 2x 10 Gbps" (or similar). Purely additive - annotates new fields on NetworkHop after the path is fully built, never modifies existing speed or path logic. Closes #349 * Add comprehensive chain walk diagnostic logging (temporary) * Skip LocalUplinkPort fallback for gateways (WAN port, not LAN) Gateway devices have LocalUplinkPort pointing to the WAN port. When the primary speed lookup returned 0 (ISP device not in UniFi), the fallback picked up the WAN port speed (e.g., 5 Gbps) instead of leaving it at 0 for the other side of the link to resolve. Confirmed via diagnostic logging: - Gateway test: BuildHopList target fallback fired on gateway WAN port - Inter-VLAN test: chain walk egress fallback fired on gateway WAN port Guarded all 4 LocalUplinkPort fallback sites with gateway type check. * Add comprehensive path trace test harness with TopologyBuilder Fluent TopologyBuilder for constructing test network topologies with mixed link speeds, LAG, wireless mesh, and inter-VLAN routing. 23 test cases covering: - Simple wired paths with mixed speeds (10G/2.5G/1G) - Gateway as target (regression test for WAN port fallback) - Inter-VLAN routing through gateway - Wi-Fi client with asymmetric TX/RX rates - Mesh AP backhaul with signal/band/channel - LAG aggregate links (2x10G) - Daisy-chain switches with bottlenecks at different layers - Same-switch paths - AP with empty port table fallback * Add mid-path bottleneck tests for all trace types 5 new tests with slow links sandwiched between faster ones: - 10G-10G-1G-10G-5G direct path - 2.5G sandwiched by 10G - Inter-VLAN with 1G bottleneck between 10G links - Gateway as target with 1G mid-path bottleneck - Same-VLAN direct path with 1G switching bottleneck (no gateway) * Remove temporary diagnostic logging
1 parent 08e4ca1 commit 7645ea0

5 files changed

Lines changed: 1885 additions & 12 deletions

File tree

src/NetworkOptimizer.UniFi/Models/NetworkHop.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ public class NetworkHop
9292
/// <summary>RX rate in Mbps for wireless link (from uplink to device)</summary>
9393
public int? WirelessRxRateMbps { get; set; }
9494

95+
/// <summary>Whether the ingress port is part of a Link Aggregation Group</summary>
96+
public bool IsLagIngress { get; set; }
97+
98+
/// <summary>Whether the egress port is part of a Link Aggregation Group</summary>
99+
public bool IsLagEgress { get; set; }
100+
101+
/// <summary>Number of member ports in the LAG group (ingress side). Null if not LAG.</summary>
102+
public int? LagIngressMemberCount { get; set; }
103+
104+
/// <summary>Number of member ports in the LAG group (egress side). Null if not LAG.</summary>
105+
public int? LagEgressMemberCount { get; set; }
106+
107+
/// <summary>Per-member speed in the LAG group (ingress side, Mbps). Null if not LAG.</summary>
108+
public int? LagIngressMemberSpeedMbps { get; set; }
109+
110+
/// <summary>Per-member speed in the LAG group (egress side, Mbps). Null if not LAG.</summary>
111+
public int? LagEgressMemberSpeedMbps { get; set; }
112+
95113
/// <summary>Additional notes (e.g., "L3 routing", "Wireless uplink")</summary>
96114
public string? Notes { get; set; }
97115
}

src/NetworkOptimizer.UniFi/NetworkPathAnalyzer.cs

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,9 @@ public async Task<NetworkPath> CalculatePathAsync(
505505
// Enrich hops with device settings (jumbo frames, flow control, HW accel)
506506
await EnrichDeviceSettingsAsync(path.Hops, rawDevices, cancellationToken);
507507

508+
// Annotate LAG membership on hop ports
509+
AnnotateLagMembership(path.Hops, rawDevices);
510+
508511
// Calculate bottleneck
509512
CalculateBottleneck(path);
510513

@@ -597,6 +600,9 @@ public async Task<NetworkPath> CalculateGatewayDirectPathAsync(
597600
// Enrich hops with device settings (jumbo frames, flow control, HW accel)
598601
await EnrichDeviceSettingsAsync(path.Hops, rawDevices, cancellationToken);
599602

603+
// Annotate LAG membership on hop ports
604+
AnnotateLagMembership(path.Hops, rawDevices);
605+
600606
CalculateBottleneck(path);
601607

602608
_logger.LogInformation("Gateway direct path: WAN {Down}/{Up} Mbps", wanDownloadMbps, wanUploadMbps);
@@ -794,7 +800,9 @@ public async Task<NetworkPath> CalculatePathToGatewayAsync(
794800
else if (!string.IsNullOrEmpty(currentMac) && currentPort.HasValue)
795801
{
796802
deviceHop.IngressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, currentMac, currentPort);
797-
if (deviceHop.IngressSpeedMbps == 0 && targetDevice.LocalUplinkPort.HasValue)
803+
// Skip for gateways: their LocalUplinkPort is the WAN port, not a LAN-side link.
804+
if (deviceHop.IngressSpeedMbps == 0 && targetDevice.LocalUplinkPort.HasValue
805+
&& targetDevice.Type != DeviceType.Gateway)
798806
{
799807
deviceHop.IngressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, targetDevice.Mac, targetDevice.LocalUplinkPort);
800808
}
@@ -867,8 +875,9 @@ public async Task<NetworkPath> CalculatePathToGatewayAsync(
867875
hop.EgressPort = device.UplinkPort;
868876
hop.EgressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, device.UplinkMac, device.UplinkPort);
869877
// If upstream device has no port table (e.g., AP with empty port_table),
870-
// fall back to local device's uplink port speed (same physical link, same negotiated speed)
871-
if (hop.EgressSpeedMbps == 0 && device.LocalUplinkPort.HasValue)
878+
// fall back to local device's uplink port speed (same physical link, same negotiated speed).
879+
// Skip for gateways: their LocalUplinkPort is the WAN port, not a LAN-side link.
880+
if (hop.EgressSpeedMbps == 0 && device.LocalUplinkPort.HasValue && !isGateway)
872881
{
873882
hop.EgressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, device.Mac, device.LocalUplinkPort);
874883
}
@@ -903,6 +912,9 @@ public async Task<NetworkPath> CalculatePathToGatewayAsync(
903912
// Enrich hops with device settings
904913
await EnrichDeviceSettingsAsync(path.Hops, rawDevices, cancellationToken);
905914

915+
// Annotate LAG membership on hop ports
916+
AnnotateLagMembership(path.Hops, rawDevices);
917+
906918
// Calculate bottleneck
907919
CalculateBottleneck(path);
908920

@@ -1293,6 +1305,78 @@ private async Task EnrichDeviceSettingsAsync(
12931305
}
12941306
}
12951307

1308+
/// <summary>
1309+
/// Annotate hops with LAG membership info by checking each hop's ingress/egress ports
1310+
/// against the device port tables. Purely additive - only sets new LAG fields, never
1311+
/// modifies existing hop data.
1312+
/// </summary>
1313+
private void AnnotateLagMembership(List<NetworkHop> hops, Dictionary<string, UniFiDeviceResponse> rawDevices)
1314+
{
1315+
foreach (var hop in hops)
1316+
{
1317+
if (string.IsNullOrEmpty(hop.DeviceMac) || !rawDevices.TryGetValue(hop.DeviceMac, out var device))
1318+
continue;
1319+
1320+
if (device.PortTable == null || device.PortTable.Count == 0)
1321+
continue;
1322+
1323+
// Check ingress port for LAG
1324+
if (hop.IngressPort.HasValue)
1325+
{
1326+
var lagInfo = GetLagMemberInfo(device.PortTable, hop.IngressPort.Value);
1327+
if (lagInfo.HasValue)
1328+
{
1329+
hop.IsLagIngress = true;
1330+
hop.LagIngressMemberCount = lagInfo.Value.MemberCount;
1331+
hop.LagIngressMemberSpeedMbps = lagInfo.Value.MemberSpeedMbps;
1332+
}
1333+
}
1334+
1335+
// Check egress port for LAG
1336+
if (hop.EgressPort.HasValue)
1337+
{
1338+
var lagInfo = GetLagMemberInfo(device.PortTable, hop.EgressPort.Value);
1339+
if (lagInfo.HasValue)
1340+
{
1341+
hop.IsLagEgress = true;
1342+
hop.LagEgressMemberCount = lagInfo.Value.MemberCount;
1343+
hop.LagEgressMemberSpeedMbps = lagInfo.Value.MemberSpeedMbps;
1344+
}
1345+
}
1346+
}
1347+
}
1348+
1349+
/// <summary>
1350+
/// Returns LAG member info for a port if it's part of a LAG group.
1351+
/// </summary>
1352+
private static (int MemberCount, int MemberSpeedMbps)? GetLagMemberInfo(List<SwitchPort> portTable, int portIdx)
1353+
{
1354+
var port = portTable.FirstOrDefault(p => p.PortIdx == portIdx);
1355+
if (port == null)
1356+
return null;
1357+
1358+
// Port is a LAG child
1359+
if (port.AggregatedBy.HasValue)
1360+
{
1361+
var parent = portTable.FirstOrDefault(p => p.PortIdx == port.AggregatedBy.Value);
1362+
var siblings = portTable.Where(p => p.AggregatedBy == port.AggregatedBy.Value).ToList();
1363+
var memberCount = siblings.Count + (parent != null ? 1 : 0);
1364+
var memberSpeed = parent?.Speed ?? siblings.FirstOrDefault(s => s.Up)?.Speed ?? 0;
1365+
return memberCount > 1 ? (memberCount, memberSpeed) : null;
1366+
}
1367+
1368+
// Port is a LAG parent
1369+
var children = portTable.Where(p => p.AggregatedBy == portIdx).ToList();
1370+
if (children.Count > 0)
1371+
{
1372+
var memberCount = children.Count + 1; // parent + children
1373+
var memberSpeed = port.Speed;
1374+
return (memberCount, memberSpeed);
1375+
}
1376+
1377+
return null;
1378+
}
1379+
12961380
/// <summary>
12971381
/// Gets the port speed for a specific port on a device.
12981382
/// Returns the LAG aggregate speed when the port is part of a Link Aggregation Group.
@@ -1637,8 +1721,10 @@ internal void BuildHopList(
16371721
// Wired uplink - get port speed from upstream switch
16381722
deviceHop.IngressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, currentMac, currentPort);
16391723
// If upstream device has no port table (e.g., AP with empty port_table),
1640-
// fall back to local device's uplink port speed (same physical link, same negotiated speed)
1641-
if (deviceHop.IngressSpeedMbps == 0 && targetDevice.LocalUplinkPort.HasValue)
1724+
// fall back to local device's uplink port speed (same physical link, same negotiated speed).
1725+
// Skip for gateways: their LocalUplinkPort is the WAN port, not a LAN-side link.
1726+
if (deviceHop.IngressSpeedMbps == 0 && targetDevice.LocalUplinkPort.HasValue
1727+
&& targetDevice.Type != DeviceType.Gateway)
16421728
{
16431729
deviceHop.IngressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, targetDevice.Mac, targetDevice.LocalUplinkPort);
16441730
}
@@ -1993,8 +2079,9 @@ internal void BuildHopList(
19932079
hop.EgressPort = device.UplinkPort;
19942080
hop.EgressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, device.UplinkMac, device.UplinkPort);
19952081
// If upstream device has no port table (e.g., AP with empty port_table),
1996-
// fall back to local device's uplink port speed (same physical link, same negotiated speed)
1997-
if (hop.EgressSpeedMbps == 0 && device.LocalUplinkPort.HasValue)
2082+
// fall back to local device's uplink port speed (same physical link, same negotiated speed).
2083+
// Skip for gateways: their LocalUplinkPort is the WAN port, not a LAN-side link.
2084+
if (hop.EgressSpeedMbps == 0 && device.LocalUplinkPort.HasValue && !isGateway)
19982085
{
19992086
hop.EgressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, device.Mac, device.LocalUplinkPort);
20002087
}

src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -887,17 +887,31 @@
887887
if (isWirelessLink)
888888
return GetWifiTooltip(hop, nextHop, isBottleneckLink);
889889

890+
var parts = new List<string>();
891+
892+
// LAG info - check egress of current hop or ingress of next hop
893+
var lagMemberCount = hop.LagEgressMemberCount ?? nextHop.LagIngressMemberCount;
894+
var lagMemberSpeed = hop.LagEgressMemberSpeedMbps ?? nextHop.LagIngressMemberSpeedMbps;
895+
if (lagMemberCount.HasValue && lagMemberSpeed.HasValue)
896+
{
897+
var memberSpeedStr = lagMemberSpeed.Value >= 1000
898+
? $"{lagMemberSpeed.Value / 1000.0:G} Gbps"
899+
: $"{lagMemberSpeed.Value} Mbps";
900+
parts.Add($"<div class='device-tooltip-row'><span class='device-tooltip-label'>Link Aggregation:</span> {lagMemberCount.Value}x {memberSpeedStr}</div>");
901+
}
902+
903+
// SQM info for WAN links
890904
var wanHop = hop.Type == HopType.Wan ? hop : (nextHop.Type == HopType.Wan ? nextHop : null);
891905
if (wanHop?.SmartQueueEnabled.HasValue == true)
892906
{
893907
var sqmStatus = wanHop.SmartQueueEnabled!.Value ? "Enabled" : "Disabled";
894-
var sqmLine = $"<div class='device-tooltip-row'><span class='device-tooltip-label'>Smart Queues:</span> {sqmStatus}</div>";
895-
if (isBottleneckLink)
896-
return sqmLine + "<div class='wifi-tooltip-bottleneck'>Bottleneck: slowest link in path</div>";
897-
return sqmLine;
908+
parts.Add($"<div class='device-tooltip-row'><span class='device-tooltip-label'>Smart Queues:</span> {sqmStatus}</div>");
898909
}
899910

900-
return isBottleneckLink ? "Bottleneck: slowest link in path" : null;
911+
if (isBottleneckLink)
912+
parts.Add("<div class='wifi-tooltip-bottleneck'>Bottleneck: slowest link in path</div>");
913+
914+
return parts.Count > 0 ? string.Join("", parts) : null;
901915
}
902916

903917
/// <summary>

0 commit comments

Comments
 (0)