Files
RouterOS/RouterOSAssistant/Core/Models/OverviewGraph.swift
T
KayandClaude Sonnet 5 c9671b017c 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>
2026-09-16 21:46:39 +02:00

154 lines
6.8 KiB
Swift

import Foundation
/// One box in the Übersicht diagram — an interface, IP address, DHCP server, firewall rule etc.
/// `kind` is a finer-grained tag than `category` (e.g. "vlan" vs "bridge" within the interface
/// category) used only to pick an icon/color, never for graph logic.
struct OverviewNode: Identifiable, Equatable {
enum Category: String, CaseIterable, Identifiable {
case interface = "Interfaces"
case ipAddress = "IP-Adressen"
case service = "Pools & DHCP"
case route = "Routen"
case firewall = "Firewall & NAT"
var id: String { rawValue }
}
/// Which live RouterOS item this node was built from, and where to write changes back to —
/// nil for nodes that don't map to exactly one editable item (e.g. an address-list node folds
/// several list entries together). Lets the Übersicht tab open the same edit form the
/// Experte-Tab uses, pre-filled, instead of duplicating its field schemas/write logic.
struct EditTarget: Equatable {
let menuPath: String
let restPath: String
/// RouterOS' own ".id" for this item (e.g. "*7") — required to `set` the right one.
let itemID: String
}
let id: String
let category: Category
let kind: String
let title: String
let subtitle: String?
/// Full raw fields, shown in the detail panel when this node is selected.
let detail: [(key: String, value: String)]
let editTarget: EditTarget?
init(
id: String, category: Category, kind: String, title: String, subtitle: String?,
detail: [(key: String, value: String)], editTarget: EditTarget? = nil
) {
self.id = id
self.category = category
self.kind = kind
self.title = title
self.subtitle = subtitle
self.detail = detail
self.editTarget = editTarget
}
static func == (lhs: OverviewNode, rhs: OverviewNode) -> Bool {
lhs.id == rhs.id && lhs.category == rhs.category && lhs.kind == rhs.kind
&& lhs.title == rhs.title && lhs.subtitle == rhs.subtitle
&& lhs.detail.map(\.key) == rhs.detail.map(\.key) && lhs.detail.map(\.value) == rhs.detail.map(\.value)
&& lhs.editTarget == rhs.editTarget
}
}
/// What kind of dependency an edge represents — drives its color in the diagram, so different
/// relationship types (a VLAN's parent link vs. a firewall rule's interface) are visually
/// distinct at a glance, not just distinguishable by hovering.
enum OverviewEdgeKind: String, CaseIterable, Identifiable {
case vlan = "VLAN → Basis-Interface"
case bridgePort = "Bridge-Port"
case wireguardPeer = "WireGuard-Peer"
case ipAddress = "Interface → IP-Adresse"
case dhcp = "DHCP / Pool"
case route = "Route → Interface"
case firewallInterface = "Firewall/NAT → Interface"
case addressList = "Adress-Liste → Regel"
var id: String { rawValue }
}
/// One line in the diagram, always drawn from an earlier column to a later one (or within a
/// column) — direction follows the actual dependency (e.g. an interface "carries" an IP address,
/// a DHCP server "uses" a pool), not just which menu was read first.
struct OverviewEdge: Identifiable, Equatable {
var id: String { "\(from)->\(to)>\(label ?? "")" }
let from: String
let to: String
let label: String?
let kind: OverviewEdgeKind
}
struct OverviewGraph: Equatable {
var nodes: [OverviewNode] = []
var edges: [OverviewEdge] = []
/// Router areas that exist but aren't part of this topology diagram (VPN-Benutzer, WLAN-
/// Sicherheitsprofile, Queues, System, Werkzeuge — none of them wire into the interface/IP/
/// firewall dependency chain this diagram is about). Listed so nothing is silently hidden;
/// full access to all of them stays in the Experte-Tab.
static let unmappedAreas = [
"VPN: PPP-Benutzer/-Profile", "WLAN-Sicherheitsprofile", "Queues/Bandbreiten-Steuerung",
"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
}
/// Every node reachable from `nodeID` by following edges in either direction, regardless of
/// category — used by the Übersicht "Fokus-Modus" (`OverviewView`) to decide which nodes move
/// into their own strip below a divider when a node is clicked. Deliberately separate from
/// `highlightedNodeIDs`: that one only expands past one hop for interface nodes (a distinct,
/// already-live-confirmed scope for edge-brightness highlighting), while the focus strip is
/// meant to always show the complete parent/child chain of whatever was clicked.
func connectedChain(startingAt nodeID: String) -> Set<String> {
guard nodes.contains(where: { $0.id == nodeID }) else { return [] }
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
}
}