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
356 lines
15 KiB
Swift
356 lines
15 KiB
Swift
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())
|
|
}
|