Files
RouterOS/RouterOSAssistant/Features/Overview/OverviewView.swift
T
KayandClaude Sonnet 5 aebba46989 Bug 29: Übersicht-Tab aktualisierte sich nicht selbst beim Tab-Wechsel
.task lief nur einmal pro View-Lebenszeit — SwiftUI zerstört Tab-Inhalte
auf macOS beim Wechsel nicht, die View bleibt am Leben, .task feuert
also nie erneut beim Zurückwechseln. Fix: .onAppear statt .task, feuert
bei jedem Sichtbarwerden des Tabs neu — Änderungen aus dem Experte-Tab
(z.B. eine neu angelegte Route) erscheinen jetzt automatisch, ohne
manuellen "Aktualisieren"-Klick.

59 Unit-Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
2026-09-15 14:02:48 +02:00

715 lines
33 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
/// Reused only to open the Experte-Tab's own add/edit sheet (`ExpertItemEditView`) for a
/// clicked node's underlying RouterOS item — same field schemas, same idempotent write path,
/// instead of duplicating either here.
@StateObject private var expertViewModel: ExpertViewModel
@State private var scale: CGFloat = 1.0
@State private var hoveredNodeID: String?
@State private var hoveredEdge: OverviewEdge?
@State private var hoverPoint: CGPoint = .zero
/// Clicking one of a selected node's highlighted lines shows a full explanation in the right
/// panel (mirroring node clicks) — the floating hover tooltip alone only has room for the
/// bare kind/from/to, not a plain-language explanation of what the connection means.
@State private var selectedEdge: OverviewEdge?
/// A click "locks in" a node's highlight until something else is clicked (or the same node
/// again, to deselect) — it takes priority over hover. Hover only drives the highlight while
/// nothing is selected yet, for a quick preview while sweeping across the diagram. Selection
/// winning is what lets a clicked node's connections stay highlighted while tracing the mouse
/// along one of its curves to read the tooltip, even where that curve passes close to some
/// other, unrelated node's card — confirmed live (2026-09-15): the previous "hover always
/// wins" order made the highlight jump to whatever card the cursor grazed en route.
private var highlightNodeID: String? { viewModel.selectedNodeID ?? hoveredNodeID }
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService))
_expertViewModel = StateObject(wrappedValue: ExpertViewModel(connectionService: connectionService))
}
private var layout: OverviewLayoutResult { OverviewLayout.layout(for: viewModel.graph) }
/// The curated schema for this menu if one exists (so the edit sheet gets the same tooltips/
/// pickers as the Experte-Tab), otherwise a minimal generic one — every field then falls back
/// to the sheet's free-form key=value editor, same as opening an uncurated path there directly.
private func schema(for editTarget: OverviewNode.EditTarget) -> RouterOSMenuSchema {
RouterOSSchemaCatalog.all.first(where: { $0.menuPath == editTarget.menuPath }) ?? RouterOSMenuSchema(
menuPath: editTarget.menuPath,
restPath: editTarget.restPath,
category: .system,
displayName: editTarget.menuPath,
summary: "Generischer Zugriff ohne kuratierte Felder.",
explanation: "Alle Felder erscheinen unten als freie Schlüssel/Wert-Paare."
)
}
/// Opens the Experte-Tab's edit sheet for a clicked node — reconstructs a `RouterOSMenuItem`
/// from the node's already-loaded `detail` fields (no extra round-trip needed) and reuses
/// `ExpertViewModel.startEditing`/`open` exactly as the Experte-Tab itself does.
private func editNode(_ node: OverviewNode) {
guard let editTarget = node.editTarget else { return }
let resolvedSchema = schema(for: editTarget)
expertViewModel.open(resolvedSchema)
let fields = Dictionary(uniqueKeysWithValues: node.detail)
expertViewModel.startEditing(RouterOSMenuItem(id: editTarget.itemID, fields: fields))
}
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") }
}
}
.onAppear {
// SwiftUI keeps every tab's content alive when switching tabs on macOS (this view
// isn't torn down and recreated) — `.task` only ever runs once per view lifetime,
// so it wouldn't refire just from revisiting this tab. `.onAppear` does fire again
// each time, which is what actually picks up changes made elsewhere (e.g. a route
// added in the Experte-Tab) without a manual "Aktualisieren" click. Confirmed live
// (2026-09-15): the tab didn't refresh after switching away and back.
Task { await viewModel.load() }
}
.sheet(item: $expertViewModel.editingItem) { _ in
// `selectedSchema` is always set by `editNode` right before `editingItem`, so this
// is never actually reached — required only because the view needs a non-optional
// schema to construct.
if let resolvedSchema = expertViewModel.selectedSchema {
ExpertItemEditView(viewModel: expertViewModel, schema: resolvedSchema, isNew: false)
}
}
.onChange(of: expertViewModel.editingItem == nil) { _, isNil in
guard isNil else { return }
Task { 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,
hoveredEdge: $hoveredEdge,
hoverPoint: $hoverPoint,
selectedEdge: $selectedEdge
)
.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 {
selectedEdge = nil
viewModel.selectedNodeID = (viewModel.selectedNodeID == node.id) ? nil : node.id
}
.onHover { isHovering in
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
}
}
}
if let edge = hoveredEdge {
EdgeTooltipView(edge: edge, graph: viewModel.graph)
.position(
x: min(max(hoverPoint.x + 90, 90), layout.canvasSize.width - 90),
y: max(hoverPoint.y - 26, 16)
)
.allowsHitTesting(false)
}
}
.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 edge = selectedEdge {
EdgeDetailView(edge: edge, graph: viewModel.graph) { nodeID in
selectedEdge = nil
viewModel.selectedNodeID = nodeID
}
} else if let id = viewModel.selectedNodeID, let node = viewModel.graph.nodes.first(where: { $0.id == id }) {
NodeDetailView(node: node, graph: viewModel.graph, onEdit: editNode)
} 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. Also tracks the mouse
/// against every edge's actual curve (not just its bounding box) so hovering a connection line
/// itself — not just a node — reports which edge is closest, for the tooltip in `OverviewView`.
private struct EdgesCanvas: View {
let graph: OverviewGraph
let positions: [String: CGPoint]
let highlightNodeID: String?
@Binding var hoveredEdge: OverviewEdge?
@Binding var hoverPoint: CGPoint
@Binding var selectedEdge: OverviewEdge?
/// How close the mouse needs to be to an edge's curve (in the canvas' own, unscaled point
/// space — SwiftUI reports hover coordinates already adjusted for any ancestor `.scaleEffect`,
/// so this threshold stays a constant regardless of the current zoom level) to count as
/// hovering it.
private static let hitTestDistance: CGFloat = 8
private struct EdgeGeometry {
let edge: OverviewEdge
let start: CGPoint
let control1: CGPoint
let control2: CGPoint
let end: CGPoint
}
/// Same curve construction as the drawing loop below, computed once and shared with hit-
/// testing so hovering can never disagree with what's actually drawn.
///
/// Not every edge runs left-to-right between two different columns: a VLAN/bridge-port edge
/// connects two nodes in the *same* column (interface → interface), and a firewall/NAT rule's
/// outgoing-interface edge points *back* to an earlier column. The original single formula
/// always exited the right edge of "from" and entered the left edge of "to" — for a
/// same-column pair that put both control points ~180pt apart outside the nodes, and for a
/// genuinely backward edge it put them past each other in opposite directions; both cases
/// pushed the curve's control points well past x=0, clipped by the canvas' own bounds
/// (confirmed live 2026-09-15: exactly the "connection lines cut off on the left" report).
private var geometries: [EdgeGeometry] {
graph.edges.compactMap { edge in
guard let from = positions[edge.from], let to = positions[edge.to] else { return nil }
let dx = to.x - from.x
let halfWidth = OverviewLayout.nodeWidth / 2
let start: CGPoint
let end: CGPoint
let control1: CGPoint
let control2: CGPoint
if abs(dx) < 1 {
// Same column: bulge out from the right edge of both nodes instead of looping
// around the far sides.
start = CGPoint(x: from.x + halfWidth, y: from.y)
end = CGPoint(x: to.x + halfWidth, y: to.y)
let bulge: CGFloat = 26
control1 = CGPoint(x: start.x + bulge, y: start.y)
control2 = CGPoint(x: end.x + bulge, y: end.y)
} else if dx > 0 {
// Forward: exit the right edge of "from", enter the left edge of "to".
start = CGPoint(x: from.x + halfWidth, y: from.y)
end = CGPoint(x: to.x - halfWidth, y: to.y)
let offset = max(abs(end.x - start.x) / 2, 30)
control1 = CGPoint(x: start.x + offset, y: start.y)
control2 = CGPoint(x: end.x - offset, y: end.y)
} else {
// Backward: mirror of the forward case — exit the left edge of "from", enter the
// right edge of "to".
start = CGPoint(x: from.x - halfWidth, y: from.y)
end = CGPoint(x: to.x + halfWidth, y: to.y)
let offset = max(abs(end.x - start.x) / 2, 30)
control1 = CGPoint(x: start.x - offset, y: start.y)
control2 = CGPoint(x: end.x + offset, y: end.y)
}
return EdgeGeometry(edge: edge, start: start, control1: control1, control2: control2, end: end)
}
}
var body: some View {
Canvas { context, _ in
for geometry in geometries {
let edge = geometry.edge
let isConnected = highlightNodeID != nil && (edge.from == highlightNodeID || edge.to == highlightNodeID)
let isDimmed = highlightNodeID != nil && !isConnected
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
var path = Path()
path.move(to: geometry.start)
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
let baseColor = OverviewStyle.color(for: edge.kind)
context.stroke(
path,
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3)
)
}
}
.onContinuousHover { phase in
switch phase {
case .active(let location):
hoverPoint = location
hoveredEdge = stickyNearestEdge(to: location)
case .ended:
hoveredEdge = nil
}
}
.gesture(
SpatialTapGesture()
.onEnded { value in
guard let edge = nearestEdge(to: value.location) else { return }
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
}
)
}
/// Edges eligible for hover right now — every edge normally, but narrowed down to just the
/// ones touching `highlightNodeID` once a node is clicked/hovered. A clicked interface can
/// easily have half a dozen edges leaving the same corner; searching the whole diagram for
/// "nearest curve" among all of them (plus everything else on screen) is exactly what made
/// hovering feel like it kept jumping to the wrong line. Once the user has already narrowed
/// things down to one node's connections, hover only needs to disambiguate between those.
private var candidateGeometries: [EdgeGeometry] {
guard let highlightNodeID else { return geometries }
return geometries.filter { $0.edge.from == highlightNodeID || $0.edge.to == highlightNodeID }
}
/// Several edges often fan out from the exact same point (e.g. every edge leaving one
/// interface's right edge) — right there, they're all near-equidistant from the cursor, so
/// picking the single closest one on every mouse-move event flickers between them on the
/// slightest movement. Once an edge is hovered, keep showing it as long as the cursor stays
/// within a wider tolerance of it, and only re-pick the nearest candidate once the cursor has
/// actually moved away from the current one.
private func stickyNearestEdge(to point: CGPoint) -> OverviewEdge? {
if let current = hoveredEdge,
let currentGeometry = candidateGeometries.first(where: { $0.edge.id == current.id }) {
let distanceToCurrent = distanceToCurve(
from: point, p0: currentGeometry.start, p1: currentGeometry.control1,
p2: currentGeometry.control2, p3: currentGeometry.end
)
if distanceToCurrent < Self.hitTestDistance * 2 {
return current
}
}
return nearestEdge(to: point)
}
private func nearestEdge(to point: CGPoint) -> OverviewEdge? {
var best: (edge: OverviewEdge, distance: CGFloat)?
for geometry in candidateGeometries {
let distance = distanceToCurve(
from: point, p0: geometry.start, p1: geometry.control1, p2: geometry.control2, p3: geometry.end
)
if distance < Self.hitTestDistance, best == nil || distance < best!.distance {
best = (geometry.edge, distance)
}
}
return best?.edge
}
/// Approximates point-to-cubic-bezier distance by sampling the curve — exact enough for an
/// 8pt hit-test threshold, and far simpler than solving it analytically.
private func distanceToCurve(from point: CGPoint, p0: CGPoint, p1: CGPoint, p2: CGPoint, p3: CGPoint) -> CGFloat {
let samples = 24
var minDistance = CGFloat.greatestFiniteMagnitude
for step in 0...samples {
let t = CGFloat(step) / CGFloat(samples)
let u = 1 - t
let x = u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x
let y = u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y
let dx = x - point.x
let dy = y - point.y
minDistance = min(minDistance, (dx * dx + dy * dy).squareRoot())
}
return minDistance
}
}
/// Floating label shown next to the cursor while hovering a connection line — what it connects
/// and what kind of dependency it is, without needing to click either endpoint node first.
private struct EdgeTooltipView: View {
let edge: OverviewEdge
let graph: OverviewGraph
private var fromTitle: String { graph.nodes.first(where: { $0.id == edge.from })?.title ?? edge.from }
private var toTitle: String { graph.nodes.first(where: { $0.id == edge.to })?.title ?? edge.to }
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(edge.kind.rawValue)
.font(.caption.bold())
Text("\(fromTitle)\(toTitle)")
.font(.caption2)
if let label = edge.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(OverviewStyle.color(for: edge.kind), lineWidth: 1)
)
.fixedSize()
}
}
/// Full-detail view for a clicked connection line, shown in the right-hand panel exactly like a
/// clicked node's details — the floating hover tooltip only has room for the bare kind/from/to,
/// this adds a plain-language explanation of what the connection actually means plus quick jumps
/// to either endpoint's own detail view.
private struct EdgeDetailView: View {
let edge: OverviewEdge
let graph: OverviewGraph
let onJumpToNode: (String) -> Void
private var fromNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.from }) }
private var toNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.to }) }
private var fromTitle: String { fromNode?.title ?? edge.from }
private var toTitle: String { toNode?.title ?? edge.to }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "arrow.triangle.branch")
.foregroundStyle(OverviewStyle.color(for: edge.kind))
Text(edge.kind.rawValue).font(.title3.bold())
}
Text("Verbindung").font(.caption).foregroundStyle(.secondary)
Divider()
Text(OverviewStyle.explanation(for: edge.kind))
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
if let label = edge.label {
Text(label)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
Text("Verbunden").font(.subheadline.bold())
endpointRow(title: "Von", node: fromNode, fallbackTitle: fromTitle, id: edge.from)
endpointRow(title: "Nach", node: toNode, fallbackTitle: toTitle, id: edge.to)
}
.padding()
}
}
@ViewBuilder
private func endpointRow(title: String, node: OverviewNode?, fallbackTitle: String, id: String) -> some View {
Button {
onJumpToNode(id)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
HStack(spacing: 4) {
if let node {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
}
Text(fallbackTitle).font(.callout.bold())
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.3)))
}
.buttonStyle(.plain)
}
}
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
let onEdit: (OverviewNode) -> Void
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())
Spacer()
if node.editTarget != nil {
Button {
onEdit(node)
} label: {
Label("Bearbeiten", systemImage: "pencil")
}
.help("Öffnet dasselbe Bearbeiten-Formular wie im Experte-Tab und schreibt Änderungen direkt an den Router.")
}
}
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
}
}
/// Plain-language explanation of what this connection kind actually means — shown in
/// `EdgeDetailView` so clicking a line answers "why is this connected" for someone who
/// doesn't already know RouterOS' internal terminology.
static func explanation(for kind: OverviewEdgeKind) -> String {
switch kind {
case .vlan:
return "Das VLAN-Interface baut auf dem Basis-Interface auf — es ist ein eigenes, per VLAN-Kennung getrenntes Netzwerk auf demselben physischen Anschluss."
case .bridgePort:
return "Dieser physische Port ist Mitglied dieser Bridge — Geräte an diesem Port verhalten sich, als hingen sie am selben Kabel wie alle anderen Bridge-Ports."
case .wireguardPeer:
return "Dieser WireGuard-VPN-Tunnel läuft über dieses Interface."
case .ipAddress:
return "Diesem Interface ist diese IP-Adresse zugewiesen."
case .dhcp:
return "Diese DHCP-Komponente (Server, Pool oder Netzwerk-Optionen) gehört zu diesem Interface bzw. dieser Adresse."
case .route:
return "Diese Route führt über dieses Interface bzw. dieses Gateway."
case .firewallInterface:
return "Diese Firewall- oder NAT-Regel bezieht sich auf dieses Interface (als Eingang oder Ausgang)."
case .addressList:
return "Diese Regel prüft, ob eine Adresse in dieser Adress-Liste steht."
}
}
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())
}