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:
@@ -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" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user