From 9b26677dc210c6a655e86c92e39e70c05737eaba Mon Sep 17 00:00:00 2001 From: Kay Date: Tue, 15 Sep 2026 19:58:05 +0200 Subject: [PATCH] =?UTF-8?q?=C3=9Cbersicht:=20Interface-Klick=20hebt=20tran?= =?UTF-8?q?sitiv=20alle=20zusammenh=C3=A4ngenden=20Linien=20hervor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverviewGraph.highlightedNodeIDs(startingAt:) macht für Interface-Knoten eine BFS über alle Kanten statt nur 1-Hop-Matching, damit z.B. "DHCP-Server -> Pool" oder "IP-Adresse -> DHCP-Netzwerk" mit sichtbar werden. Andere Knotentypen bleiben unverändert bei 1-Hop. Logik isoliert unit-getestet (GUI selbst nicht automatisiert klickbar). 61 Tests grün. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG --- CHATLOG.md | 16 +++++++++ HANDOFF.md | 29 +++++++++++++++ .../Core/Models/OverviewGraph.swift | 29 +++++++++++++++ .../Features/Overview/OverviewView.swift | 19 ++++++---- .../OverviewGraphTests.swift | 36 +++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/CHATLOG.md b/CHATLOG.md index 2cb2a24..94390bb 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -873,3 +873,19 @@ Test, WLAN/Bonding/PPPoE-Live-Tests). Backend-/Laufzeit-Text von RouterOS oder app-eigenen Fehlerpfaden (technische/englische Ausgaben, keine kuratierten UI-Strings). HANDOFF.md/README.md/Manual.md aktualisiert, Commit + Push. +- "nochmal zum Tab Übersicht, nur eine Idee: wenn ich einen Interface- + Button klicke, möchte ich ALLE Linien sehen..." → `OverviewGraph. + highlightedNodeIDs(startingAt:)` ergänzt: für Interface-Knoten eine + BFS über alle Kanten (beide Richtungen), für jeden anderen Knotentyp + unverändert nur 1-Hop. "teste die Interface-Verbindung im Übersicht- + Tab" → da die laufende GUI nicht selbst klickbar ist, die BFS-Logik in + `OverviewGraphTests.swift` isoliert getestet (Interface→Adresse→DHCP- + Netzwerk und Interface→DHCP-Server→Pool, inkl. Sanity-Check, dass Pool/ + DHCP-Netzwerk wirklich zwei Hops entfernt liegen). 61 Tests grün. + "ich räume den Router mal auf - werksreset" / "ist durch, funktioniert" + → HANDOFF.md aktualisiert: Router am 2026-09-15 erneut auf + Werkseinstellungen, alle alten Config-Stände hinfällig. "alles + speichern bitte" → HANDOFF.md/CHATLOG.md aktualisiert, Commit + Push — + visuelle Live-Prüfung des Diagramms selbst steht noch aus (nur die + Logik ist getestet), auf ausdrücklichen Nutzerwunsch trotzdem + gespeichert. diff --git a/HANDOFF.md b/HANDOFF.md index fc02f2d..22886b5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1160,6 +1160,35 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und Knoten zusammen, kein 1:1-Bezug zu einem Router-Item, bräuchte eine andere UI (Liste statt Formular). +12. **Übersicht-Tab: Interface-Klick hebt jetzt transitiv alle + zusammenhängenden Linien hervor, nicht nur die direkt anliegenden** + — Nutzeridee: "wenn ich einen Interface-Button klicke, möchte ich + ALLE Linien sehen, die mit dem Interface irgendwie zusammenhängen". + Bisher galt für jeden Knotentyp dieselbe 1-Hop-Regel (nur Kanten, die + den geklickten Knoten selbst berühren) — bei einem Interface reißt + das z.B. die Kette "DHCP-Server → sein Pool" oder "IP-Adresse → + DHCP-Netzwerk-Optionen" ab, da diese Kanten nicht am Interface selbst + hängen. Fix: `OverviewGraph.highlightedNodeIDs(startingAt:)` — für + jeden Knotentyp außer Interface unverändert nur der eine Knoten + selbst, für ein Interface eine Breitensuche (BFS) über alle Kanten in + beide Richtungen, die den kompletten zusammenhängenden Ast einsammelt + (Interface → Adresse/DHCP-Server/Route/Firewall-Regel, und von dort + weiter → Pool bzw. DHCP-Netzwerk). Ausdrücklich nur für Interfaces so + gewollt — alle anderen Knotentypen bleiben bei der bisherigen 1-Hop- + Regel. Da ich die laufende App-GUI nicht selbst anklicken kann, die + BFS-Logik stattdessen isoliert in `OverviewGraphTests.swift` getestet + (`testHighlightedNodeIDsExpandsTransitivelyOnlyForInterfaces`): + baut einen Graphen mit Interface→Adresse→DHCP-Netzwerk und + Interface→DHCP-Server→Pool auf, bestätigt zunächst per Sanity-Check, + dass Pool und DHCP-Netzwerk tatsächlich zwei Hops entfernt sind (kein + direkter Edge zum Interface), und prüft dann, dass + `highlightedNodeIDs(startingAt:)` vom Interface aus alle fünf Knoten + liefert, aber vom Pool oder von der Adresse aus nur sich selbst. 61 + Tests grün (60 + 1 neu). **Visuelle Live-Prüfung im laufenden + Diagramm steht noch aus** — vom Nutzer noch nicht bestätigt, nur die + Logik selbst ist getestet; auf Nutzerwunsch trotzdem bereits + committed+gepusht ("alles speichern bitte"). + Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15. diff --git a/RouterOSAssistant/Core/Models/OverviewGraph.swift b/RouterOSAssistant/Core/Models/OverviewGraph.swift index c595b33..26f141d 100644 --- a/RouterOSAssistant/Core/Models/OverviewGraph.swift +++ b/RouterOSAssistant/Core/Models/OverviewGraph.swift @@ -95,4 +95,33 @@ struct OverviewGraph: Equatable { "System (Name/Uhrzeit/Scheduler/Skripte/Benutzerkonten)", "Werkzeuge (Netwatch/E-Mail)", "Firewall: Mangle- und Raw-Regeln" ] + + /// Node IDs whose edges the Übersicht diagram should draw at full strength when `nodeID` is + /// selected/hovered. Normally just `nodeID` itself (one-hop matching against `edges`) — but + /// for an Interface node specifically, the user asked to see *every* line that traces back to + /// it, not just the ones directly touching it (e.g. a DHCP server's own pool, or an IP + /// address's DHCP-network options, sit a second hop away from the interface itself). Confirmed + /// scope (2026-09-15): only interfaces expand this way — every other node kind keeps the + /// original one-hop highlight. + func highlightedNodeIDs(startingAt nodeID: String) -> Set { + guard let node = nodes.first(where: { $0.id == nodeID }), node.category == .interface else { + return [nodeID] + } + var visited: Set = [nodeID] + var frontier = [nodeID] + while !frontier.isEmpty { + var next: [String] = [] + for current in frontier { + for edge in edges { + if edge.from == current, visited.insert(edge.to).inserted { + next.append(edge.to) + } else if edge.to == current, visited.insert(edge.from).inserted { + next.append(edge.from) + } + } + } + frontier = next + } + return visited + } } diff --git a/RouterOSAssistant/Features/Overview/OverviewView.swift b/RouterOSAssistant/Features/Overview/OverviewView.swift index bdb58ac..c7b93cb 100644 --- a/RouterOSAssistant/Features/Overview/OverviewView.swift +++ b/RouterOSAssistant/Features/Overview/OverviewView.swift @@ -30,6 +30,13 @@ struct OverviewView: View { /// wins" order made the highlight jump to whatever card the cursor grazed en route. private var highlightNodeID: String? { viewModel.selectedNodeID ?? hoveredNodeID } + /// Node IDs whose edges should draw at full strength — see `OverviewGraph.highlightedNodeIDs` + /// for the one-hop-vs-interface-transitive-closure logic itself. + private var highlightedNodeIDs: Set { + guard let id = highlightNodeID else { return [] } + return viewModel.graph.highlightedNodeIDs(startingAt: id) + } + init(connectionService: ConnectionService) { self.connectionService = connectionService _viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService)) @@ -143,7 +150,7 @@ struct OverviewView: View { EdgesCanvas( graph: viewModel.graph, positions: layout.positions, - highlightNodeID: highlightNodeID, + highlightedNodeIDs: highlightedNodeIDs, hoveredEdge: $hoveredEdge, hoverPoint: $hoverPoint, selectedEdge: $selectedEdge @@ -223,7 +230,7 @@ struct OverviewView: View { private struct EdgesCanvas: View { let graph: OverviewGraph let positions: [String: CGPoint] - let highlightNodeID: String? + let highlightedNodeIDs: Set @Binding var hoveredEdge: OverviewEdge? @Binding var hoverPoint: CGPoint @Binding var selectedEdge: OverviewEdge? @@ -296,8 +303,8 @@ private struct EdgesCanvas: View { Canvas { context, _ in for geometry in geometries { let edge = geometry.edge - let isConnected = highlightNodeID != nil && (edge.from == highlightNodeID || edge.to == highlightNodeID) - let isDimmed = highlightNodeID != nil && !isConnected + let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to) + let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id var path = Path() path.move(to: geometry.start) @@ -335,8 +342,8 @@ private struct EdgesCanvas: View { /// hovering feel like it kept jumping to the wrong line. Once the user has already narrowed /// things down to one node's connections, hover only needs to disambiguate between those. private var candidateGeometries: [EdgeGeometry] { - guard let highlightNodeID else { return geometries } - return geometries.filter { $0.edge.from == highlightNodeID || $0.edge.to == highlightNodeID } + guard !highlightedNodeIDs.isEmpty else { return geometries } + return geometries.filter { highlightedNodeIDs.contains($0.edge.from) || highlightedNodeIDs.contains($0.edge.to) } } /// Several edges often fan out from the exact same point (e.g. every edge leaving one diff --git a/RouterOSAssistantTests/OverviewGraphTests.swift b/RouterOSAssistantTests/OverviewGraphTests.swift index e082a28..0699f69 100644 --- a/RouterOSAssistantTests/OverviewGraphTests.swift +++ b/RouterOSAssistantTests/OverviewGraphTests.swift @@ -173,4 +173,40 @@ final class OverviewGraphTests: XCTestCase { let route = graph.nodes.first { $0.id == "route:0" } XCTAssertNil(route?.editTarget) } + + /// Clicking an Interface should highlight every line that traces back to it, not just the + /// ones directly touching it — e.g. a DHCP server's own pool, or an IP address's DHCP-network + /// options, sit a second hop away from the interface itself. Confirmed scope (2026-09-15): + /// only interfaces expand this way — every other node kind stays one-hop-only. + func testHighlightedNodeIDsExpandsTransitivelyOnlyForInterfaces() { + let graph = OverviewViewModel.buildGraph( + interfaces: [item(["name": "ether1", "type": "ether"])], + bridgePorts: [], vlans: [], wireguardPeers: [], + addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"])], + pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])], + dhcpServers: [item(["name": "dhcp1", "interface": "ether1", "address-pool": "pool1"])], + dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])], + dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] + ) + + let interfaceID = "Interfaces:ether1" + let addressID = "IP-Adressen:192.168.88.1/24" + let dhcpServerID = "Pools & DHCP:dhcp:dhcp1" + let poolID = "Pools & DHCP:pool:pool1" + let dhcpNetworkID = "Pools & DHCP:dhcpnet:192.168.88.0/24" + + // Sanity: the pool and the DHCP-network options really are two hops away from the + // interface (no direct edge to either) — otherwise this test wouldn't distinguish + // transitive-closure behavior from plain one-hop matching. + XCTAssertFalse(graph.edges.contains { $0.from == interfaceID && $0.to == poolID }) + XCTAssertFalse(graph.edges.contains { $0.from == interfaceID && $0.to == dhcpNetworkID }) + + let fromInterface = graph.highlightedNodeIDs(startingAt: interfaceID) + XCTAssertEqual(fromInterface, [interfaceID, addressID, dhcpServerID, poolID, dhcpNetworkID]) + + // A non-interface node (e.g. the pool) stays one-hop-only: just itself, regardless of + // what it's connected to. + XCTAssertEqual(graph.highlightedNodeIDs(startingAt: poolID), [poolID]) + XCTAssertEqual(graph.highlightedNodeIDs(startingAt: addressID), [addressID]) + } }