M28: Übersicht Fokus-Modus + einheitliches Close-Button-Layout

Klick auf einen Knoten in der Übersicht öffnet jetzt ein schwebendes
Popup mit der kompletten verbundenen Kette (neu OverviewGraph.
connectedChain, volle transitive Hülle unabhängig vom Knotentyp,
bewusst getrennt von der bestehenden highlightedNodeIDs), sauber im
selben Spalten-Layout neu angeordnet, Rest des Diagramms abgedunkelt.
Nicht-modales Overlay statt .sheet, damit die rechte Seitenleiste
währenddessen bedienbar bleibt (Knoten direkt aus dem Popup heraus
bearbeitbar). Popup-Größe passt sich automatisch dem Inhalt an, keine
Scrollbalken.

Der dabei entstandene Close-Button-Header (Titel + Spacer + X, fest
oben, Divider direkt darunter) wurde auf alle vier Popup-Formulare der
App vereinheitlicht: Experte-Bearbeiten-Sheet und die drei
Devices-Sheets (Rohdaten, Netzwerk-Test, Port-Scan) — dort ersetzt er
jeweils den bisherigen einzelnen "Schließen"-Button unten.

Mehrere Design-Iterationen live mit dem User durchgespielt (Trennlinie
im Canvas → separates Panel → Popup → .sheet → non-modales Overlay),
finale Version live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-16 21:46:39 +02:00
co-authored by Claude Sonnet 5
parent 684e1c184a
commit c9671b017c
9 changed files with 380 additions and 66 deletions
@@ -209,4 +209,33 @@ final class OverviewGraphTests: XCTestCase {
XCTAssertEqual(graph.highlightedNodeIDs(startingAt: poolID), [poolID])
XCTAssertEqual(graph.highlightedNodeIDs(startingAt: addressID), [addressID])
}
/// `connectedChain` backs the Übersicht "Fokus-Modus" and, unlike `highlightedNodeIDs`, always
/// walks the full transitive closure regardless of node category starting from the pool
/// (two hops from the interface) should still reach every other node in the same graph used
/// above.
func testConnectedChainExpandsTransitivelyForEveryNodeKind() {
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"
let fullChain: Set<String> = [interfaceID, addressID, dhcpServerID, poolID, dhcpNetworkID]
XCTAssertEqual(graph.connectedChain(startingAt: poolID), fullChain)
XCTAssertEqual(graph.connectedChain(startingAt: dhcpNetworkID), fullChain)
XCTAssertEqual(graph.connectedChain(startingAt: interfaceID), fullChain)
XCTAssertEqual(graph.connectedChain(startingAt: "does-not-exist"), [])
}
}