forked from kay/RouterOS
M15: Übersicht-Tab — Diagramm-Fixes + Direktes Bearbeiten
Diagramm-Fixes: echter Geometrie-Bug behoben (VLAN-/Bridge-Port-Kanten laufen innerhalb derselben Spalte, eine Firewall/NAT-Kante rückwärts — die Kurven-Berechnung nahm immer "rechts raus, links rein" an und schoss dabei über den Canvas hinaus, links abgeschnitten). Hover-Flackern durch Trägheit + Beschränkung auf hervorgehobene Kanten behoben. Klick-vs- Hover-Priorität vertauscht (Klick gewinnt jetzt über Hover, vorher verdrängte das Streifen fremder Karten beim Nachfahren einer Linie die Auswahl). Linien-Klick zeigt jetzt volle Erklärung im rechten Panel (EdgeDetailView) statt nur Hover-Tooltip. Ein Auto-Fit-Versuch (GeometryReader) brach das Scroll-Verhalten und wurde wieder zurückgezogen. Neue Fähigkeit: ein Knoten (IP-Adresse, Pool, DHCP-Server/-Netzwerk/ -Client, Route, Firewall-Filter-/NAT-Regel, WireGuard-Peer) lässt sich direkt über denselben Dialog wie im Experte-Tab bearbeiten und zurückschreiben (OverviewNode.EditTarget + wiederverwendete ExpertItemEditView). Live bestätigt: Kommentar-Änderung an einer Firewall-Regel. 59 Unit-Tests grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
+30
@@ -735,3 +735,33 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
|
||||
verifiziert. Nebenbei zwei veraltete Doku-Stellen korrigiert (Gitea-
|
||||
Remote existiert längst, war noch als "nicht vorhanden" dokumentiert).
|
||||
HANDOFF.md/README.md aktualisiert, Commit + Push.
|
||||
|
||||
## Session: Experte-Tab-Politur + M15 (Übersicht-Tab)
|
||||
|
||||
- "machen wir weiter bei den Tooltips im Experte-Tab" → alle
|
||||
`RouterOSFieldSchema.help`-Texte überarbeitet (konkrete Adress-
|
||||
Beispiele wie "192.168.88.1/24"), plus auf Nachfrage fette
|
||||
Sektions-Überschrift und hellblaue Hervorhebung des aktiven
|
||||
Listeneintrags ergänzt (`.listRowBackground` funktionierte im
|
||||
Sidebar-Stil nicht, Hintergrund stattdessen am Zeileninhalt gesetzt).
|
||||
Commit + Push.
|
||||
- "lass uns den Übersicht-Tab nochmal anschauen... Größe... Hover-Infos
|
||||
auf Verbindungslinien" → mehrere Iterationsrunden: Auto-Fit-Versuch
|
||||
(GeometryReader) brach das Scroll-Verhalten, wieder zurückgezogen;
|
||||
echter Geometrie-Bug gefunden (gleiche-Spalte-/Rückwärts-Kanten
|
||||
schossen mit falschen Kontrollpunkten über den Canvas hinaus, links
|
||||
abgeschnitten) und gefixt; Hover-Flackern durch Trägheit + Beschränkung
|
||||
auf die hervorgehobenen Kanten behoben; Klick-vs-Hover-Priorität
|
||||
vertauscht (Klick gewinnt jetzt), da Hover über fremde Karten beim
|
||||
Nachfahren einer Linie die Auswahl verdrängte; Linien-Klick zeigt jetzt
|
||||
volle Erklärung im rechten Panel statt nur Hover-Tooltip.
|
||||
- "neuer Milestone: ... direkt bearbeiten? und rückwirken ... (wie im
|
||||
Experte-Tab)" → M15: `OverviewNode.EditTarget` (Menüpfad/REST-Pfad/
|
||||
RouterOS-`.id`) für IP-Adressen, Pools, DHCP-Server/-Netzwerke/
|
||||
-Clients, Routen, Firewall-Filter-/NAT-Regeln, WireGuard-Peers.
|
||||
"Bearbeiten"-Button öffnet dieselbe `ExpertItemEditView`-Sheet wie der
|
||||
Experte-Tab (dafür `private` entfernt), eigene `ExpertViewModel`-
|
||||
Instanz, kein Code dupliziert. Nutzer bestätigte live: Kommentar an
|
||||
einer Firewall-Regel geändert, "funktioniert". 59 Unit-Tests grün,
|
||||
HANDOFF.md/README.md aktualisiert (u.a. M11s veraltete "rein lesend"-
|
||||
Aussage korrigiert), Commit + Push.
|
||||
|
||||
+89
-5
@@ -752,6 +752,70 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
|
||||
"Lease-Zeit" und Adress-Listen "Ablaufzeit". Nutzer bestätigte danach:
|
||||
"funktioniert".
|
||||
|
||||
## M15: Übersicht-Tab — Diagramm-Fixes + Direktes Bearbeiten
|
||||
|
||||
Auf Nutzerwunsch zwei Themen in einer Session: erst mehrere echte Bugs im
|
||||
bestehenden Übersicht-Diagramm gefunden und gefixt, danach eine neue
|
||||
Fähigkeit ergänzt — ein Knoten direkt anklicken und bearbeiten, statt nur
|
||||
anzusehen.
|
||||
|
||||
**Diagramm-Fixes** (alle live an "Verbindungslinien werden links
|
||||
abgeschnitten" / "Hover springt ständig" diagnostiziert):
|
||||
|
||||
- Erst versucht, das Diagramm beim Öffnen automatisch ans Fenster
|
||||
anzupassen (`GeometryReader` + einmalig gesetzter Scale) — hat den
|
||||
ScrollView-Scrollzustand durcheinandergebracht (Inhalt verschob sich
|
||||
nach dem ersten Layout, oberer Teil nicht mehr erreichbar). **Wieder
|
||||
komplett zurückgezogen**, zurück zur ursprünglichen manuellen
|
||||
Zoom-Steuerung (`-`/`100%`/`+`).
|
||||
- **Echter Geometrie-Bug in `EdgesCanvas`:** VLAN-/Bridge-Port-Kanten
|
||||
laufen innerhalb derselben Spalte (Interface → Interface), eine
|
||||
Firewall/NAT-Kante läuft rückwärts (rechts nach links) — die
|
||||
Kurven-Berechnung nahm aber immer "rechts aus dem Start-, links in den
|
||||
Zielknoten" an, wodurch die Kontrollpunkte bei diesen Fällen weit über
|
||||
den Canvas hinausschossen (auch ins Negative) und am linken Rand
|
||||
abgeschnitten wurden. Fix: Kurvenaufbau jetzt richtungsabhängig
|
||||
(gleiche Spalte → kleine Ausbuchtung rechts, rückwärts → gespiegelte
|
||||
S-Kurve).
|
||||
- **Hover-Instabilität:** Mehrere Kanten verlassen denselben Knotenpunkt
|
||||
am selben Punkt — dort sind sie fast gleich nah am Cursor, kleinste
|
||||
Mausbewegung ließ die Auswahl ständig springen. Fix in zwei Schritten:
|
||||
(1) Trägheit — einmal gehoverte Kante bleibt aktiv, solange der Cursor
|
||||
nah genug bleibt, statt bei jedem Event neu die global nächste zu
|
||||
wählen; (2) Hover-Kandidaten auf die gerade hervorgehobenen
|
||||
(verbundenen) Kanten beschränkt, statt gegen alle Kanten im Diagramm zu
|
||||
prüfen.
|
||||
- **Klick-vs-Hover-Priorität vertauscht:** `highlightNodeID` ließ Hover
|
||||
über Klick gewinnen — beim Nachfahren einer Linie zu ihrem Ziel
|
||||
streifte der Cursor oft andere, unbeteiligte Karten, deren Hover dann
|
||||
die Hervorhebung des angeklickten Knotens verdrängte. Fix: Klick-
|
||||
Auswahl gewinnt jetzt über Hover (Hover dient nur noch als Vorschau,
|
||||
solange nichts ausgewählt ist).
|
||||
- Verbindungslinie anklicken zeigt jetzt ausführliche, verständliche
|
||||
Infos im rechten Panel (`EdgeDetailView`: Klartext-Erklärung pro
|
||||
Verbindungsart, "Von"/"Nach" mit Sprung zum jeweiligen Knoten) — vorher
|
||||
gab es nur den kleinen Hover-Tooltip mit den bloßen Rohdaten.
|
||||
|
||||
**Direktes Bearbeiten:** `OverviewNode.EditTarget` (Menüpfad, REST-Pfad,
|
||||
RouterOS-eigene `.id`) an jedem Knoten, der sich sauber auf genau ein
|
||||
Router-Item abbildet — IP-Adressen, Pools, DHCP-Server/-Netzwerke/
|
||||
-Clients, Routen, Firewall-Filter-/NAT-Regeln, WireGuard-Peers. Klick auf
|
||||
"Bearbeiten" im Detailpanel öffnet dieselbe `ExpertItemEditView`-Sheet
|
||||
wie der Experte-Tab (dafür `private` entfernt, jetzt modulweit
|
||||
sichtbar), über eine eigene `ExpertViewModel`-Instanz — gleiche Felder/
|
||||
Tooltips, gleicher Bestätigungsdialog, gleicher Schreibpfad, keine
|
||||
Duplikation der Schema-/Write-Logik. Nach dem Speichern lädt die
|
||||
Übersicht automatisch neu. Bewusst nicht abgedeckt: physische/VLAN/
|
||||
Bridge-Interfaces (Felder über mehrere typ-spezifische Menüs verteilt)
|
||||
und Adress-Listen-Knoten (fassen mehrere Einträge zusammen, kein 1:1-
|
||||
Bezug zu einem Router-Item).
|
||||
|
||||
**Live gegen Hardware verifiziert** — Nutzer bestätigte Kommentar-
|
||||
Änderung an einer Firewall-Regel über den neuen Bearbeiten-Weg
|
||||
("funktioniert"). Alle 59 Unit-Tests grün, inkl. neuer Tests für
|
||||
`editTarget` (`OverviewGraphTests.
|
||||
testEditableNodesCarryTheirRouterOSMenuAndItemID`).
|
||||
|
||||
## Stand der Milestones
|
||||
|
||||
- ✅ M1–M4: Projektgerüst, Connect, Backup, WAN/LAN/DHCP, VLAN — gegen
|
||||
@@ -795,11 +859,12 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
|
||||
WAN-Ausfall).
|
||||
- ✅ M11: Übersicht-Tab (Diagramm: Interfaces→IP→DHCP/Pools→Routen→
|
||||
Firewall/NAT, echte Referenz-Kanten, nach Verbindungsart eingefärbt,
|
||||
Hover/Klick-Hervorhebung) — **live gegen Hardware verifiziert**, rein
|
||||
lesend (kein `apply()`), vom Nutzer bestätigt ("das gefällt mir schon
|
||||
sehr gut"). Deckt bewusst nicht ab: VPN/WLAN-Sicherheitsprofile/Queues/
|
||||
System/Werkzeuge/Mangle/Raw (siehe `OverviewGraph.unmappedAreas`),
|
||||
weiterhin nur über den Experte-Tab erreichbar.
|
||||
Hover/Klick-Hervorhebung) — **live gegen Hardware verifiziert**, vom
|
||||
Nutzer bestätigt ("das gefällt mir schon sehr gut"). War anfangs rein
|
||||
lesend, seit M15 kann ein Knoten direkt bearbeitet werden (siehe
|
||||
unten) — deckt weiterhin bewusst nicht ab: VPN/WLAN-Sicherheitsprofile/
|
||||
Queues/System/Werkzeuge/Mangle/Raw (siehe `OverviewGraph.
|
||||
unmappedAreas`), weiterhin nur über den Experte-Tab erreichbar.
|
||||
- ✅ M12: Geräte-Tab (LAN-Scanner: DHCP-Leases+ARP+Bridge-Host, eine
|
||||
Tabelle pro physischem Port, "Feste IP zuweisen" für dynamische Leases,
|
||||
"Feste Zuweisung entfernen" für den Rückweg) — **live gegen Hardware
|
||||
@@ -844,6 +909,15 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
|
||||
Wizard/Experte-Tab. Dark Mode auf Nachfrage geprüft und ohne
|
||||
Codeänderung bestätigt funktionierend (keine feste Farbwerte/
|
||||
Appearance-Überschreibung im Code).
|
||||
- ✅ M15: Übersicht-Tab — mehrere Diagramm-Bugs gefixt (Verbindungslinien
|
||||
am linken Rand abgeschnitten bei gleicher-Spalte-/Rückwärts-Kanten,
|
||||
nervöses Hover, Klick-Auswahl wurde von Hover unbeteiligter Karten
|
||||
verdrängt) plus neue Fähigkeit: Knoten (IP-Adresse, Pool, DHCP-Server/
|
||||
-Netzwerk/-Client, Route, Firewall-Filter-/NAT-Regel, WireGuard-Peer)
|
||||
direkt über dieselbe Sheet wie im Experte-Tab bearbeiten und
|
||||
zurückschreiben. **Live gegen Hardware verifiziert** — Nutzer
|
||||
bestätigte Kommentar-Änderung an einer Firewall-Regel über den neuen
|
||||
Weg ("funktioniert").
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
@@ -912,6 +986,16 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
|
||||
Hintergrund direkt am Zeileninhalt (`Text` in `RoundedRectangle`-
|
||||
Fill) gesetzt. Alles vom Nutzer live in Xcode bestätigt ("passt
|
||||
gut").
|
||||
11. **M15-Bearbeiten-Funktion deckt Interfaces und Adress-Listen bewusst
|
||||
nicht ab** — physische/VLAN/Bridge-Interfaces verteilen ihre Felder
|
||||
über mehrere typ-spezifische RouterOS-Menüs (`/interface ethernet`,
|
||||
`/interface bridge`, `/interface vlan`, ...), ein sauberes
|
||||
`OverviewNode.EditTarget` bräuchte pro Interface-Typ eine eigene
|
||||
Zuordnung statt der generischen `/interface`. Adress-Listen-Knoten
|
||||
fassen mehrere Einträge in einem Knoten zusammen, kein 1:1-Bezug zu
|
||||
einem Router-Item — bräuchte eine andere UI (Liste statt Formular).
|
||||
Beides bei Bedarf nachrüstbar, für diese Session bewusst
|
||||
zurückgestellt.
|
||||
|
||||
Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe
|
||||
oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15.
|
||||
|
||||
@@ -34,7 +34,12 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
|
||||
(Interfaces → IP-Adressen → DHCP/Pools → Routen → Firewall/NAT), mit
|
||||
echten, aus RouterOS' eigenen Referenzfeldern abgeleiteten
|
||||
Verbindungslinien, nach Verbindungsart eingefärbt, Hover/Klick hebt
|
||||
Zusammenhänge hervor. Rein lesend.
|
||||
Zusammenhänge hervor, Klick auf eine Verbindungslinie zeigt eine
|
||||
verständliche Erklärung samt Sprung zu den verbundenen Elementen.
|
||||
Ein Knoten (IP-Adresse, Pool, DHCP-Server/-Netzwerk/-Client, Route,
|
||||
Firewall-Filter/-NAT-Regel, WireGuard-Peer) lässt sich direkt über
|
||||
denselben Dialog wie im Experte-Tab bearbeiten und ans Gerät
|
||||
zurückschreiben.
|
||||
- **Geräte** — LAN-Scanner (DHCP-Leases + ARP + Bridge-Host-Tabelle),
|
||||
eine Tabelle pro physischem Port. Rechtsklick weist einem Gerät eine
|
||||
feste IP-Adresse zu (RouterOS' "Make Static") oder entfernt sie wieder.
|
||||
@@ -101,6 +106,7 @@ darauf bauen Übersicht-, Geräte- und Experte-Tab gemeinsam auf.
|
||||
| M12 | Geräte-Tab (LAN-Scanner + Static-IP) | ✅ live verifiziert |
|
||||
| M13 | Backup-Wiederherstellung | ✅ live verifiziert |
|
||||
| M14 | Update-Check (Software + Firmware) | ✅ live verifiziert |
|
||||
| M15 | Übersicht-Tab: Diagramm-Fixes + Direktes Bearbeiten | ✅ live verifiziert |
|
||||
|
||||
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
|
||||
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
|
||||
|
||||
@@ -14,6 +14,17 @@ struct OverviewNode: Identifiable, Equatable {
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
/// Which live RouterOS item this node was built from, and where to write changes back to —
|
||||
/// nil for nodes that don't map to exactly one editable item (e.g. an address-list node folds
|
||||
/// several list entries together). Lets the Übersicht tab open the same edit form the
|
||||
/// Experte-Tab uses, pre-filled, instead of duplicating its field schemas/write logic.
|
||||
struct EditTarget: Equatable {
|
||||
let menuPath: String
|
||||
let restPath: String
|
||||
/// RouterOS' own ".id" for this item (e.g. "*7") — required to `set` the right one.
|
||||
let itemID: String
|
||||
}
|
||||
|
||||
let id: String
|
||||
let category: Category
|
||||
let kind: String
|
||||
@@ -21,11 +32,26 @@ struct OverviewNode: Identifiable, Equatable {
|
||||
let subtitle: String?
|
||||
/// Full raw fields, shown in the detail panel when this node is selected.
|
||||
let detail: [(key: String, value: String)]
|
||||
let editTarget: EditTarget?
|
||||
|
||||
init(
|
||||
id: String, category: Category, kind: String, title: String, subtitle: String?,
|
||||
detail: [(key: String, value: String)], editTarget: EditTarget? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.category = category
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.detail = detail
|
||||
self.editTarget = editTarget
|
||||
}
|
||||
|
||||
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)
|
||||
&& lhs.editTarget == rhs.editTarget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,10 @@ struct ExpertMenuDetailView: View {
|
||||
|
||||
/// Add/edit sheet: curated fields (with tooltips) from the schema, plus a free-form "weitere
|
||||
/// Parameter" list for anything the schema doesn't curate — so every field RouterOS actually
|
||||
/// supports stays reachable even where this app hasn't described it yet.
|
||||
private struct ExpertItemEditView: View {
|
||||
/// supports stays reachable even where this app hasn't described it yet. Not private: the
|
||||
/// Übersicht tab reuses this same sheet (with its own `ExpertViewModel`) so clicking an editable
|
||||
/// node there writes back through the exact same form/command path as the Experte-Tab.
|
||||
struct ExpertItemEditView: View {
|
||||
@ObservedObject var viewModel: ExpertViewModel
|
||||
let schema: RouterOSMenuSchema
|
||||
let isNew: Bool
|
||||
|
||||
@@ -8,21 +8,61 @@ import SwiftUI
|
||||
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?
|
||||
|
||||
/// 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 }
|
||||
/// 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 {
|
||||
@@ -63,6 +103,18 @@ struct OverviewView: View {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,37 +136,54 @@ struct OverviewView: View {
|
||||
} else {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
EdgesCanvas(graph: viewModel.graph, positions: layout.positions, highlightNodeID: highlightNodeID)
|
||||
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(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)
|
||||
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)
|
||||
@@ -125,8 +194,13 @@ struct OverviewView: View {
|
||||
|
||||
@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)
|
||||
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()
|
||||
}
|
||||
@@ -136,36 +210,283 @@ struct OverviewView: View {
|
||||
/// 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.
|
||||
/// 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 edge in graph.edges {
|
||||
guard let from = positions[edge.from], let to = positions[edge.to] else { continue }
|
||||
for geometry in geometries {
|
||||
let edge = geometry.edge
|
||||
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)
|
||||
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
|
||||
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)
|
||||
)
|
||||
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(isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6))),
|
||||
lineWidth: isConnected ? 2.6 : 1.3
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +531,7 @@ private struct NodeCardView: View {
|
||||
private struct NodeDetailView: View {
|
||||
let node: OverviewNode
|
||||
let graph: OverviewGraph
|
||||
let onEdit: (OverviewNode) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -218,6 +540,15 @@ private struct NodeDetailView: View {
|
||||
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)
|
||||
@@ -328,6 +659,30 @@ 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"
|
||||
|
||||
@@ -124,7 +124,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .interface, kind: "wireguard-peer",
|
||||
title: "Peer", subtitle: allowed,
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers", itemID: item.id)
|
||||
))
|
||||
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .wireguardPeer))
|
||||
}
|
||||
@@ -137,7 +138,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .ipAddress, kind: "address",
|
||||
title: address, subtitle: iface,
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/ip address", restPath: "ip/address", itemID: item.id)
|
||||
))
|
||||
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .ipAddress))
|
||||
if let network = item.fields["network"] {
|
||||
@@ -151,7 +153,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: nodeID(.service, "pool:" + name), category: .service, kind: "pool",
|
||||
title: name, subtitle: item.fields["ranges"],
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/ip pool", restPath: "ip/pool", itemID: item.id)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -162,7 +165,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .service, kind: "dhcp-server",
|
||||
title: name, subtitle: "DHCP-Server",
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server", itemID: item.id)
|
||||
))
|
||||
if let iface = item.fields["interface"] {
|
||||
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
|
||||
@@ -180,7 +184,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .service, kind: "dhcp-network",
|
||||
title: address, subtitle: item.fields["gateway"].map { "Gateway \($0)" },
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network", itemID: item.id)
|
||||
))
|
||||
let networkPart = address.split(separator: "/").first.map(String.init) ?? address
|
||||
if let addressID = networkToAddressID[networkPart] {
|
||||
@@ -195,7 +200,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .service, kind: "dhcp-client",
|
||||
title: "DHCP-Client", subtitle: iface,
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client", itemID: item.id)
|
||||
))
|
||||
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
|
||||
}
|
||||
@@ -210,7 +216,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .route, kind: "route",
|
||||
title: dst, subtitle: "über \(gateway)",
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: "/ip route", restPath: "ip/route", itemID: item.id)
|
||||
))
|
||||
if knownInterfaceNames.contains(gateway) {
|
||||
edges.append(OverviewEdge(from: nodeID(.interface, gateway), to: id, label: nil, kind: .route))
|
||||
@@ -233,14 +240,14 @@ final class OverviewViewModel: ObservableObject {
|
||||
|
||||
// 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)
|
||||
appendFirewallRules(filterRules, kind: "filter", label: "Filter", menuPath: "/ip firewall filter", restPath: "ip/firewall/filter", into: &nodes, edges: &edges)
|
||||
appendFirewallRules(natRules, kind: "nat", label: "NAT", menuPath: "/ip firewall nat", restPath: "ip/firewall/nat", into: &nodes, edges: &edges)
|
||||
|
||||
return OverviewGraph(nodes: nodes, edges: edges)
|
||||
}
|
||||
|
||||
private nonisolated static func appendFirewallRules(
|
||||
_ rules: [RouterOSMenuItem], kind: String, label: String,
|
||||
_ rules: [RouterOSMenuItem], kind: String, label: String, menuPath: String, restPath: String,
|
||||
into nodes: inout [OverviewNode], edges: inout [OverviewEdge]
|
||||
) {
|
||||
for (index, item) in rules.enumerated() {
|
||||
@@ -250,7 +257,8 @@ final class OverviewViewModel: ObservableObject {
|
||||
nodes.append(OverviewNode(
|
||||
id: id, category: .firewall, kind: kind,
|
||||
title: "\(label): \(action)", subtitle: item.fields["comment"] ?? chain,
|
||||
detail: sortedDetail(item.fields)
|
||||
detail: sortedDetail(item.fields),
|
||||
editTarget: .init(menuPath: menuPath, restPath: restPath, itemID: item.id)
|
||||
))
|
||||
if let inIface = item.fields["in-interface"], !inIface.isEmpty {
|
||||
edges.append(OverviewEdge(from: nodeID(.interface, inIface), to: id, label: nil, kind: .firewallInterface))
|
||||
|
||||
@@ -102,4 +102,37 @@ final class OverviewGraphTests: XCTestCase {
|
||||
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" })
|
||||
}
|
||||
|
||||
func testEditableNodesCarryTheirRouterOSMenuAndItemID() {
|
||||
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"], id: "*1")],
|
||||
pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"], id: "*2")],
|
||||
dhcpServers: [], dhcpNetworks: [], dhcpClients: [],
|
||||
routes: [item(["dst-address": "0.0.0.0/0", "gateway": "192.168.88.254"], id: "*3")],
|
||||
filterRules: [item(["chain": "forward", "action": "drop"], id: "*4")],
|
||||
natRules: [item(["chain": "srcnat", "action": "masquerade"], id: "*5")],
|
||||
addressLists: [item(["list": "gesperrt", "address": "10.0.0.5"], id: "*6")]
|
||||
)
|
||||
|
||||
let address = graph.nodes.first { $0.id == "IP-Adressen:192.168.88.1/24" }
|
||||
XCTAssertEqual(address?.editTarget, OverviewNode.EditTarget(menuPath: "/ip address", restPath: "ip/address", itemID: "*1"))
|
||||
|
||||
let pool = graph.nodes.first { $0.id == "Pools & DHCP:pool:pool1" }
|
||||
XCTAssertEqual(pool?.editTarget, OverviewNode.EditTarget(menuPath: "/ip pool", restPath: "ip/pool", itemID: "*2"))
|
||||
|
||||
let route = graph.nodes.first { $0.id == "route:0" }
|
||||
XCTAssertEqual(route?.editTarget, OverviewNode.EditTarget(menuPath: "/ip route", restPath: "ip/route", itemID: "*3"))
|
||||
|
||||
let filter = graph.nodes.first { $0.id == "filter:0" }
|
||||
XCTAssertEqual(filter?.editTarget, OverviewNode.EditTarget(menuPath: "/ip firewall filter", restPath: "ip/firewall/filter", itemID: "*4"))
|
||||
|
||||
let nat = graph.nodes.first { $0.id == "nat:0" }
|
||||
XCTAssertEqual(nat?.editTarget, OverviewNode.EditTarget(menuPath: "/ip firewall nat", restPath: "ip/firewall/nat", itemID: "*5"))
|
||||
|
||||
// Address-list nodes fold multiple entries together — no single item to write back to.
|
||||
let addressList = graph.nodes.first { $0.id == "Firewall & NAT:addrlist:gesperrt" }
|
||||
XCTAssertNil(addressList?.editTarget)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user