M11: Übersicht-Tab — IST-Zustand-Diagramm des Routers

Neuer Tab zeigt die komplette aktuelle Router-Konfiguration als grafisches
Diagramm: Interfaces, IP-Adressen, DHCP/Pools, Routen und Firewall/NAT in
Spalten, verbunden durch Linien, die RouterOS' eigene Referenzfelder
abbilden (VLAN->Basis-Interface, DHCP-Server->Pool, Firewall-Regel->
Interface/Adress-Liste usw.), nicht geraten. Rein lesend, kein apply().

Verbindungsarten sind farblich getrennt (8 Kategorien), Hover/Klick auf
eine Karte hebt ihre Linien hervor und blendet den Rest ab. Detail-Panel
zeigt Rohfelder + Verbindungen; Legende erklärt Spalten, Farben und listet
bewusst nicht gegraphte Bereiche (VPN, WLAN-Sicherheitsprofile, Queues,
System, Werkzeuge, Mangle/Raw), die weiterhin im Experte-Tab erreichbar
bleiben.

OverviewViewModel.buildGraph ist eine reine, nonisolated Funktion,
getestet in OverviewGraphTests (6 Fälle: IP/Interface, VLAN-Parent,
DHCP->Pool, DHCP-Netzwerk->passende IP-Adresse über RouterOS' eigenes
"network"-Feld, Route nur bei Interface-Gateway, Firewall->Interface/
Adress-Liste).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
This commit is contained in:
Kay
2026-09-14 10:05:39 +02:00
co-authored by Claude Sonnet 5
parent c9ecd3a0a9
commit 10f30a8a7f
6 changed files with 852 additions and 0 deletions
@@ -11,6 +11,8 @@ struct RouterOSAssistantApp: App {
.tabItem { Label("Verbinden", systemImage: "network") }
SetupView(connectionService: connectionService)
.tabItem { Label("Einrichten", systemImage: "checklist") }
OverviewView(connectionService: connectionService)
.tabItem { Label("Übersicht", systemImage: "point.3.connected.trianglepath.dotted") }
ExpertView(connectionService: connectionService)
.tabItem { Label("Experte", systemImage: "wrench.and.screwdriver") }
BackupListView(connectionService: connectionService)
@@ -0,0 +1,72 @@
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 }
}
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)]
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)
}
}
/// 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"
]
}
@@ -0,0 +1,41 @@
import CoreGraphics
/// Fixed column/row grid layout for the Übersicht diagram pure geometry, no SwiftUI. Columns
/// follow the dependency order left-to-right (interfaces are the foundation everything else
/// attaches to); nodes within a column are stacked top-to-bottom, sorted so repeated runs render
/// in a stable order across reloads.
struct OverviewLayoutResult {
var positions: [String: CGPoint] = [:]
var canvasSize: CGSize = .zero
}
enum OverviewLayout {
static let columnOrder: [OverviewNode.Category] = [.interface, .ipAddress, .service, .route, .firewall]
static let columnWidth: CGFloat = 220
static let rowHeight: CGFloat = 64
static let nodeWidth: CGFloat = 180
static let nodeHeight: CGFloat = 50
static let topInset: CGFloat = 44
static let leftInset: CGFloat = 40
static func layout(for graph: OverviewGraph) -> OverviewLayoutResult {
var positions: [String: CGPoint] = [:]
var maxRows = 0
for (columnIndex, category) in columnOrder.enumerated() {
let nodesInColumn = graph.nodes
.filter { $0.category == category }
.sorted { lhs, rhs in
lhs.kind == rhs.kind ? lhs.title < rhs.title : lhs.kind < rhs.kind
}
maxRows = max(maxRows, nodesInColumn.count)
let x = leftInset + CGFloat(columnIndex) * columnWidth + nodeWidth / 2
for (row, node) in nodesInColumn.enumerated() {
let y = topInset + CGFloat(row) * rowHeight + nodeHeight / 2
positions[node.id] = CGPoint(x: x, y: y)
}
}
let width = leftInset * 2 + CGFloat(columnOrder.count) * columnWidth
let height = topInset * 2 + CGFloat(max(maxRows, 1)) * rowHeight
return OverviewLayoutResult(positions: positions, canvasSize: CGSize(width: width, height: height))
}
}
@@ -0,0 +1,355 @@
import SwiftUI
/// "Übersicht" tab: read-only diagram of the connected router's complete current configuration
/// (IST-Zustand) interfaces, IP addresses, DHCP/pools, routes and firewall/NAT, connected by
/// lines that follow the router's own reference fields. For someone handed a router with unknown
/// existing config, this is meant to answer "what's actually set up here" at a glance, before
/// diving into the Experte-Tab to change anything.
struct OverviewView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: OverviewViewModel
@State private var scale: CGFloat = 1.0
@State private var hoveredNodeID: String?
/// Hover wins over a click while the mouse is over a node, so sweeping across the diagram
/// previews each node's connections without needing to click first; the last click still
/// "sticks" once the mouse moves away, keeping the detail panel showing what was selected.
private var highlightNodeID: String? { hoveredNodeID ?? viewModel.selectedNodeID }
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService))
}
private var layout: OverviewLayoutResult { OverviewLayout.layout(for: viewModel.graph) }
var body: some View {
NavigationStack {
Group {
if connectionService.credentials == nil {
ContentUnavailableView(
"Nicht verbunden",
systemImage: "network.slash",
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
)
} else {
HSplitView {
diagramArea
.frame(minWidth: 480, minHeight: 320)
detailPanel
.frame(minWidth: 260, idealWidth: 300, maxWidth: 380)
}
}
}
.navigationTitle("Übersicht")
.toolbar {
ToolbarItemGroup {
if viewModel.isLoading {
ProgressView().controlSize(.small)
}
Button {
Task { await viewModel.load() }
} label: {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
.disabled(viewModel.isLoading)
Button { scale = max(0.4, scale - 0.15) } label: { Image(systemName: "minus.magnifyingglass") }
Button { scale = 1.0 } label: { Text("100%") }
Button { scale = min(2.0, scale + 0.15) } label: { Image(systemName: "plus.magnifyingglass") }
}
}
.task {
if viewModel.graph.nodes.isEmpty {
await viewModel.load()
}
}
}
}
@ViewBuilder
private var diagramArea: some View {
if viewModel.isLoading && viewModel.graph.nodes.isEmpty {
ProgressView("Lade Router-Konfiguration…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.loadError, viewModel.graph.nodes.isEmpty {
ContentUnavailableView(
"Konnte Konfiguration nicht laden", systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else if viewModel.graph.nodes.isEmpty {
ContentUnavailableView(
"Keine Konfiguration gefunden", systemImage: "square.dashed",
description: Text("Der Router meldet aktuell keine Interfaces/Adressen.")
)
} else {
ScrollView([.horizontal, .vertical]) {
ZStack(alignment: .topLeading) {
EdgesCanvas(graph: viewModel.graph, positions: layout.positions, highlightNodeID: highlightNodeID)
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
Text(category.rawValue)
.font(.headline)
.foregroundStyle(.secondary)
.position(
x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2,
y: 14
)
}
ForEach(viewModel.graph.nodes) { node in
if let point = layout.positions[node.id] {
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
isHovered: node.id == hoveredNodeID
)
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
.position(point)
.onTapGesture {
viewModel.selectedNodeID = (viewModel.selectedNodeID == node.id) ? nil : node.id
}
.onHover { isHovering in
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
}
}
}
}
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
.scaleEffect(scale, anchor: .topLeading)
.frame(width: layout.canvasSize.width * scale, height: layout.canvasSize.height * scale)
.padding()
}
}
}
@ViewBuilder
private var detailPanel: some View {
if let id = viewModel.selectedNodeID, let node = viewModel.graph.nodes.first(where: { $0.id == id }) {
NodeDetailView(node: node, graph: viewModel.graph)
} else {
LegendView()
}
}
}
/// Draws every edge as a bezier curve, colored by its `kind` (see `OverviewStyle.color(for:)`)
/// so the different dependency types are visually separated even with nothing selected. Hovering
/// or clicking a node pushes its own edges to full strength and fades every other edge down to
/// a hint, so "what connects to this" reads instantly in a busy diagram.
private struct EdgesCanvas: View {
let graph: OverviewGraph
let positions: [String: CGPoint]
let highlightNodeID: String?
var body: some View {
Canvas { context, _ in
for edge in graph.edges {
guard let from = positions[edge.from], let to = positions[edge.to] else { continue }
let isConnected = highlightNodeID != nil && (edge.from == highlightNodeID || edge.to == highlightNodeID)
let isDimmed = highlightNodeID != nil && !isConnected
let start = CGPoint(x: from.x + OverviewLayout.nodeWidth / 2, y: from.y)
let end = CGPoint(x: to.x - OverviewLayout.nodeWidth / 2, y: to.y)
let controlOffset = max(abs(end.x - start.x) / 2, 30)
var path = Path()
path.move(to: start)
path.addCurve(
to: end,
control1: CGPoint(x: start.x + controlOffset, y: start.y),
control2: CGPoint(x: end.x - controlOffset, y: end.y)
)
let baseColor = OverviewStyle.color(for: edge.kind)
context.stroke(
path,
with: .color(baseColor.opacity(isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6))),
lineWidth: isConnected ? 2.6 : 1.3
)
}
}
}
}
private struct NodeCardView: View {
let node: OverviewNode
let isSelected: Bool
let isHovered: Bool
var body: some View {
VStack(spacing: 2) {
HStack(spacing: 4) {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
Text(node.title)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
if let subtitle = node.subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(6)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 8).fill(.background))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(
isSelected || isHovered ? Color.accentColor : OverviewStyle.color(for: node.category).opacity(0.5),
lineWidth: isSelected || isHovered ? 2 : 1
)
)
.shadow(color: .black.opacity(isHovered ? 0.22 : 0.08), radius: isHovered ? 4 : 1, y: isHovered ? 2 : 1)
.scaleEffect(isHovered ? 1.05 : 1.0)
.animation(.easeOut(duration: 0.12), value: isHovered)
.contentShape(Rectangle())
}
}
private struct NodeDetailView: View {
let node: OverviewNode
let graph: OverviewGraph
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
Text(node.title).font(.title3.bold())
}
Text(node.category.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
if !node.detail.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 4) {
ForEach(node.detail, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
}
}
}
}
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
if !connected.isEmpty {
Divider()
Text("Verbindungen").font(.subheadline.bold())
ForEach(connected) { edge in
let otherID = edge.from == node.id ? edge.to : edge.from
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
Label(
edge.label.map { "\(otherTitle) (\($0))" } ?? otherTitle,
systemImage: edge.from == node.id ? "arrow.right" : "arrow.left"
)
.foregroundStyle(OverviewStyle.color(for: edge.kind))
.font(.caption)
}
}
}
.padding()
}
}
}
private struct LegendView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Übersicht").font(.title3.bold())
Text("Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.")
.font(.caption)
Divider()
Text("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.")
.font(.caption)
Divider()
Text("Nicht im Diagramm (aber im Experte-Tab erreichbar):").font(.caption.bold())
VStack(alignment: .leading, spacing: 3) {
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
Text("· \(area)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Divider()
Text("Spalten").font(.caption.bold())
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewNode.Category.allCases) { category in
Label(category.rawValue, systemImage: "square.fill")
.foregroundStyle(OverviewStyle.color(for: category))
.font(.caption)
}
}
Divider()
Text("Verbindungsarten").font(.caption.bold())
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewEdgeKind.allCases) { kind in
Label(kind.rawValue, systemImage: "minus")
.foregroundStyle(OverviewStyle.color(for: kind))
.font(.caption)
}
}
}
.padding()
}
}
}
enum OverviewStyle {
static func color(for category: OverviewNode.Category) -> Color {
switch category {
case .interface: return .blue
case .ipAddress: return .teal
case .service: return .purple
case .route: return .orange
case .firewall: return .red
}
}
static func color(for kind: OverviewEdgeKind) -> Color {
switch kind {
case .vlan: return .indigo
case .bridgePort: return .cyan
case .wireguardPeer: return .pink
case .ipAddress: return .teal
case .dhcp: return .purple
case .route: return .orange
case .firewallInterface: return .red
case .addressList: return .yellow
}
}
static func icon(for kind: String) -> String {
switch kind {
case "ether": return "cable.connector"
case "vlan": return "square.stack.3d.up"
case "bridge": return "point.3.connected.trianglepath.dotted"
case "wlan", "wireless", "wifi": return "wifi"
case "wireguard": return "lock.shield"
case "wireguard-peer": return "person.crop.circle.badge.checkmark"
case "address": return "number"
case "pool": return "tray.full"
case "dhcp-server": return "server.rack"
case "dhcp-network": return "network"
case "dhcp-client": return "arrow.down.circle"
case "route": return "signpost.right.and.left"
case "filter": return "shield.lefthalf.filled"
case "nat": return "arrow.left.arrow.right"
case "address-list": return "list.bullet.rectangle"
default: return "questionmark.circle"
}
}
}
#Preview {
OverviewView(connectionService: ConnectionService())
}
@@ -0,0 +1,277 @@
import Foundation
/// Builds the read-only "Übersicht" topology graph from a live router: fetches the same generic
/// menus the Expert tool already knows how to read (`ConnectionService.fetchMenuItems`) and wires
/// them together into nodes/edges using RouterOS' own reference fields (an interface name a DHCP
/// server points at, a pool name it uses, ...) never guessed, only what the router itself
/// returns. Purely additive/read-only: never calls `apply()`, nothing here can change the router.
@MainActor
final class OverviewViewModel: ObservableObject {
@Published private(set) var graph = OverviewGraph()
@Published private(set) var isLoading = false
@Published private(set) var loadError: String?
@Published var selectedNodeID: String?
private let connectionService: ConnectionService
init(connectionService: ConnectionService) {
self.connectionService = connectionService
}
func load() async {
isLoading = true
loadError = nil
do {
async let interfaces = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
async let bridgePorts = connectionService.fetchMenuItems(menuPath: "/interface bridge port", restPath: "interface/bridge/port")
async let vlans = connectionService.fetchMenuItems(menuPath: "/interface vlan", restPath: "interface/vlan")
async let wireguardPeers = connectionService.fetchMenuItems(menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers")
async let addresses = connectionService.fetchMenuItems(menuPath: "/ip address", restPath: "ip/address")
async let pools = connectionService.fetchMenuItems(menuPath: "/ip pool", restPath: "ip/pool")
async let dhcpServers = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server")
async let dhcpNetworks = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network")
async let dhcpClients = connectionService.fetchMenuItems(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client")
async let routes = connectionService.fetchMenuItems(menuPath: "/ip route", restPath: "ip/route")
async let filterRules = connectionService.fetchMenuItems(menuPath: "/ip firewall filter", restPath: "ip/firewall/filter")
async let natRules = connectionService.fetchMenuItems(menuPath: "/ip firewall nat", restPath: "ip/firewall/nat")
async let addressLists = connectionService.fetchMenuItems(menuPath: "/ip firewall address-list", restPath: "ip/firewall/address-list")
graph = try await Self.buildGraph(
interfaces: interfaces,
bridgePorts: bridgePorts,
vlans: vlans,
wireguardPeers: wireguardPeers,
addresses: addresses,
pools: pools,
dhcpServers: dhcpServers,
dhcpNetworks: dhcpNetworks,
dhcpClients: dhcpClients,
routes: routes,
filterRules: filterRules,
natRules: natRules,
addressLists: addressLists
)
} catch {
loadError = error.localizedDescription
}
isLoading = false
}
// MARK: - Pure graph construction (testable without a live router)
nonisolated static func buildGraph(
interfaces: [RouterOSMenuItem],
bridgePorts: [RouterOSMenuItem],
vlans: [RouterOSMenuItem],
wireguardPeers: [RouterOSMenuItem],
addresses: [RouterOSMenuItem],
pools: [RouterOSMenuItem],
dhcpServers: [RouterOSMenuItem],
dhcpNetworks: [RouterOSMenuItem],
dhcpClients: [RouterOSMenuItem],
routes: [RouterOSMenuItem],
filterRules: [RouterOSMenuItem],
natRules: [RouterOSMenuItem],
addressLists: [RouterOSMenuItem]
) -> OverviewGraph {
var nodes: [OverviewNode] = []
var edges: [OverviewEdge] = []
var knownInterfaceNames = Set<String>()
// Interfaces: every physical/virtual interface RouterOS reports (ethernet, vlan, bridge,
// wireless, wireguard, ...). "vlan-id"/parent info is filled in below from "/interface
// vlan", since the generic "/interface" listing alone doesn't carry it.
var vlanInfoByName: [String: (vlanID: String, parent: String)] = [:]
for item in vlans {
guard let name = item.fields["name"] else { continue }
vlanInfoByName[name] = (item.fields["vlan-id"] ?? "?", item.fields["interface"] ?? "")
}
for item in interfaces {
guard let name = item.fields["name"] else { continue }
let type = item.fields["type"] ?? "unbekannt"
knownInterfaceNames.insert(name)
var subtitle = type
if let vlanInfo = vlanInfoByName[name] {
subtitle = "VLAN \(vlanInfo.vlanID)"
}
if item.fields["disabled"] == "true" || item.fields["disabled"] == "yes" {
subtitle += " · deaktiviert"
}
nodes.append(OverviewNode(
id: nodeID(.interface, name), category: .interface, kind: type,
title: name, subtitle: subtitle,
detail: sortedDetail(item.fields)
))
}
// VLAN -> parent interface (e.g. a VLAN on ether5, or on a bridge).
for (vlanName, info) in vlanInfoByName where !info.parent.isEmpty {
edges.append(OverviewEdge(from: nodeID(.interface, info.parent), to: nodeID(.interface, vlanName), label: "trägt VLAN \(info.vlanID)", kind: .vlan))
}
// Bridge ports: physical/virtual interface -> the bridge it's a member of.
for item in bridgePorts {
guard let bridge = item.fields["bridge"], let iface = item.fields["interface"] else { continue }
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: nodeID(.interface, bridge), label: "Bridge-Port", kind: .bridgePort))
}
// WireGuard peers: shown as small nodes hanging off their WireGuard interface.
for (index, item) in wireguardPeers.enumerated() {
guard let iface = item.fields["interface"] else { continue }
let id = "wgpeer:\(index)"
let allowed = item.fields["allowed-address"] ?? "?"
nodes.append(OverviewNode(
id: id, category: .interface, kind: "wireguard-peer",
title: "Peer", subtitle: allowed,
detail: sortedDetail(item.fields)
))
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .wireguardPeer))
}
// IP addresses: hang off the interface they're assigned to.
var networkToAddressID: [String: String] = [:] // "192.168.88.0" -> "ip:192.168.88.1/24"
for item in addresses {
guard let address = item.fields["address"], let iface = item.fields["interface"] else { continue }
let id = nodeID(.ipAddress, address)
nodes.append(OverviewNode(
id: id, category: .ipAddress, kind: "address",
title: address, subtitle: iface,
detail: sortedDetail(item.fields)
))
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .ipAddress))
if let network = item.fields["network"] {
networkToAddressID[network] = id
}
}
// Address pools.
for item in pools {
guard let name = item.fields["name"] else { continue }
nodes.append(OverviewNode(
id: nodeID(.service, "pool:" + name), category: .service, kind: "pool",
title: name, subtitle: item.fields["ranges"],
detail: sortedDetail(item.fields)
))
}
// DHCP servers: interface -> server -> pool it hands out addresses from.
for item in dhcpServers {
guard let name = item.fields["name"] else { continue }
let id = nodeID(.service, "dhcp:" + name)
nodes.append(OverviewNode(
id: id, category: .service, kind: "dhcp-server",
title: name, subtitle: "DHCP-Server",
detail: sortedDetail(item.fields)
))
if let iface = item.fields["interface"] {
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
}
if let pool = item.fields["address-pool"], pool != "static-only" {
edges.append(OverviewEdge(from: id, to: nodeID(.service, "pool:" + pool), label: "nutzt Pool", kind: .dhcp))
}
}
// DHCP networks (gateway/DNS options): linked to the IP address whose subnet matches,
// using RouterOS' own "network" field on /ip address not recomputed CIDR math.
for item in dhcpNetworks {
guard let address = item.fields["address"] else { continue }
let id = nodeID(.service, "dhcpnet:" + address)
nodes.append(OverviewNode(
id: id, category: .service, kind: "dhcp-network",
title: address, subtitle: item.fields["gateway"].map { "Gateway \($0)" },
detail: sortedDetail(item.fields)
))
let networkPart = address.split(separator: "/").first.map(String.init) ?? address
if let addressID = networkToAddressID[networkPart] {
edges.append(OverviewEdge(from: addressID, to: id, label: "Optionen", kind: .dhcp))
}
}
// DHCP client (WAN side): interface -> "bezieht Adresse per DHCP".
for item in dhcpClients {
guard let iface = item.fields["interface"] else { continue }
let id = nodeID(.service, "dhcpclient:" + iface)
nodes.append(OverviewNode(
id: id, category: .service, kind: "dhcp-client",
title: "DHCP-Client", subtitle: iface,
detail: sortedDetail(item.fields)
))
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
}
// Static routes: linked to their gateway interface when the gateway *is* an interface
// name (RouterOS allows either an IP or an interface name here); otherwise shown
// unconnected rather than guessed.
for (index, item) in routes.enumerated() {
guard let dst = item.fields["dst-address"] else { continue }
let gateway = item.fields["gateway"] ?? "?"
let id = "route:\(index)"
nodes.append(OverviewNode(
id: id, category: .route, kind: "route",
title: dst, subtitle: "über \(gateway)",
detail: sortedDetail(item.fields)
))
if knownInterfaceNames.contains(gateway) {
edges.append(OverviewEdge(from: nodeID(.interface, gateway), to: id, label: nil, kind: .route))
}
}
// Address lists: one node per distinct list name, entries folded into its detail.
var addressListEntries: [String: [String]] = [:]
for item in addressLists {
guard let list = item.fields["list"] else { continue }
addressListEntries[list, default: []].append(item.fields["address"] ?? "?")
}
for (list, addressesInList) in addressListEntries.sorted(by: { $0.key < $1.key }) {
nodes.append(OverviewNode(
id: nodeID(.firewall, "addrlist:" + list), category: .firewall, kind: "address-list",
title: list, subtitle: "\(addressesInList.count) Einträge",
detail: addressesInList.enumerated().map { (key: "Adresse \($0.offset + 1)", value: $0.element) }
))
}
// Firewall filter + NAT rules: connected to their in/out interface and any address-list
// they reference the two things that make a rule's *placement* in the topology visible.
appendFirewallRules(filterRules, kind: "filter", label: "Filter", into: &nodes, edges: &edges)
appendFirewallRules(natRules, kind: "nat", label: "NAT", into: &nodes, edges: &edges)
return OverviewGraph(nodes: nodes, edges: edges)
}
private nonisolated static func appendFirewallRules(
_ rules: [RouterOSMenuItem], kind: String, label: String,
into nodes: inout [OverviewNode], edges: inout [OverviewEdge]
) {
for (index, item) in rules.enumerated() {
let chain = item.fields["chain"] ?? "?"
let action = item.fields["action"] ?? "?"
let id = "\(kind):\(index)"
nodes.append(OverviewNode(
id: id, category: .firewall, kind: kind,
title: "\(label): \(action)", subtitle: item.fields["comment"] ?? chain,
detail: sortedDetail(item.fields)
))
if let inIface = item.fields["in-interface"], !inIface.isEmpty {
edges.append(OverviewEdge(from: nodeID(.interface, inIface), to: id, label: nil, kind: .firewallInterface))
}
if let outIface = item.fields["out-interface"], !outIface.isEmpty {
edges.append(OverviewEdge(from: id, to: nodeID(.interface, outIface), label: nil, kind: .firewallInterface))
}
if let srcList = item.fields["src-address-list"], !srcList.isEmpty {
edges.append(OverviewEdge(from: nodeID(.firewall, "addrlist:" + srcList), to: id, label: nil, kind: .addressList))
}
if let dstList = item.fields["dst-address-list"], !dstList.isEmpty {
edges.append(OverviewEdge(from: nodeID(.firewall, "addrlist:" + dstList), to: id, label: nil, kind: .addressList))
}
}
}
private nonisolated static func nodeID(_ category: OverviewNode.Category, _ key: String) -> String {
"\(category.rawValue):\(key)"
}
private nonisolated static func sortedDetail(_ fields: [String: String]) -> [(key: String, value: String)] {
fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
}
}
@@ -0,0 +1,105 @@
import XCTest
@testable import RouterOSAssistant
final class OverviewGraphTests: XCTestCase {
private func item(_ fields: [String: String], id: String = "*1") -> RouterOSMenuItem {
RouterOSMenuItem(id: id, fields: fields)
}
func testInterfaceToIPAddressEdge() {
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: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [],
filterRules: [], natRules: [], addressLists: []
)
XCTAssertTrue(graph.nodes.contains { $0.id == "Interfaces:ether1" })
XCTAssertTrue(graph.nodes.contains { $0.id == "IP-Adressen:192.168.88.1/24" })
XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether1" && $0.to == "IP-Adressen:192.168.88.1/24" })
}
func testVlanEdgeToParentInterface() {
let graph = OverviewViewModel.buildGraph(
interfaces: [
item(["name": "ether5", "type": "ether"]),
item(["name": "vlan20", "type": "vlan"])
],
bridgePorts: [],
vlans: [item(["name": "vlan20", "vlan-id": "20", "interface": "ether5"])],
wireguardPeers: [], addresses: [], pools: [], dhcpServers: [], dhcpNetworks: [],
dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: []
)
XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether5" && $0.to == "Interfaces:vlan20" })
let vlanNode = graph.nodes.first { $0.id == "Interfaces:vlan20" }
XCTAssertEqual(vlanNode?.subtitle, "VLAN 20")
}
func testDhcpServerEdgesToInterfaceAndPool() {
let graph = OverviewViewModel.buildGraph(
interfaces: [item(["name": "ether2", "type": "ether"])],
bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [],
pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])],
dhcpServers: [item(["name": "dhcp1", "interface": "ether2", "address-pool": "pool1"])],
dhcpNetworks: [], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: []
)
XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether2" && $0.to == "Pools & DHCP:dhcp:dhcp1" })
XCTAssertTrue(graph.edges.contains { $0.from == "Pools & DHCP:dhcp:dhcp1" && $0.to == "Pools & DHCP:pool:pool1" })
}
func testDhcpNetworkLinksViaAddressNetworkField() {
let graph = OverviewViewModel.buildGraph(
interfaces: [item(["name": "ether2", "type": "ether"])],
bridgePorts: [], vlans: [], wireguardPeers: [],
addresses: [item(["address": "192.168.88.1/24", "interface": "ether2", "network": "192.168.88.0"])],
pools: [], dhcpServers: [],
dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])],
dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: []
)
XCTAssertTrue(graph.edges.contains {
$0.from == "IP-Adressen:192.168.88.1/24" && $0.to == "Pools & DHCP:dhcpnet:192.168.88.0/24"
})
}
func testRouteLinksToInterfaceOnlyWhenGatewayIsAnInterfaceName() {
let graph = OverviewViewModel.buildGraph(
interfaces: [item(["name": "ether1", "type": "ether"])],
bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [], pools: [],
dhcpServers: [], dhcpNetworks: [], dhcpClients: [],
routes: [
item(["dst-address": "0.0.0.0/0", "gateway": "ether1"]),
item(["dst-address": "10.0.0.0/24", "gateway": "192.168.88.254"])
],
filterRules: [], natRules: [], addressLists: []
)
XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether1" && $0.to == "route:0" })
XCTAssertFalse(graph.edges.contains { $0.to == "route:1" })
}
func testFirewallFilterEdgesToInterfacesAndAddressList() {
let graph = OverviewViewModel.buildGraph(
interfaces: [
item(["name": "ether1", "type": "ether"]),
item(["name": "ether2", "type": "ether"])
],
bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [], pools: [],
dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [],
filterRules: [item([
"chain": "forward", "action": "drop",
"in-interface": "ether1", "out-interface": "ether2",
"src-address-list": "gesperrt"
])],
natRules: [],
addressLists: [item(["list": "gesperrt", "address": "10.0.0.5"])]
)
XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether1" && $0.to == "filter:0" })
XCTAssertTrue(graph.edges.contains { $0.from == "filter:0" && $0.to == "Interfaces:ether2" })
XCTAssertTrue(graph.edges.contains { $0.from == "Firewall & NAT:addrlist:gesperrt" && $0.to == "filter:0" })
}
}