Übersicht: Interface-Klick hebt transitiv alle zusammenhängenden Linien hervor

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
Kay
2026-09-15 19:58:05 +02:00
co-authored by Claude Sonnet 5
parent ba6603ba64
commit 9b26677dc2
5 changed files with 123 additions and 6 deletions
+16
View File
@@ -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.
+29
View File
@@ -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.
@@ -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<String> {
guard let node = nodes.first(where: { $0.id == nodeID }), node.category == .interface else {
return [nodeID]
}
var visited: Set<String> = [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
}
}
@@ -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<String> {
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<String>
@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
@@ -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])
}
}