forked from kay/RouterOS
Ü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:
+16
@@ -873,3 +873,19 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
|
|||||||
Backend-/Laufzeit-Text von RouterOS oder app-eigenen Fehlerpfaden
|
Backend-/Laufzeit-Text von RouterOS oder app-eigenen Fehlerpfaden
|
||||||
(technische/englische Ausgaben, keine kuratierten UI-Strings).
|
(technische/englische Ausgaben, keine kuratierten UI-Strings).
|
||||||
HANDOFF.md/README.md/Manual.md aktualisiert, Commit + Push.
|
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
@@ -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
|
Knoten zusammen, kein 1:1-Bezug zu einem Router-Item, bräuchte eine
|
||||||
andere UI (Liste statt Formular).
|
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
|
Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe
|
||||||
oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15.
|
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)",
|
"System (Name/Uhrzeit/Scheduler/Skripte/Benutzerkonten)", "Werkzeuge (Netwatch/E-Mail)",
|
||||||
"Firewall: Mangle- und Raw-Regeln"
|
"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.
|
/// wins" order made the highlight jump to whatever card the cursor grazed en route.
|
||||||
private var highlightNodeID: String? { viewModel.selectedNodeID ?? hoveredNodeID }
|
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) {
|
init(connectionService: ConnectionService) {
|
||||||
self.connectionService = connectionService
|
self.connectionService = connectionService
|
||||||
_viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService))
|
_viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService))
|
||||||
@@ -143,7 +150,7 @@ struct OverviewView: View {
|
|||||||
EdgesCanvas(
|
EdgesCanvas(
|
||||||
graph: viewModel.graph,
|
graph: viewModel.graph,
|
||||||
positions: layout.positions,
|
positions: layout.positions,
|
||||||
highlightNodeID: highlightNodeID,
|
highlightedNodeIDs: highlightedNodeIDs,
|
||||||
hoveredEdge: $hoveredEdge,
|
hoveredEdge: $hoveredEdge,
|
||||||
hoverPoint: $hoverPoint,
|
hoverPoint: $hoverPoint,
|
||||||
selectedEdge: $selectedEdge
|
selectedEdge: $selectedEdge
|
||||||
@@ -223,7 +230,7 @@ struct OverviewView: View {
|
|||||||
private struct EdgesCanvas: View {
|
private struct EdgesCanvas: View {
|
||||||
let graph: OverviewGraph
|
let graph: OverviewGraph
|
||||||
let positions: [String: CGPoint]
|
let positions: [String: CGPoint]
|
||||||
let highlightNodeID: String?
|
let highlightedNodeIDs: Set<String>
|
||||||
@Binding var hoveredEdge: OverviewEdge?
|
@Binding var hoveredEdge: OverviewEdge?
|
||||||
@Binding var hoverPoint: CGPoint
|
@Binding var hoverPoint: CGPoint
|
||||||
@Binding var selectedEdge: OverviewEdge?
|
@Binding var selectedEdge: OverviewEdge?
|
||||||
@@ -296,8 +303,8 @@ private struct EdgesCanvas: View {
|
|||||||
Canvas { context, _ in
|
Canvas { context, _ in
|
||||||
for geometry in geometries {
|
for geometry in geometries {
|
||||||
let edge = geometry.edge
|
let edge = geometry.edge
|
||||||
let isConnected = highlightNodeID != nil && (edge.from == highlightNodeID || edge.to == highlightNodeID)
|
let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to)
|
||||||
let isDimmed = highlightNodeID != nil && !isConnected
|
let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected
|
||||||
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
|
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
|
||||||
var path = Path()
|
var path = Path()
|
||||||
path.move(to: geometry.start)
|
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
|
/// 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.
|
/// things down to one node's connections, hover only needs to disambiguate between those.
|
||||||
private var candidateGeometries: [EdgeGeometry] {
|
private var candidateGeometries: [EdgeGeometry] {
|
||||||
guard let highlightNodeID else { return geometries }
|
guard !highlightedNodeIDs.isEmpty else { return geometries }
|
||||||
return geometries.filter { $0.edge.from == highlightNodeID || $0.edge.to == highlightNodeID }
|
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
|
/// 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" }
|
let route = graph.nodes.first { $0.id == "route:0" }
|
||||||
XCTAssertNil(route?.editTarget)
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user