"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und springen per Textanker direkt zur passenden Manual-Stelle. build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES- Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs- Dictionary wiederverwendet wird (714 Einträge geparst) statt einer zweiten, separat gepflegten Übersetzung. Live bestätigt (DE und EN). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
981 lines
49 KiB
Swift
981 lines
49 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
|
|
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
|
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
|
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
|
|
@State private var scale: CGFloat = 1.0
|
|
@State private var hoveredNodeID: String?
|
|
@State private var hoveredEdge: OverviewEdge?
|
|
@State private var hoverPoint: CGPoint = .zero
|
|
/// The focus panel's own hover/tooltip state — kept separate from the main diagram's, since
|
|
/// the panel is a genuinely separate view further down, not sharing the main canvas'
|
|
/// coordinate space (a shared `hoverPoint` would position the main diagram's floating tooltip
|
|
/// using panel-local coordinates while hovering an edge down there).
|
|
@State private var panelHoveredEdge: OverviewEdge?
|
|
@State private var panelHoverPoint: 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 }
|
|
|
|
/// Node IDs whose edges should draw at full strength — see `OverviewGraph.highlightedNodeIDs`
|
|
/// for the one-hop-vs-interface-transitive-closure logic itself.
|
|
private var highlightedNodeIDs: Set<String> {
|
|
guard let id = highlightNodeID else { return [] }
|
|
return viewModel.graph.highlightedNodeIDs(startingAt: id)
|
|
}
|
|
|
|
/// Manual per-node drag adjustments, on top of `OverviewLayout`'s computed grid position —
|
|
/// per explicit request: nodes can be dragged around for a clearer layout, connection lines
|
|
/// follow automatically, and a toolbar button resets back to the original arrangement. Kept
|
|
/// as plain view state (not persisted) — "Ausgangszustand" only ever means this session's
|
|
/// freshly computed column layout, not something remembered across relaunches.
|
|
@State private var nodeOffsets: [String: CGSize] = [:]
|
|
/// The node currently mid-drag, if any, plus its live (not-yet-committed) translation — kept
|
|
/// separate from `nodeOffsets` so `EdgesCanvas`/the dragged card itself update every frame of
|
|
/// the drag, not just once it ends.
|
|
@GestureState private var activeDrag: ActiveNodeDrag?
|
|
|
|
private struct ActiveNodeDrag: Equatable {
|
|
let nodeID: String
|
|
let translation: CGSize
|
|
}
|
|
|
|
private func offset(forNodeID id: String) -> CGSize {
|
|
var result = nodeOffsets[id] ?? .zero
|
|
if let activeDrag, activeDrag.nodeID == id {
|
|
result.width += activeDrag.translation.width
|
|
result.height += activeDrag.translation.height
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// "Fokus-Modus": the currently selected node's complete parent/child chain — `nil` while
|
|
/// nothing is selected (focus mode off). Driven by `viewModel.selectedNodeID` directly (not a
|
|
/// separate flag) so the existing click-to-select/click-again-to-deselect toggle already in
|
|
/// the node tap gesture doubles as "enter/exit focus" for free.
|
|
private var focusChainIDs: Set<String>? {
|
|
guard let id = viewModel.selectedNodeID else { return nil }
|
|
return viewModel.graph.connectedChain(startingAt: id)
|
|
}
|
|
|
|
/// Sub-graph containing only the focused chain — feeds both `focusSubLayout` (below) and the
|
|
/// focus panel's own node/edge rendering.
|
|
private var focusSubGraph: OverviewGraph? {
|
|
guard let chain = focusChainIDs else { return nil }
|
|
return OverviewGraph(
|
|
nodes: viewModel.graph.nodes.filter { chain.contains($0.id) },
|
|
edges: viewModel.graph.edges.filter { chain.contains($0.from) && chain.contains($0.to) }
|
|
)
|
|
}
|
|
|
|
/// Laid out with the exact same `OverviewLayout` column logic as the main diagram (per
|
|
/// explicit request), just scoped to the chain, so the focus panel reads as a smaller version
|
|
/// of the same diagram rather than a different layout style.
|
|
private var focusSubLayout: OverviewLayoutResult? {
|
|
guard let subGraph = focusSubGraph else { return nil }
|
|
return OverviewLayout.layout(for: subGraph)
|
|
}
|
|
|
|
|
|
/// `OverviewLayout`'s computed grid positions, shifted by each node's current (committed +
|
|
/// live-dragging) offset — what both the node cards and `EdgesCanvas`'s line endpoints
|
|
/// actually draw at. The main diagram's own layout never changes in focus mode — the focused
|
|
/// chain is shown a second time, neatly re-laid-out, in the separate panel below (see
|
|
/// `focusPanel`); nodes here just dim if they're not part of the chain.
|
|
private var effectivePositions: [String: CGPoint] {
|
|
var result: [String: CGPoint] = [:]
|
|
result.reserveCapacity(layout.positions.count)
|
|
for (id, base) in layout.positions {
|
|
let off = offset(forNodeID: id)
|
|
result[id] = CGPoint(x: base.x + off.width, y: base.y + off.height)
|
|
}
|
|
return result
|
|
}
|
|
|
|
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(
|
|
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
|
systemImage: "network.slash",
|
|
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
|
|
)
|
|
} else {
|
|
HSplitView {
|
|
diagramArea
|
|
.frame(minWidth: 480, minHeight: 320)
|
|
detailPanel
|
|
.frame(minWidth: 260, idealWidth: 300, maxWidth: 380)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht", appLanguage)))
|
|
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabOverview) } }
|
|
.toolbar {
|
|
ToolbarItemGroup {
|
|
if viewModel.isLoading {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
Button {
|
|
Task { await viewModel.load() }
|
|
} label: {
|
|
Label(L10n.t("Aktualisieren", appLanguage), 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") }
|
|
Button(L10n.t("Zurücksetzen", appLanguage)) {
|
|
nodeOffsets = [:]
|
|
}
|
|
.disabled(nodeOffsets.isEmpty)
|
|
.help(L10n.t("Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.", appLanguage))
|
|
}
|
|
}
|
|
.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(L10n.t("Lade Router-Konfiguration…", appLanguage))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.loadError, viewModel.graph.nodes.isEmpty {
|
|
ContentUnavailableView(
|
|
L10n.t("Konnte Konfiguration nicht laden", appLanguage), systemImage: "exclamationmark.triangle",
|
|
description: Text(error)
|
|
)
|
|
} else if viewModel.graph.nodes.isEmpty {
|
|
ContentUnavailableView(
|
|
L10n.t("Keine Konfiguration gefunden", appLanguage), systemImage: "square.dashed",
|
|
description: Text(L10n.t("Der Router meldet aktuell keine Interfaces/Adressen.", appLanguage))
|
|
)
|
|
} else {
|
|
ZStack {
|
|
ScrollView([.horizontal, .vertical]) {
|
|
ZStack(alignment: .topLeading) {
|
|
EdgesCanvas(
|
|
graph: viewModel.graph,
|
|
positions: effectivePositions,
|
|
highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs,
|
|
theme: colorTheme,
|
|
hoveredEdge: $hoveredEdge,
|
|
hoverPoint: $hoverPoint,
|
|
selectedEdge: $selectedEdge,
|
|
onBackgroundTap: {
|
|
viewModel.selectedNodeID = nil
|
|
selectedEdge = nil
|
|
}
|
|
)
|
|
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
|
|
|
|
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
|
|
Text(L10n.t(category.rawValue, appLanguage))
|
|
.appFont(.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 = effectivePositions[node.id] {
|
|
let isChainMember = focusChainIDs?.contains(node.id) == true
|
|
NodeCardView(
|
|
node: node,
|
|
isSelected: node.id == viewModel.selectedNodeID,
|
|
isHovered: node.id == hoveredNodeID,
|
|
theme: colorTheme
|
|
)
|
|
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
|
|
.position(point)
|
|
.opacity(focusChainIDs == nil || isChainMember ? 1.0 : 0.25)
|
|
.onTapGesture {
|
|
selectedEdge = nil
|
|
viewModel.selectedNodeID = (viewModel.selectedNodeID == node.id) ? nil : node.id
|
|
}
|
|
.onHover { isHovering in
|
|
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
|
|
}
|
|
// `.simultaneousGesture` (not `.gesture`) so this doesn't steal
|
|
// the tap above — a small `minimumDistance` lets a plain click
|
|
// still register as a tap-to-select instead of a zero-length drag.
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 2, coordinateSpace: .local)
|
|
.updating($activeDrag) { value, state, _ in
|
|
state = ActiveNodeDrag(nodeID: node.id, translation: value.translation)
|
|
}
|
|
.onEnded { value in
|
|
var committed = nodeOffsets[node.id] ?? .zero
|
|
committed.width += value.translation.width
|
|
committed.height += value.translation.height
|
|
nodeOffsets[node.id] = committed
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
if let edge = hoveredEdge {
|
|
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme)
|
|
.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()
|
|
}
|
|
|
|
if let subGraph = focusSubGraph, let subLayout = focusSubLayout {
|
|
// Not a `.sheet` — a `.sheet` is a genuine OS-level modal that blocks the rest of
|
|
// the window, including the right-hand sidebar's "Bearbeiten" button. Per explicit
|
|
// follow-up request, editing a node from the sidebar has to work while this popup
|
|
// is still open, so it's a plain non-modal overlay instead — the sidebar
|
|
// (`detailPanel`, a separate `HSplitView` pane, never covered by this overlay)
|
|
// stays fully interactive.
|
|
Color.black.opacity(0.25)
|
|
.ignoresSafeArea()
|
|
.onTapGesture {
|
|
viewModel.selectedNodeID = nil
|
|
selectedEdge = nil
|
|
}
|
|
focusPanel(subGraph: subGraph, subLayout: subLayout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Separate panel below the main diagram showing the focused node's complete chain, neatly
|
|
/// re-laid-out with the same `OverviewLayout` column logic — per explicit request: not a
|
|
/// divider splitting the same scrollable canvas (an earlier iteration of this), a genuinely
|
|
/// separate panel spanning the main diagram's own viewport width, height fit to its content,
|
|
/// with its own close button (background click / re-clicking the same node up in the main
|
|
/// diagram still also close it, same as before — this just adds an explicit affordance).
|
|
@ViewBuilder
|
|
private func focusPanel(subGraph: OverviewGraph, subLayout: OverviewLayoutResult) -> some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(L10n.t("Fokus", appLanguage)).appFont(.headline)
|
|
Spacer()
|
|
Button {
|
|
viewModel.selectedNodeID = nil
|
|
selectedEdge = nil
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(L10n.t("Fokus-Ansicht schließen", appLanguage))
|
|
}
|
|
.padding(10)
|
|
|
|
Divider()
|
|
|
|
// No `ScrollView` here on purpose — per explicit request, this sheet sizes itself
|
|
// exactly to `subLayout.canvasSize` (below) instead of clipping/scrolling a fixed
|
|
// viewport, same "form sheet" sizing model as `ExpertItemEditView`.
|
|
ZStack(alignment: .topLeading) {
|
|
EdgesCanvas(
|
|
graph: subGraph,
|
|
positions: subLayout.positions,
|
|
highlightedNodeIDs: Set(subGraph.nodes.map(\.id)),
|
|
theme: colorTheme,
|
|
hoveredEdge: $panelHoveredEdge,
|
|
hoverPoint: $panelHoverPoint,
|
|
selectedEdge: $selectedEdge,
|
|
onBackgroundTap: {
|
|
viewModel.selectedNodeID = nil
|
|
selectedEdge = nil
|
|
}
|
|
)
|
|
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
|
|
|
|
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
|
|
Text(L10n.t(category.rawValue, appLanguage))
|
|
.appFont(.headline)
|
|
.foregroundStyle(.secondary)
|
|
.position(
|
|
x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2,
|
|
y: 14
|
|
)
|
|
}
|
|
|
|
ForEach(subGraph.nodes) { node in
|
|
if let point = subLayout.positions[node.id] {
|
|
NodeCardView(
|
|
node: node,
|
|
isSelected: node.id == viewModel.selectedNodeID,
|
|
isHovered: node.id == hoveredNodeID,
|
|
theme: colorTheme
|
|
)
|
|
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
|
|
.position(point)
|
|
.onTapGesture {
|
|
selectedEdge = nil
|
|
viewModel.selectedNodeID = node.id
|
|
}
|
|
.onHover { isHovering in
|
|
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let edge = panelHoveredEdge {
|
|
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage, theme: colorTheme)
|
|
.position(
|
|
x: min(max(panelHoverPoint.x + 90, 90), subLayout.canvasSize.width - 90),
|
|
y: max(panelHoverPoint.y - 26, 16)
|
|
)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
|
|
}
|
|
.fixedSize()
|
|
.background(.regularMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.3), radius: 20, y: 8)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detailPanel: some View {
|
|
if let edge = selectedEdge {
|
|
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme) { 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, appLanguage: appLanguage, theme: colorTheme, onEdit: editNode) { nodeID in
|
|
selectedEdge = nil
|
|
viewModel.selectedNodeID = nodeID
|
|
}
|
|
} else {
|
|
LegendView(appLanguage: appLanguage, theme: colorTheme)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draws every edge as a bezier curve, colored by its `kind` (see `colorTheme.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 highlightedNodeIDs: Set<String>
|
|
let theme: ColorTheme
|
|
@Binding var hoveredEdge: OverviewEdge?
|
|
@Binding var hoverPoint: CGPoint
|
|
@Binding var selectedEdge: OverviewEdge?
|
|
/// Fires when a tap lands on the canvas but not on any edge — this `Canvas` already covers
|
|
/// the full diagram area underneath the node cards, so its own tap gesture is the simplest
|
|
/// place to catch "clicked empty space" without a second, competing gesture recognizer. Used
|
|
/// by `OverviewView` to exit "Fokus-Modus" on a background click, mirroring the existing
|
|
/// click-the-same-node-again toggle.
|
|
let onBackgroundTap: () -> Void
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// One "on, off" cycle of the flow animation's dash pattern, in points.
|
|
private static let flowDash: [CGFloat] = [5, 5]
|
|
/// How fast the dash pattern travels along a line, in points/second — slow enough to read as
|
|
/// "gentle flow", not a distracting marquee.
|
|
private static let flowSpeed: CGFloat = 12
|
|
|
|
/// Per explicit request: every connection line gets an animated flow direction (from "from"
|
|
/// to "to", the same direction the diagram's own dependency arrows already document) rather
|
|
/// than a static stroke. `TimelineView(.animation)` re-invokes the `Canvas` closure on every
|
|
/// display frame with a fresh `timeline.date`, which is what lets a `dashPhase` computed from
|
|
/// elapsed time actually animate — a plain `@State` `withAnimation(.repeatForever)` value
|
|
/// wouldn't: `Canvas` is immediate-mode, so nothing outside of `TimelineView` re-triggers its
|
|
/// drawing closure on every frame. The phase decreases over time, which moves the visible
|
|
/// dashes in the direction the path is stroked (start → end, i.e. "from" → "to").
|
|
private func dashPhase(at date: Date) -> CGFloat {
|
|
let cycleLength = Self.flowDash.reduce(0, +)
|
|
let elapsed = CGFloat(date.timeIntervalSinceReferenceDate)
|
|
return -(elapsed * Self.flowSpeed).truncatingRemainder(dividingBy: cycleLength)
|
|
}
|
|
|
|
var body: some View {
|
|
TimelineView(.animation) { timeline in
|
|
let phase = dashPhase(at: timeline.date)
|
|
Canvas { context, _ in
|
|
for geometry in geometries {
|
|
let edge = geometry.edge
|
|
let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to)
|
|
let isDimmed = !highlightedNodeIDs.isEmpty && !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 = theme.color(for: edge.kind)
|
|
context.stroke(
|
|
path,
|
|
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
|
|
style: StrokeStyle(
|
|
lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3),
|
|
lineCap: .round,
|
|
dash: Self.flowDash,
|
|
dashPhase: phase
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.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 {
|
|
onBackgroundTap()
|
|
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 !highlightedNodeIDs.isEmpty else { return geometries }
|
|
return geometries.filter { highlightedNodeIDs.contains($0.edge.from) || highlightedNodeIDs.contains($0.edge.to) }
|
|
}
|
|
|
|
/// 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
|
|
let appLanguage: String
|
|
let theme: ColorTheme
|
|
|
|
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(L10n.t(edge.kind.rawValue, appLanguage))
|
|
.appFont(.caption, bold: true)
|
|
Text("\(fromTitle) → \(toTitle)")
|
|
.appFont(.caption2)
|
|
if let label = edge.label {
|
|
Text(label)
|
|
.appFont(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(8)
|
|
.background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(theme.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 appLanguage: String
|
|
let theme: ColorTheme
|
|
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(theme.color(for: edge.kind))
|
|
Text(L10n.t(edge.kind.rawValue, appLanguage)).appFont(.title3, bold: true)
|
|
}
|
|
Text(L10n.t("Verbindung", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
|
|
|
|
Divider()
|
|
|
|
Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
|
|
.appFont(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if let label = edge.label {
|
|
Text(label)
|
|
.appFont(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Text(L10n.t("Verbunden", appLanguage)).appFont(.subheadline, bold: true)
|
|
|
|
endpointRow(title: L10n.t("Von", appLanguage), node: fromNode, fallbackTitle: fromTitle, id: edge.from)
|
|
endpointRow(title: L10n.t("Nach", appLanguage), 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).appFont(.caption).foregroundStyle(.secondary)
|
|
HStack(spacing: 4) {
|
|
if let node {
|
|
Image(systemName: OverviewStyle.icon(for: node.kind))
|
|
.foregroundStyle(theme.color(for: node.category))
|
|
}
|
|
Text(fallbackTitle).appFont(.callout, bold: true)
|
|
}
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.appFont(.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
|
|
let theme: ColorTheme
|
|
|
|
var body: some View {
|
|
VStack(spacing: 2) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: OverviewStyle.icon(for: node.kind))
|
|
.foregroundStyle(theme.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 : theme.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 appLanguage: String
|
|
let theme: ColorTheme
|
|
let onEdit: (OverviewNode) -> Void
|
|
let onSelectNode: (String) -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Image(systemName: OverviewStyle.icon(for: node.kind))
|
|
.foregroundStyle(theme.color(for: node.category))
|
|
Text(node.title).appFont(.title3, bold: true)
|
|
Spacer()
|
|
if node.editTarget != nil {
|
|
Button {
|
|
onEdit(node)
|
|
} label: {
|
|
Label(L10n.t("Bearbeiten", appLanguage), systemImage: "pencil")
|
|
}
|
|
.help(L10n.t("Öffnet dasselbe Bearbeiten-Formular wie im Experte-Tab und schreibt Änderungen direkt an den Router.", appLanguage))
|
|
}
|
|
}
|
|
Text(L10n.t(node.category.rawValue, appLanguage))
|
|
.appFont(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if !node.detail.isEmpty {
|
|
Divider()
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(Array(node.detail.enumerated()), id: \.element.key) { index, pair in
|
|
HStack(alignment: .top) {
|
|
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
|
|
}
|
|
.padding(.vertical, 2)
|
|
.background(TableZebra.color(for: index))
|
|
}
|
|
}
|
|
}
|
|
|
|
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
|
|
if !connected.isEmpty {
|
|
Divider()
|
|
Text(L10n.t("Verbindungen", appLanguage)).appFont(.subheadline, bold: true)
|
|
ForEach(Array(connected.enumerated()), id: \.element.id) { index, edge in
|
|
let otherID = edge.from == node.id ? edge.to : edge.from
|
|
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
|
|
Button {
|
|
onSelectNode(otherID)
|
|
} label: {
|
|
Label(
|
|
edge.label.map { "\(otherTitle) (\($0))" } ?? otherTitle,
|
|
systemImage: edge.from == node.id ? "arrow.right" : "arrow.left"
|
|
)
|
|
.foregroundStyle(theme.color(for: edge.kind))
|
|
.appFont(.caption)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 2)
|
|
.background(TableZebra.color(for: index))
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LegendView: View {
|
|
let appLanguage: String
|
|
let theme: ColorTheme
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(L10n.t("Übersicht", appLanguage)).appFont(.title3, bold: true)
|
|
Text(L10n.t("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.", appLanguage))
|
|
.appFont(.caption)
|
|
|
|
Divider()
|
|
Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage))
|
|
.appFont(.caption)
|
|
|
|
Divider()
|
|
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).appFont(.caption, bold: true)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
|
|
Text("· \(L10n.t(area, appLanguage))")
|
|
.appFont(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
Text(L10n.t("Spalten", appLanguage)).appFont(.caption, bold: true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(OverviewNode.Category.allCases) { category in
|
|
Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill")
|
|
.foregroundStyle(theme.color(for: category))
|
|
.appFont(.caption)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
Text(L10n.t("Verbindungsarten", appLanguage)).appFont(.caption, bold: true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(OverviewEdgeKind.allCases) { kind in
|
|
Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus")
|
|
.foregroundStyle(theme.color(for: kind))
|
|
.appFont(.caption)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Colors moved to `ColorTheme` (`AppPreferences.swift`) so they respond to the Settings window's
|
|
/// "Farbschema" picker — this enum now only holds the parts that don't vary by theme.
|
|
enum OverviewStyle {
|
|
/// 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())
|
|
}
|