M12: Geräte-Tab — LAN-Scanner mit Static-IP-Zuweisung

Neuer Tab: eine Tabelle pro physischem Ethernet/WLAN-Port mit den dort
gefundenen Geräten (Name, IP, MAC, Fest/Dynamisch/Kein-DHCP), gebaut aus
DHCP-Leases + ARP + Bridge-Host-Tabelle. Rechtsklick auf ein dynamisches
Gerät -> "Feste IP zuweisen" (RouterOS' "Make Static", per
/ip dhcp-server lease make-static), mit Bestätigungsdialog und
Session-Backup vor dem ersten Schreibvorgang (geteilter Mechanismus mit
dem Experte-Tab).

Vier reale Bugs live gefunden und gefixt (siehe HANDOFF.md Bug 14-17):

- "print terse" gibt das "dynamic"-Feld von /ip dhcp-server lease nie
  aus, in keinem Zustand -> Status kommt jetzt über RouterOS' find/get
  gegen die interne Eigenschaft, nicht aus gelesenen Feldern.
- fetchMenuItems' .id-Positionsüberlagerung ordnete für dieses Menü die
  falsche .id der falschen Zeile zu -> Erkennung und make-static-Ziel
  laufen jetzt über die MAC-Adresse statt .id.
- Ein SwiftUI-.confirmationDialog löschte sein eigenes Ziel-Objekt vor
  der Ausführung der bestätigten Aktion (Setter feuert bei jedem
  Knopfdruck, nicht nur Abbrechen) -> Dialog-Sichtbarkeit und
  Nutzlast entkoppelt, wie in BackupListView.
- Die eigene Verifikations-Abfrage (get [find ...] feld als ein
  kombinierter Befehl) war selbst eine nie verifizierte Annahme und
  lieferte falsche Negative -> ersetzt durch :foreach aus zwei einzeln
  bestätigten Bausteinen (find, get <id> feld).

RouterOSCommand bekommt einen neuen .action-Operationstyp für
RouterOS-"Menü-spezifische Befehle" jenseits von add/set/remove (aktuell
nur make-static). HANDOFF.md/CHATLOG.md mit allen vier Bugs, neuen
Milestones M11/M12 und offenen Punkten aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
This commit is contained in:
Kay
2026-09-14 13:55:46 +02:00
co-authored by Claude Sonnet 5
parent 10f30a8a7f
commit e2b303963a
14 changed files with 1155 additions and 27 deletions
+74
View File
@@ -432,6 +432,60 @@ nicht gegen Hardware.
echte Nutzer-Konfiguration, kein Test-Überbleibsel) und Memory
aktualisiert. Sitzung beendet.
## Session: Übersicht-Tab (M11) + Geräte-Tab (M12)
- "jetzt brauche ich eine Übersicht, welche Geräte (Name, IP-Adresse,
MAC-Adresse) an welchem Port hängen. so eine art LAN-scanner. ...
Rechtsklick->static-IP zuweisen." — nach dem Übersicht-Tab (siehe
unten) neuer "Geräte"-Tab gewünscht.
- Zuerst aber: "ich hätte gerne eine Übersicht über alle Einstellungen
des Router, quasi einen IST-Zustand. ... erstelle baue mir noch
zusätzlich einen Punkt ein, gerne in der Leiste oben, wo ich die
komplette derzeitige Konfiguration einfach und verständlich sehen kann.
grafisch mit Linien, wo was abhängig ist" → neuer "Übersicht"-Tab
(M11): Diagramm mit Spalten (Interfaces→IP→DHCP/Pools→Routen→
Firewall/NAT), Kanten aus RouterOS' eigenen Referenzfeldern, rein
lesend. "das gefällt mir schon sehr gut, kannst du die entsprechenden
Verbindung logisch farblich trennen? ein Hover-effekt beim anklicken
oder darüberfahren mit der mouse ware ein super feature." → 8
Verbindungsarten farblich getrennt, Hover/Klick hebt Kanten hervor und
blendet den Rest ab. "ok, das lassen wir so." → "ok committen" →
Commit `10f30a8`.
- Geräte-Tab (M12) gebaut: DHCP-Leases + ARP + Bridge-Host-Tabelle
verschmolzen, Rechtsklick "Feste IP zuweisen" (`make-static`). Vorher
RouterOS-Doku per Recherche-Agent geprüft (Lease-Felder, `make-static`-
Syntax, ARP/Bridge-Host-Felder) statt zu raten.
- "der anstaz ist gut, ich hätte gerne pro ehternet-port eine
tabellarische Aufstellung. weiterhin bekommen die Geräte per DHCP eine
IP-Adresse. in der Übersicht steht aber 'bereits fest zugewiesen'.
prüfe das bitte." — zwei Themen: Layout auf eine Tabelle pro Port
umgebaut (inkl. leerer Ports), und der Fest/Dynamisch-Bug wurde zur
langen Fehlersuche:
- Erster Fund: ARP-only-Geräte (kein Lease-Treffer) waren hart auf
"Fest" gesetzt — gefixt (drittes Zustandsbild "Kein DHCP").
- Zweiter Fund (Bug 14): das `dynamic`-Feld erscheint über
`print terse` nie, egal in welchem Zustand — Nutzer schickte
mehrfach Rohdaten zur Bestätigung. Erkennung auf RouterOS' `find`
umgestellt.
- Dritter Fund (Bug 15): `.id`-Positionsüberlagerung ordnete die feste
`.id` der falschen (noch dynamischen) Zeile zu — "die iMac-Zeile
zeigt 'fest', obwohl sie laut deinem letzten print noch ein D
hatte". Auf MAC-Adress-Matching umgestellt.
- Vierter Fund (Bug 16): Bestätigungsdialog löschte sein eigenes Ziel
vor der Ausführung (SwiftUI-`.confirmationDialog`-Race) — "keine
Fehlermeldung, auch nicht rot" trotz de-facto wirkungslosem Befehl.
Dialog-Sichtbarkeit und Ziel-Objekt entkoppelt.
- Fünfter Fund (Bug 17): eigene Verifikations-Abfrage (`get [find ...]
feld`) war selbst eine nie verifizierte Annahme und lieferte falsche
Negative — per Winbox bestätigt, dass die Zuweisung tatsächlich
funktionierte, während die App-Prüfung "weiterhin dynamisch" meldete.
Ersetzt durch `:foreach`+`find`+`get <id> feld` aus zwei einzeln
bestätigten Bausteinen.
- "das funktioniert jetzt super." — Feature bestätigt.
- "ja, committen und HANDOFF.md/CHATLOG.md aktualisieren" → dieser
Eintrag, HANDOFF.md (Bug 1417, M11/M12, aktualisierte Bekannte
Einschränkungen/Nächste Schritte) aktualisiert, Commit erstellt.
## Stand am Ende dieser Session (2026-09-14)
M9 (Einfach/Experte-Modus) und M10 (Experte-Tab: generischer Motor +
@@ -453,3 +507,23 @@ geprüft, `dhcp5`/`pool5` auf `ether5` als Test-Überbleibsel noch zu
klären, Dauer-Editor könnte auf weitere Zeitfelder ausgeweitet werden,
plus alle bereits vorher offenen Punkte (M7-Rest, M8-Hardware-Test,
Firewall-WAN-Port-Recheck).
## Stand am Ende dieser Session — Übersicht-Tab (M11) + Geräte-Tab (M12)
M11 (Übersicht-Tab: Diagramm des IST-Zustands, farblich getrennte
Verbindungsarten, Hover/Klick-Hervorhebung) und M12 (Geräte-Tab:
LAN-Scanner nach Port gruppiert, "Feste IP zuweisen") gebaut und beide
**live vom Nutzer bestätigt**. M12 brauchte dafür vier gefundene und
gefixte Bugs (Bug 1417, siehe HANDOFF.md): `print terse` gibt das
`dynamic`-Feld bei DHCP-Leases nie aus; `fetchMenuItems`s `.id`-Positions-
Überlagerung ordnete für dieses Menü die falsche Zeile zu; ein SwiftUI-
`.confirmationDialog` löschte sein eigenes Ziel vor der Ausführung; eine
eigene, nie verifizierte kombinierte RouterOS-Skript-Syntax lieferte
falsche Negative. Alle vier zusammen ergaben eine ungewöhnlich lange,
mehrstufige Fehlersuche mit wiederholten Rohdaten-Abgleichen und direkten
Router-Gegenchecks (SSH-CLI + Winbox) durch den Nutzer. Commit für M11
separat (`10f30a8`), M12 + HANDOFF/CHATLOG-Update in diesem Commit.
Offen: "Zurück auf dynamisch"-Aktion fehlt noch im Geräte-Tab; ob dieselbe
`.id`-Überlagerung auch im Experte-Tab bei anderen Menüs falsch zuordnen
kann, ist ungeprüft; alle bereits vorher offenen Punkte unverändert
(siehe HANDOFF.md Nächste Schritte).
+152 -26
View File
@@ -6,25 +6,28 @@ teilweise gegen zwei echte physische Mikrotik-Testgeräte verifiziert
kritischer Parser-Bug gefunden und gefixt, siehe unten). Danach noch
mehrere Nutzer-Wünsche umgesetzt: Tooltips, wählbarer Backup-Ordner,
Schnell-Backup im Verbinden-Tab, "Werkseinstellungen wiederherstellen",
eigenes App-Icon.
eigenes App-Icon. Anschließend M9 (Einfach/Experte-Modusschalter im
Wizard) und M10 (Experte-Tab: kuratiertes Formular für einen großen Teil
von RouterOS plus generischer "eigener Menüpfad"-Zugriff für den Rest) —
beide live gegen den hEX-Testrouter verifiziert, sieben Bugs dabei
gefunden und gefixt (Bug 713). Alles bis hierhin committed
(`c9ecd3a`).
Neu in dieser Session: Einrichten-Wizard hat jetzt einen Einfach/Experte-
Modusschalter (M9), und ein komplett neuer "Experte"-Tab (M10) gibt
Formularzugriff auf einen großen, aber bewusst kuratierten Ausschnitt von
RouterOS (Firewall/NAT/Mangle/Raw/Adress-Listen mit vollen Tooltips,
plus Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge, plus ein "eigener
Menüpfad"-Feld für alles, was nicht kuratiert ist). Live gegen den
hEX-Testrouter (RouterOS 7.24.2) verifiziert — erst per direktem SSH,
dann zusätzlich vom Nutzer selbst in der laufenden App (VLAN anlegen,
DHCP-Server mit Adress-Pool zuweisen, löschen) — dabei **sieben** echte
Bugs gefunden und gefixt (Bug 713 unten, nach jedem Fund Release neu
gebaut und deployt). **Bug 10 ist der wichtigste Fund dieser Session:**
RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen trotzdem Exit-Code 0
zurück — betraf `apply()` app-weit, nicht nur den Experte-Tab (siehe
unten). Release-Build unter `/Applications/RouterOS Assistant.app` ist
auf aktuellem Arbeitsstand (alle M9/M10-Änderungen + alle Bugfixes
deployt und vom Nutzer bestätigt) — **noch nicht committed** (siehe
`git status`), letzter Commit ist weiterhin `97fc216`.
**Neu in dieser Session:** zwei weitere Tabs. **M11 "Übersicht"** zeigt
die komplette aktuelle Router-Konfiguration als Diagramm (Interfaces →
IP-Adressen → DHCP/Pools → Routen → Firewall/NAT, mit echten, aus
RouterOS' eigenen Referenzfeldern abgeleiteten Verbindungslinien,
farblich nach Verbindungsart getrennt, Hover/Klick hebt Zusammenhänge
hervor). **M12 "Geräte"** ist ein LAN-Scanner (DHCP-Leases + ARP +
Bridge-Host-Tabelle, gruppiert als eine Tabelle pro physischem Port) mit
Rechtsklick-Aktion "Feste IP zuweisen" (RouterOS' "Make Static", per
`/ip dhcp-server lease make-static`). Beide Tabs live gegen den
hEX-Testrouter verifiziert — M12 dabei besonders gründlich, weil die
Fest/Dynamisch-Erkennung und die Zuweisung selbst sich als überraschend
zäh erwiesen: **vier** echte Bugs gefunden und gefixt (Bug 1417 unten),
am Ende vom Nutzer selbst bestätigt ("das funktioniert jetzt super").
Release-Build unter `/Applications/RouterOS Assistant.app` ist auf
aktuellem Stand.
## Ziel
@@ -81,10 +84,12 @@ RouterOSAssistant/
RouterOSMenuItem.swift — generisches Item (id + [String:String] fields) für JEDEN RouterOS-Menüpfad (M10)
RouterOSSchema.swift — RouterOSFieldSchema/RouterOSMenuSchema/RouterOSMenuCategory: kuratiertes Formular-Schema fürs Experte-Tool (M10)
RouterOSSchemaCatalog.swift — die eigentliche Menü-Liste: Firewall-Familie voll kuratiert, Rest teils kuratiert/teils generisch (M10)
OverviewGraph.swift — OverviewNode/OverviewEdge/OverviewEdgeKind/DevicePortGroup: reine Datenmodelle fürs Übersicht- und Geräte-Diagramm (M11/M12)
LanDevice.swift — ein LAN-Gerät (DHCP-Lease + ARP + Bridge-Host verschmolzen), fürs Geräte-Tab (M12)
Networking/
RouterOSTransport.swift — Protocol: connect/fetchDeviceInfo/fetchInterfaces/fetchFirewallRuleCounts/fetchMenuItems/apply/disconnect
RestTransport.swift — REST-API (RouterOS ≥7.1), Zertifikats-TOFU, .set via GET+PATCH (findItemID), .remove via GET+DELETE, fetchMenuItems (Array- oder Singleton-Objekt)
SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing, resetToFactoryDefaults(), eigene Hostkey-TOFU, fetchMenuItems (generisch, inkl. Singleton-Fallback)
RouterOSTransport.swift — Protocol: connect/fetchDeviceInfo/fetchInterfaces/fetchFirewallRuleCounts/fetchMenuItems/fetchFieldValues/apply/disconnect
RestTransport.swift — REST-API (RouterOS ≥7.1), Zertifikats-TOFU, .set via GET+PATCH (findItemID), .remove via GET+DELETE, fetchMenuItems (Array- oder Singleton-Objekt), fetchFieldValues (Query-Filter, unverifiziert)
SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing, resetToFactoryDefaults(), eigene Hostkey-TOFU, fetchMenuItems (generisch, inkl. Singleton-Fallback), fetchFieldValues (`:foreach`+`find`+`get`, M12/Bug 14+17)
RouterOSCliParser.swift — parst `/system resource print`, `/interface print terse`, und generisch jeden Menüpfad (parseGenericItems/parseSingletonItem, M10)
CertificateTrustStore.swift / CertificateFingerprint.swift — TOFU für REST-Zertifikate
SSHHostKeyTrustStore.swift / SSHHostKeyFingerprint.swift — TOFU für SSH-Hostkeys (M7)
@@ -97,6 +102,8 @@ RouterOSAssistant/
Wizard/Steps/Connect/ — Verbinden-Tab (inkl. Schnell-Backup-Button nach erfolgreicher Verbindung)
Wizard/Steps/Setup/ — Einrichten-Tab: Mode → Wan → Lan → (Vlan nur Experte) → Wifi → Firewall → Review/Apply
Expert/ — Experte-Tab (M10): ExpertView (Kategorie-/Menüliste + "eigener Pfad"), ExpertViewModel, ExpertMenuDetailView (Item-Liste + Add/Edit-Sheet)
Overview/ — Übersicht-Tab (M11): OverviewView (Diagramm+Legende+Detailpanel), OverviewViewModel (buildGraph, rein), OverviewLayout (Spalten/Zeilen-Geometrie)
Devices/ — Geräte-Tab (M12): DevicesView (Port-Tabellen+Rechtsklick+Rohdaten-Sheet), DevicesViewModel (buildDevices/buildPortGroups, rein; confirmStaticAssignment)
Backup/ — Sicherungen-Tab (Ordner wählen, Gefahrenzone: Werkseinstellungen wiederherstellen)
Resources/Assets.xcassets/AppIcon.appiconset/ — App-Icon "Signal Router" (16px1024px)
RouterOSAssistantTests/ — reine Unit-Tests (Command-Builder, CLI-Parser, Fallback-Logik via Mock-Transport)
@@ -244,6 +251,59 @@ erreichen (siehe Bug 1 unten).
`ExpertItemEditView`) komplett entfernt, da überflüssig. Danach vom
Nutzer selbst in der App bestätigt: neu angelegtes VLAN erscheint
sofort in der DHCP-Server-Interface-Auswahl.
14. **`/ip dhcp-server lease` gibt das `dynamic`-Feld über `print terse`
nie aus, in keinem Zustand** — beim Bau des Geräte-Tabs (M12) zwei
falsche Annahmen nacheinander gemacht: erst "fehlt = fest" (Standard-
Annahme falsch herum, jedes echte DHCP-Gerät zeigte "Fest"), dann
nach Korrektur "fehlt = dynamisch, `dynamic=no` erscheint explizit
bei festen Leases" — beides falsch. Live an echten Rohdaten geprüft
(Nutzer schickte per "Rohdaten anzeigen" wiederholt komplette
Feldlisten, u.a. für einen zweifelsfrei dynamischen UND einen
zweifelsfrei festen Eintrag): das Feld taucht in `terse` überhaupt
nie auf, unabhängig vom Status — nur `/ip dhcp-server lease print`
(ohne terse) zeigt es als Flag-Buchstabe `D`. Fix: Status kommt nicht
mehr aus gelesenen Feldern, sondern aus einer gezielten Abfrage gegen
RouterOS' interne Eigenschaft (`RouterOSTransport.fetchFieldValues`,
siehe Bug 17 für die korrekte Syntax).
15. **`fetchMenuItems`s `.id`-Positions-Überlagerung ordnete bei
`/ip dhcp-server lease` die falsche `.id` der falschen Zeile zu** —
zwei Leases (iMac dynamisch, Laptop nach Umstellung fest), aber die
App zeigte beide als "Fest". Ursache: `fetchMenuItems` liest die
`.id`s über einen separaten `:put [<Pfad> find]`-Aufruf und ordnet
sie *nach Position* den `print terse`-Zeilen zu (Mechanismus aus
Bug 8, dort für ein anderes Menü live bestätigt) — für dieses Menü
stimmte die Reihenfolge zwischen beiden Aufrufen offenbar nicht
überein, die feste `.id` landete auf der iMac-Zeile. Fix ausschließlich
im Geräte-Tab: Erkennung *und* der `make-static`-Zielauswahl laufen
jetzt komplett über die MAC-Adresse (steht direkt in derselben
`terse`-Zeile, kein Cross-Command-Abgleich nötig) statt über `.id`.
**Nicht behoben:** derselbe `.id`-Überlagerungsmechanismus wird auch
vom Experte-Tab für `.set`/`.remove` auf beliebigen Menüs benutzt —
ob das dort ebenfalls (menü- oder situationsabhängig) falsch zuordnen
kann, ist ungeprüft. Siehe Nächste Schritte.
16. **SwiftUI-`.confirmationDialog` löschte sein eigenes Ziel, bevor die
bestätigte Aktion es lesen konnte** — "Feste IP zuweisen" tat sichtbar
nichts: kein Fehler, kein Erfolg, Dialog schloss normal. Ursache:
`isPresented` war eine berechnete `Binding`, deren Setter bei *jedem*
Schließen (auch durch den bestätigenden Knopf selbst — SwiftUI ruft
den Setter mit `false` bei jedem Tap in einem `.confirmationDialog`,
unabhängig vom gedrückten Knopf) `pendingStaticAssignment = nil`
setzte — also genau das Objekt, das `confirmStaticAssignment()`
Sekundenbruchteile später lesen wollte. Fix: Dialog-Sichtbarkeit
(`showStaticConfirmation`, ein einfaches `@State Bool`) und Ziel-
Objekt (`pendingStaticAssignment`) komplett entkoppelt — genau das
Muster, das `BackupListView`s Werksreset-Dialog schon die ganze Zeit
korrekt verwendet (eigenes `@State`-Bool statt abgeleitetem Binding).
17. **Ein erster Fix-Versuch für Bug 14 geriet zu einer weiteren, nie
verifizierten Befehls-Annahme** — `<Menü> get [find feld=wert]
rückgabefeld]` als *ein* kombinierter Befehl geraten (Annahme: `get`
akzeptiert eine `find`-Ergebnisliste direkt). Live widerlegt: eine
per Winbox bestätigte, echte feste Zuweisung wurde von dieser Abfrage
trotzdem nicht gefunden (falsches Negativ). Fix: nur noch aus zwei
einzeln diese Session mehrfach bestätigten Bausteinen zusammengesetzt
— nacktes `find` mit einer Bedingung, plus `get <id> feld` auf eine
einzelne bekannte `.id`, verbunden über eine `:foreach`-Schleife
(`RouterOSTransport.fetchFieldValues`/`SSHTransport.fetchFieldValues`).
**Lehren:** Citadel/NIOSSH-Fehler immer mit `String(describing:)` loggen,
nie `.localizedDescription`. Jede View, die ein ObservableObject aus einem
@@ -265,7 +325,24 @@ Jeder Zustand, der sich während der Nutzung ändern kann (Interface-Liste
nach Anlegen eines VLANs), gehört live nachgeladen, nicht einmalig beim
Verbinden gecacht (Bug 13) — dasselbe Prinzip wie `crossReferenceOptions`
für Pool/Profil/Skript, nur nicht von Anfang an konsequent auf alle
Auswahllisten angewendet.
Auswahllisten angewendet. **`print terse` gibt nicht jede Eigenschaft
als Feld aus** — manche (z.B. `dynamic` bei DHCP-Leases) erscheinen nur
als Flag-Buchstabe im menschenlesbaren `print`, nie als `key=value`
(Bug 14); wenn ein Feld partout nicht auftaucht, RouterOS' `find`/`get`
gegen die interne Eigenschaft fragen statt weiter nach Formulierungen im
Text zu suchen, die es nicht gibt. **`fetchMenuItems`s `.id`-Überlagerung
ist nicht für jedes Menü verlässlich** (Bug 15) — bei mehreren möglichen
Identifikatoren lieber über ein Feld matchen, das direkt in derselben
Zeile steht (MAC, Name), statt über eine per Position aus einem zweiten
Befehl zugeordnete `.id`. **Jede kombinierte RouterOS-Skript-Syntax vor
dem Einsatz live prüfen, auch wenn die Einzelteile schon bestätigt sind**
(Bug 17) — `find` allein und `get <id> feld` allein zu kennen heißt
nicht, dass `get [find ...] feld` als eine Einheit genauso funktioniert.
**Bei SwiftUI-Bestätigungsdialogen Sichtbarkeit und Nutzlast nie an
dasselbe State-Feld koppeln** (Bug 16) — `isPresented`s Setter feuert bei
jedem Knopfdruck, nicht nur bei Abbrechen; ein eigenes, unabhängiges Bool
fürs Zeigen/Verstecken hält die Nutzlast so lange am Leben, wie die
bestätigte Aktion sie braucht.
## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug
@@ -296,7 +373,21 @@ wiederholen.
- **REST-Pfad ungetestet für Schreibvorgänge** — auf beiden bisherigen
Testgeräten war `www-ssl` (Port 443) aus, jeder Schreibtest lief über
SSH. Der REST-`apply()`-Pfad (`POST`/`PATCH`, `findItemID` für `.set`)
ist nur gegen Mocks getestet.
ist nur gegen Mocks getestet. Der REST-Zweig von `fetchFieldValues`
(M12/Bug 14) ist zusätzlich auf einer nirgends offiziell dokumentierten
Annahme aufgebaut (Query-String-Filter `?feld=wert` auf einem GET) —
komplett unverifiziert.
- **"Zurück auf dynamisch" fehlt im Geräte-Tab (M12)** — es gibt nur
"Feste IP zuweisen" (Winbox' "Make Static"), keine Umkehrung. Kein Bug,
einfach noch nicht gebaut; Nutzer hat danach gefragt.
- **`fetchMenuItems`s `.id`-Positions-Überlagerung: Verlässlichkeit für
andere Menüs ungeprüft** (siehe Bug 15) — bei `/ip dhcp-server lease`
live als falsch bestätigt (`.id` landete auf der falschen Zeile), für
den Geräte-Tab per MAC-Adress-Matching umgangen. Derselbe Mechanismus
läuft aber weiterhin im Experte-Tab für `.set`/`.remove` auf *jedem*
Menü — ob und wo das dort ebenfalls falsch zuordnen kann, ist nicht
untersucht. Sollte bei unerklärlichem Verhalten dort (falsches Item
geändert/gelöscht) als erste Verdachtsquelle geprüft werden.
- **WLAN-`.set`-Pfad (M5) weiterhin ungetestet gegen echte Hardware** —
nur der "kein WLAN"-Zweig ist bestätigt (zwei Testgeräte, beide ohne
WLAN-Chip). Sicherheitsprofil-Anlage + SSID/Passwort-`.set` auf einem
@@ -501,6 +592,21 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
Schreibzugriff auf `/ip address`/`/ip dhcp-client`/`/ip dns`/
`/ip service` (Risiko Selbstaussperrung/
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.
- ✅ M12: Geräte-Tab (LAN-Scanner: DHCP-Leases+ARP+Bridge-Host, eine
Tabelle pro physischem Port, "Feste IP zuweisen" für dynamische Leases)
— **live gegen Hardware verifiziert und vom Nutzer bestätigt
funktionierend** ("das funktioniert jetzt super"), nach vier gefundenen
und gefixten Bugs (Bug 1417, siehe oben) — die aufwendigste
Live-Verifikation aller Milestones bisher, mehrere Diagnose-Runden mit
Rohdaten-Vergleichen und direkten Router-Gegenchecks (SSH-CLI + Winbox).
Fehlt noch: "Zurück auf dynamisch" (siehe Bekannte Einschränkungen).
## Nächste Schritte
@@ -543,12 +649,32 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
"Wiederherstellen"-Button pro Eintrag im Sicherungen-Tab
(`BackupListView`) mit Bestätigungsdialog (ähnlich der bestehenden
Werksreset-Gefahrenzone).
11. **Geräte-Tab: "Zurück auf dynamisch"-Aktion bauen** — Gegenstück zu
"Feste IP zuweisen". RouterOS-Weg dafür noch nicht recherchiert/live
geprüft (vermutlich `remove` der Lease und Neuanlage, oder ein
eigenes Menü-Kommando wie bei `make-static` — vor dem Bauen klären,
nicht raten, siehe Bug 17 als Warnung).
12. **`.id`-Positions-Überlagerung (`fetchMenuItems`) auf weitere Menüs
prüfen** — live als falsch bestätigt für `/ip dhcp-server lease`
(Bug 15). Betrifft potenziell jedes `.set`/`.remove` im Experte-Tab.
Am ehesten zu prüfen: bei einem Menü mit mehreren gleichzeitig
vorhandenen Einträgen `:put [<Pfad> find]` und `<Pfad> print terse`
unabhängig ausführen und die Reihenfolgen von Hand vergleichen.
13. Optional: den neuen Dauer-Editor auch für den Geräte-Tab nutzen, falls
dort künftig Zeitfelder (z.B. eine Lease-Zeit-Anzeige) dazukommen.
14. `Kay-Uwes-iMac` (ether2, `defconf`) und der Test-Laptop `DEDELLB2M6GK3`
(ether3, `dhcp5`/`defconf` je nach Testrunde) sind **echte Geräte des
Nutzers**, mit denen M12 live durchgetestet wurde — keine
Test-Artefakte, nicht aufräumen. Der Laptop hatte am Ende der Session
testweise eine feste IP zugewiesen bekommen; ob das der gewünschte
Dauerzustand ist oder nur zum Testen war, mit dem Nutzer klären.
Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe
`Aperto/`-Projekt), Remote hinzufügen und pushen; bis dahin lokales Git.
`/Applications/RouterOS Assistant.app` ist der aktuell installierte
Release-Build (Stand: Commit `008ee65`, App-Icon "Signal Router"). Nach
jeder weiteren Code-Änderung muss dieser Build-und-Kopier-Schritt (siehe
oben) wiederholt werden, damit die installierte App aktuell bleibt.
Release-Build, auf dem Stand des jeweils letzten Commits auf `main`
(App-Icon "Signal Router"). Nach jeder weiteren Code-Änderung muss dieser
Build-und-Kopier-Schritt (siehe oben) wiederholt werden, damit die
installierte App aktuell bleibt.
@@ -13,6 +13,8 @@ struct RouterOSAssistantApp: App {
.tabItem { Label("Einrichten", systemImage: "checklist") }
OverviewView(connectionService: connectionService)
.tabItem { Label("Übersicht", systemImage: "point.3.connected.trianglepath.dotted") }
DevicesView(connectionService: connectionService)
.tabItem { Label("Geräte", systemImage: "laptopcomputer.and.iphone") }
ExpertView(connectionService: connectionService)
.tabItem { Label("Experte", systemImage: "wrench.and.screwdriver") }
BackupListView(connectionService: connectionService)
@@ -0,0 +1,63 @@
import Foundation
/// One device seen on the LAN combines a DHCP lease (name/IP/MAC/static-or-not) with the
/// physical port it was learned on, resolved from ARP + the bridge host table. Built once per
/// "Geräte" tab refresh by `DevicesViewModel.buildDevices`, never mutated in place.
struct LanDevice: Identifiable, Equatable {
/// Unique per row (not the MAC alone) RouterOS can report two lease entries for the same
/// MAC (observed live: a device converted to static that still shows a separate dynamic
/// entry too), and both need to render rather than collide under one SwiftUI List identity.
let id: String
let ipAddress: String
let macAddress: String
let hostName: String?
/// True once RouterOS' own "dynamic" flag on the lease is false/absent a manually added or
/// "make-static"-converted lease. Always true for ARP-only entries (nothing to make static).
/// NOTE: RouterOS' official docs don't formally list "dynamic" as a lease property (only as a
/// print *flag* letter see RouterOSCommand.Operation.action's doc comment) if this turns
/// out wrong on real hardware, check `rawFields` (via "Rohdaten anzeigen") for the actual key.
let isStatic: Bool
/// False for a device seen only via ARP, with no matching DHCP lease RouterOS has no lease
/// item to convert for these, so the Geräte tab shows them read-only.
let hasLease: Bool
/// The lease's RouterOS `.id`, needed for the "make-static" command. Nil when `hasLease` is
/// false.
let leaseID: String?
let dhcpServerName: String?
/// The physical port this device was learned on (e.g. "ether3") set only when resolved via
/// the bridge host table or a non-bridge ARP interface, i.e. an actual port, never a guess.
let resolvedPort: String?
/// Set instead of `resolvedPort` when the device is only known to be somewhere behind this
/// bridge/network, not which physical port kept distinct so the UI never overstates
/// precision it doesn't have.
let networkHint: String?
let comment: String?
/// Every field RouterOS returned for this item, unfiltered shown via "Rohdaten anzeigen"
/// so a wrong assumption above (e.g. about the "dynamic" field) is one right-click away from
/// being checked against the real device instead of guessed again.
let rawFields: [(key: String, value: String)]
var isExactPort: Bool { resolvedPort != nil }
var displayPort: String {
if let resolvedPort { return "Port: \(resolvedPort)" }
if let networkHint { return "Netz: \(networkHint)" }
return "unbekannt"
}
static func == (lhs: LanDevice, rhs: LanDevice) -> Bool {
lhs.id == rhs.id && lhs.ipAddress == rhs.ipAddress && lhs.macAddress == rhs.macAddress
&& lhs.hostName == rhs.hostName && lhs.isStatic == rhs.isStatic && lhs.hasLease == rhs.hasLease
&& lhs.leaseID == rhs.leaseID && lhs.dhcpServerName == rhs.dhcpServerName
&& lhs.resolvedPort == rhs.resolvedPort && lhs.networkHint == rhs.networkHint && lhs.comment == rhs.comment
&& lhs.rawFields.map(\.key) == rhs.rawFields.map(\.key) && lhs.rawFields.map(\.value) == rhs.rawFields.map(\.value)
}
}
/// One physical Ethernet/WLAN port with the devices resolved onto it the "Geräte" tab's
/// per-port table. Includes ports with zero devices, so unused ports are visible too.
struct DevicePortGroup: Identifiable {
let id: String
let title: String
let devices: [LanDevice]
}
@@ -13,6 +13,18 @@ struct RouterOSCommand: Equatable, Identifiable {
/// Deletes an existing item matched by one field's value (SSH: `<menuPath> remove [find
/// field=value]`; REST again needs a GET-for-id first, then `DELETE restPath/<id>`).
case remove(matchField: String, matchValue: String)
/// A RouterOS "menu specific command" beyond add/set/remove, applied to one item matched
/// by a field's value e.g. `/ip dhcp-server lease make-static (id)`, which converts a
/// dynamic lease to a permanent one. Officially documented for the CLI as taking a bare
/// id/index (https://help.mikrotik.com/docs/spaces/ROS/pages/24805500/DHCP); this app
/// renders it via the same `[find field=value]` selector already proven live for
/// `.set`/`.remove` rather than a positional index, since RouterOS "id"-type arguments
/// accept both and this app never has a reliable position, only real `.id` values. REST
/// has no official documentation for this action at all the shape used here
/// (`POST <restPath>/<name>` with `{"numbers": <id>}`) is community-reported only
/// (https://forum.mikrotik.com/t/rest-api-convert-lease-to-static/176515), unverified
/// against real hardware.
case action(name: String, matchField: String, matchValue: String)
}
var id: String {
@@ -23,6 +35,8 @@ struct RouterOSCommand: Equatable, Identifiable {
return "set:\(menuPath):\(field)=\(value):\(summary)"
case .remove(let field, let value):
return "remove:\(menuPath):\(field)=\(value):\(summary)"
case .action(let name, let field, let value):
return "action:\(menuPath):\(name):\(field)=\(value):\(summary)"
}
}
@@ -73,6 +87,23 @@ struct RouterOSCommand: Equatable, Identifiable {
)
}
static func action(
menuPath: String,
restPath: String,
name: String,
matchField: String,
matchValue: String,
summary: String
) -> RouterOSCommand {
RouterOSCommand(
menuPath: menuPath,
restPath: restPath,
operation: .action(name: name, matchField: matchField, matchValue: matchValue),
arguments: [:],
summary: summary
)
}
/// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge`
/// or `/interface wireless set [find name=wlan1] ssid=Home`.
var cliLine: String {
@@ -91,6 +122,8 @@ struct RouterOSCommand: Equatable, Identifiable {
return args.isEmpty ? "\(menuPath) set \(finder)" : "\(menuPath) set \(finder) \(args)"
case .remove(let field, let value):
return "\(menuPath) remove [find \(field)=\(Self.quoteIfNeeded(value))]"
case .action(let name, let field, let value):
return "\(menuPath) \(name) [find \(field)=\(Self.quoteIfNeeded(value))]"
}
}
@@ -78,6 +78,19 @@ final class RestTransport: NSObject, RouterOSTransport {
throw RouterOSError.invalidResponse(restPath)
}
/// Unverified against real hardware (this app's REST write/query paths in general are
/// see HANDOFF.md). RouterOS REST's general convention is that a GET accepts query-string
/// property filters (`?field=value`), mirroring the console's `find field=value` used here
/// on the same assumption, not confirmed for this exact property. Unlike SSH, REST's GET
/// already returns full objects, so no separate id-overlay is involved here at all.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let data = try await send(path: "\(restPath)?\(whereField)=\(whereValue)", method: "GET", jsonBody: nil)
guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return Set(array.compactMap { $0[returnField] as? String })
}
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
var fields: [String: String] = [:]
var id = ""
@@ -108,6 +121,12 @@ final class RestTransport: NSObject, RouterOSTransport {
case .remove(let matchField, let matchValue):
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "DELETE", jsonBody: nil)
case .action(let name, let matchField, let matchValue):
// Community-reported shape (not MikroTik-documented, see RouterOSCommand.Operation)
// POST to the action's own sub-path with the matched item's id under "numbers",
// mirroring the console's own `<path> <name> numbers=<id>` argument name.
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(name)", method: "POST", jsonBody: ["numbers": itemID])
}
}
@@ -143,7 +162,13 @@ final class RestTransport: NSObject, RouterOSTransport {
}
private func send(path: String, method: String, jsonBody: [String: String]?) async throws -> Data {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
// `URL(string:relativeTo:)` (not `appendingPathComponent`, which percent-encodes "?")
// so a path carrying a query string (e.g. "ip/dhcp-server/lease?dynamic=no" from
// `fetchItemIDs`) is actually sent as a query, not a literal "?"-containing path segment.
guard let url = URL(string: path, relativeTo: baseURL) else {
throw RouterOSError.invalidResponse(path)
}
var request = URLRequest(url: url)
request.httpMethod = method
let authString = "\(credentials.username):\(credentials.password)"
@@ -14,6 +14,19 @@ protocol RouterOSTransport: AnyObject {
/// Lists existing items under any RouterOS menu the generic read side of the Expert tool,
/// works for menus without a curated `RouterOSMenuSchema` too.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem]
/// Values of one field across every item matching a `find` filter, via RouterOS' own
/// `find`/`get` for properties "print terse" doesn't expose as a key=value field at all.
/// Confirmed live: a DHCP server lease's "dynamic" state never appears in `print terse`
/// output in either state (checked against a real hEX lease both before and after converting
/// it to static via `/ip dhcp-server lease print`'s flags column), even though
/// `find dynamic=no` can still filter on it directly against RouterOS' internal data model.
/// Deliberately does NOT return `.id` (an earlier version did) `fetchMenuItems`' `.id`
/// overlay pairs two separate commands' output by row position, and that pairing was
/// confirmed live to mis-assign `.id` to the wrong row for this exact menu (two leases, the
/// wrong one showed "already static"). `returnField` should be something read directly off
/// the same `print terse` line (e.g. "mac-address"), so the caller never depends on that
/// overlay at all for this lookup.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String>
func apply(_ command: RouterOSCommand) async throws
func disconnect() async
}
@@ -95,6 +95,26 @@ final class SSHTransport: RouterOSTransport {
return items
}
/// `:foreach i in=[<menuPath> find whereField=whereValue] do={:put [<menuPath> get $i
/// returnField]}` built from two independently confirmed-live primitives only: a bare
/// `find` with one condition (verified repeatedly this session, e.g.
/// `find dynamic=no` reliably returning exactly the right id), and `get <id> field` on a
/// single, already-known id (standard, unambiguous RouterOS syntax). An earlier version tried
/// `get [find ...] returnField` as one combined call to do this in a single round trip
/// that specific combined form was never actually verified and was confirmed live to be
/// wrong (a real make-static conversion confirmed via Winbox wasn't found by it). `:put`
/// inside `:foreach` prints one value per line, so this splits on newlines, not ";" (the
/// semicolon-joined shape only applies to a single `:put [<menuPath> find ...]` list).
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let script = ":foreach i in=[\(menuPath) find \(whereField)=\(whereValue)] do={:put [\(menuPath) get $i \(returnField)]}"
let output = try await run(script)
let values = output
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return Set(values)
}
/// RouterOS' SSH CLI exits 0 even when a command fails confirmed live: both
/// `/ip dhcp-server add ...` on an interface that already has one ("failure: server or
/// relay with such interface already exists") and an invalid action ("syntax error (line 1
@@ -105,6 +105,12 @@ final class ConnectionService: ObservableObject {
return try await activeTransport.fetchMenuItems(menuPath: menuPath, restPath: restPath)
}
/// Field values matching an internal-property filter see `RouterOSTransport.fetchFieldValues`.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchFieldValues(menuPath: menuPath, restPath: restPath, whereField: whereField, whereValue: whereValue, returnField: returnField)
}
private func finishConnecting(using transport: RouterOSTransport) async {
activeTransport = transport
do {
@@ -0,0 +1,259 @@
import SwiftUI
/// "Geräte" tab: LAN scanner one table per physical Ethernet/WLAN port, each listing the
/// devices resolved onto it (name/IP/MAC/status), plus a right-click action to give a device a
/// permanent static IP the same way Winbox's "Make Static" does.
struct DevicesView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: DevicesViewModel
@State private var rawFieldsDevice: LanDevice?
/// Plain, independent Bool deliberately NOT derived from `pendingStaticAssignment`.
/// SwiftUI's `.confirmationDialog` calls the `isPresented` binding's setter with `false` on
/// EVERY dismissal, including tapping "Zuweisen" itself a computed binding that reacted to
/// that by nil-ing `pendingStaticAssignment` was found to clear it before
/// `confirmStaticAssignment()` could read it, so the confirmed action silently ran on `nil`
/// and did nothing. Matches this app's own established, working pattern for confirmation
/// dialogs elsewhere (BackupListView's factory-reset dialog): the dialog's own visibility and
/// its payload are two separate pieces of state.
@State private var showStaticConfirmation = false
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: DevicesViewModel(connectionService: connectionService))
}
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 if viewModel.isLoading && viewModel.devices.isEmpty {
ProgressView("Suche Geräte im Netz…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.loadError, viewModel.devices.isEmpty {
ContentUnavailableView(
"Konnte Geräte nicht laden", systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else if viewModel.portGroups.isEmpty {
ContentUnavailableView(
"Keine Ports gefunden", systemImage: "wifi.slash",
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
)
} else {
List {
ForEach(viewModel.portGroups) { group in
Section {
if group.devices.isEmpty {
Text("Keine Geräte").font(.caption).foregroundStyle(.secondary)
} else {
DeviceColumnHeader()
ForEach(group.devices) { device in
DeviceRow(device: device)
.contextMenu {
deviceMenu(for: device)
}
}
}
} header: {
Text("\(group.title) (\(group.devices.count))")
}
}
}
}
}
.navigationTitle("Geräte")
.toolbar {
ToolbarItem {
Button {
Task { await viewModel.load() }
} label: {
if viewModel.isLoading {
ProgressView().controlSize(.small)
} else {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
}
.disabled(viewModel.isLoading)
}
}
.task {
if viewModel.devices.isEmpty {
await viewModel.load()
}
}
.confirmationDialog(
"Feste IP-Adresse zuweisen?",
isPresented: $showStaticConfirmation,
titleVisibility: .visible
) {
Button("Zuweisen") {
Task { await viewModel.confirmStaticAssignment() }
}
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticAssignment() }
} message: {
if let device = viewModel.pendingStaticAssignment {
Text(
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
+ (device.hostName.map { " (\($0))" } ?? "")
+ " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch."
+ "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")"
)
}
}
.alert(
"Fehler",
isPresented: Binding(
get: { viewModel.applyError != nil },
set: { if !$0 { viewModel.dismissApplyError() } }
),
presenting: viewModel.applyError
) { _ in
Button("OK") {}
} message: { error in
Text(error)
}
.sheet(item: $rawFieldsDevice) { device in
RawFieldsSheet(device: device) { rawFieldsDevice = nil }
}
}
}
@ViewBuilder
private func deviceMenu(for device: LanDevice) -> some View {
if device.hasLease {
if device.isStatic {
Text("Bereits fest zugewiesen")
} else {
Button {
viewModel.pendingStaticAssignment = device
showStaticConfirmation = true
} label: {
Label("Feste IP zuweisen", systemImage: "pin.fill")
}
}
} else {
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
}
Divider()
Button {
rawFieldsDevice = device
} label: {
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
}
}
}
/// Column widths shared between the header and each row so they line up like a real table.
private enum DeviceColumn {
static let icon: CGFloat = 16
static let name: CGFloat = 130
static let ip: CGFloat = 110
static let mac: CGFloat = 140
static let status: CGFloat = 80
}
private struct DeviceColumnHeader: View {
var body: some View {
HStack(spacing: 8) {
Spacer().frame(width: DeviceColumn.icon)
Text("Name").frame(width: DeviceColumn.name, alignment: .leading)
Text("IP-Adresse").frame(width: DeviceColumn.ip, alignment: .leading)
Text("MAC-Adresse").frame(width: DeviceColumn.mac, alignment: .leading)
Text("Status").frame(width: DeviceColumn.status, alignment: .leading)
Spacer()
}
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
}
private struct DeviceRow: View {
let device: LanDevice
/// Three distinct states, not two a device with no matching DHCP lease at all is neither
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
/// with "Fest" previously mislabeled every ARP-only device, DHCP clients included whenever
/// their lease didn't get matched.
private var statusIcon: String {
guard device.hasLease else { return "questionmark.circle" }
return device.isStatic ? "pin.fill" : "circle.fill"
}
private var statusColor: Color {
guard device.hasLease else { return .secondary }
return device.isStatic ? .orange : .green
}
private var statusText: String {
guard device.hasLease else { return "Kein DHCP" }
return device.isStatic ? "Fest" : "Dynamisch"
}
var body: some View {
HStack(spacing: 8) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.system(size: 8))
.frame(width: DeviceColumn.icon)
.help(statusText)
Text(device.hostName?.isEmpty == false ? device.hostName! : "-")
.font(.system(size: 12))
.lineLimit(1)
.frame(width: DeviceColumn.name, alignment: .leading)
Text(device.ipAddress)
.font(.system(size: 12))
.frame(width: DeviceColumn.ip, alignment: .leading)
Text(device.macAddress)
.font(.system(size: 11, design: .monospaced))
.frame(width: DeviceColumn.mac, alignment: .leading)
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: DeviceColumn.status, alignment: .leading)
Spacer()
}
.padding(.vertical, 1)
}
}
private struct RawFieldsSheet: View {
let device: LanDevice
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rohdaten: \(device.macAddress)")
.font(.headline)
Text("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(device.rawFields, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
}
}
}
}
HStack {
Spacer()
Button("Schließen") { onClose() }
}
}
.padding()
.frame(minWidth: 380, minHeight: 300)
}
}
#Preview {
DevicesView(connectionService: ConnectionService())
}
@@ -0,0 +1,319 @@
import Foundation
/// Builds the "Geräte" tab's LAN device list from a live router DHCP leases enriched with the
/// physical port each device was learned on (ARP + bridge host table) grouped into one table
/// per physical port, and drives the one write action this tab offers: converting a dynamic
/// lease to a permanent static one.
@MainActor
final class DevicesViewModel: ObservableObject {
@Published private(set) var devices: [LanDevice] = []
@Published private(set) var portGroups: [DevicePortGroup] = []
@Published private(set) var isLoading = false
@Published private(set) var loadError: String?
@Published var pendingStaticAssignment: LanDevice?
@Published private(set) var isApplying = false
@Published private(set) var applyError: String?
private let connectionService: ConnectionService
private let backupService: BackupService
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
self.connectionService = connectionService
self.backupService = backupService
}
func load() async {
isLoading = true
loadError = nil
do {
async let leasesTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease")
async let arpTask = connectionService.fetchMenuItems(menuPath: "/ip arp", restPath: "ip/arp")
async let bridgeHostsTask = connectionService.fetchMenuItems(menuPath: "/interface bridge host", restPath: "interface/bridge/host")
async let interfacesTask = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
async let dhcpServersTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server")
let leases = try await leasesTask
let arpEntries = try await arpTask
let bridgeHosts = try await bridgeHostsTask
let interfaces = try await interfacesTask
let dhcpServers = try await dhcpServersTask
// Best-effort: if this specific lookup fails (e.g. an older RouterOS version that
// doesn't support filtering "find" on this property), fall back to "nothing known
// static yet" rather than failing the whole tab matches the safe default already
// used when a lease is entirely unmatched. Returns MAC addresses, not `.id`s see
// `RouterOSTransport.fetchFieldValues`'s doc comment for why `.id` isn't trustworthy
// for this menu.
let staticMacs = Set((try? await connectionService.fetchFieldValues(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
whereField: "dynamic", whereValue: "no", returnField: "mac-address"
))?.map { $0.lowercased() } ?? [])
let builtDevices = Self.buildDevices(
leases: leases, arpEntries: arpEntries, bridgeHosts: bridgeHosts,
interfaces: interfaces, dhcpServers: dhcpServers, staticMacAddresses: staticMacs
)
devices = builtDevices
portGroups = Self.buildPortGroups(devices: builtDevices, interfaces: interfaces)
} catch {
loadError = error.localizedDescription
}
isLoading = false
}
/// The command "Feste IP zuweisen" would run shown to the user before it executes, same
/// convention as the rest of the app (Wizard review screen, Expert tool's confirmation
/// dialog).
var pendingCommand: RouterOSCommand? {
guard let device = pendingStaticAssignment, device.hasLease else { return nil }
// Matched by MAC address, not `.id` deliberately, see `RouterOSTransport.
// fetchFieldValues`'s doc comment for why the `.id` this app would otherwise have on
// hand (`device.leaseID`) isn't trustworthy for this menu.
return .action(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
name: "make-static", matchField: "mac-address", matchValue: device.macAddress,
summary: "Feste IP \(device.ipAddress) für \(device.macAddress)"
)
}
/// Backs up once per connection before this tab's first write same shared flag the Expert
/// tool uses (`ConnectionService.hasExpertToolBackedUpThisSession`), so a session that already
/// backed up via one "power tool" doesn't back up again via the other.
private func ensureSessionBackup() async throws {
guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return }
_ = try await backupService.createBackup(for: credentials)
connectionService.markExpertToolBackedUpThisSession()
}
func confirmStaticAssignment() async {
guard let command = pendingCommand, let device = pendingStaticAssignment else { return }
isApplying = true
applyError = nil
do {
try await ensureSessionBackup()
try await connectionService.apply(command)
// Confirmed live: RouterOS' SSH CLI can run "make-static [find ...]" without any
// error output while genuinely not converting anything (a mismatched selector) "no
// error" alone isn't proof it worked (same lesson as Bug 10 in HANDOFF.md, one level
// deeper: even a real, syntactically valid, silently-successful-looking command can
// still not have the intended effect). Verify the lease is now actually in the static
// set before declaring success by MAC, same reasoning as `pendingCommand`.
let staticMacs = Set((try await connectionService.fetchFieldValues(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
whereField: "dynamic", whereValue: "no", returnField: "mac-address"
)).map { $0.lowercased() })
guard staticMacs.contains(device.macAddress.lowercased()) else {
throw RouterOSError.invalidResponse(
"Befehl lief ohne Fehlermeldung, aber der Router zeigt den Eintrag weiterhin als dynamisch (geprüft über \"find dynamic=no\"). Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren."
)
}
pendingStaticAssignment = nil
await load()
} catch {
// Clear this on failure too, not just success otherwise the confirmationDialog's
// `isPresented` binding (tied to `pendingStaticAssignment != nil`) stays "open" while
// SwiftUI also tries to present the error `.alert`, and the two presentations can
// conflict enough that the alert never actually becomes visible a silent failure
// that looked like nothing happened at all.
pendingStaticAssignment = nil
applyError = error.localizedDescription
}
isApplying = false
}
func cancelStaticAssignment() {
pendingStaticAssignment = nil
}
func dismissApplyError() {
applyError = nil
}
// MARK: - Pure device list construction (testable without a live router)
nonisolated static func buildDevices(
leases: [RouterOSMenuItem],
arpEntries: [RouterOSMenuItem],
bridgeHosts: [RouterOSMenuItem],
interfaces: [RouterOSMenuItem],
dhcpServers: [RouterOSMenuItem],
staticMacAddresses: Set<String> = []
) -> [LanDevice] {
var bridgeNames = Set<String>()
for item in interfaces where item.fields["type"] == "bridge" {
if let name = item.fields["name"] { bridgeNames.insert(name) }
}
// Exact: the bridge host table names the actual physical port a MAC was learned on
// behind a bridge (RouterOS field "on-interface" confirmed against MikroTik's
// Bridging and Switching docs, not guessed).
var exactPortByMAC: [String: String] = [:]
for item in bridgeHosts {
guard let mac = item.fields["mac-address"]?.lowercased(), let onInterface = item.fields["on-interface"] else { continue }
exactPortByMAC[mac] = onInterface
}
// Fallback: ARP's "interface" field exact if that interface isn't a bridge, otherwise
// only "somewhere on this bridge" (resolved further by the bridge host table above when
// available).
var arpInterfaceByMAC: [String: String] = [:]
for item in arpEntries {
guard let mac = item.fields["mac-address"]?.lowercased(), let iface = item.fields["interface"] else { continue }
arpInterfaceByMAC[mac] = iface
}
// Last resort: the DHCP server's own configured interface network-level only, since a
// server can sit on a bridge shared by several physical ports.
var interfaceByDHCPServer: [String: String] = [:]
for item in dhcpServers {
guard let name = item.fields["name"], let iface = item.fields["interface"] else { continue }
interfaceByDHCPServer[name] = iface
}
func resolvePort(mac: String, fallbackServer: String?) -> (port: String?, network: String?) {
if let exact = exactPortByMAC[mac] {
return (exact, nil)
}
if let iface = arpInterfaceByMAC[mac] {
return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil)
}
if let server = fallbackServer, let iface = interfaceByDHCPServer[server] {
return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil)
}
return (nil, nil)
}
var devices: [LanDevice] = []
var seenMACs = Set<String>()
for (index, item) in leases.enumerated() {
guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue }
let mac = macRaw.lowercased()
seenMACs.insert(mac)
// Confirmed live on a real hEX (two independent checks, "/ip dhcp-server lease
// print"'s flags column before and after converting a lease to static): "dynamic" is
// never emitted by "print terse" for this menu, in EITHER state not omitted only
// for the common case, genuinely absent from the parseable output entirely. Reading
// the field at all (this app's first two attempts) can't work. Static/dynamic here
// instead comes from `staticMacAddresses`, fetched via RouterOS' `find dynamic=no`
// matched by MAC, not `.id`, because `.id` from `fetchMenuItems`' positional overlay
// was confirmed live to mis-pair with the wrong lease for this exact menu (a static
// lease's `.id` got attached to a different, still-dynamic lease's row, making it
// wrongly show "Fest" too).
let isStaticLease = staticMacAddresses.contains(mac)
let server = item.fields["server"]
let (port, network) = resolvePort(mac: mac, fallbackServer: server)
devices.append(LanDevice(
// Unique per row, not just the MAC if RouterOS ever reports two lease entries
// for the same MAC (observed: a device that should be static still showing
// dynamic after conversion, plausibly because RouterOS created a fresh dynamic
// lease alongside the newly-static one), both must render, not silently collapse
// to one via a SwiftUI List/ForEach duplicate-id collision.
id: "lease-\(index)-\(mac)",
ipAddress: address,
macAddress: macRaw,
hostName: item.fields["host-name"],
isStatic: isStaticLease,
hasLease: true,
leaseID: item.id,
dhcpServerName: server,
resolvedPort: port,
networkHint: network,
comment: item.fields["comment"],
rawFields: [(key: ".id", value: item.id)] + item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
))
}
// ARP-only devices: seen on the network but with no matching DHCP lease (statically
// configured outside DHCP, or a stray/foreign device). Shown read-only there's no
// lease item to convert, and guessing which DHCP server's network a static-IP device
// "belongs to" isn't something this app will do.
for (index, item) in arpEntries.enumerated() {
guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue }
let mac = macRaw.lowercased()
guard !seenMACs.contains(mac) else { continue }
seenMACs.insert(mac)
let (port, network) = resolvePort(mac: mac, fallbackServer: nil)
devices.append(LanDevice(
id: "arp-\(index)-\(mac)",
ipAddress: address,
macAddress: macRaw,
hostName: nil,
// No lease at all here "isStatic" (a DHCP *static reservation*) simply doesn't
// apply, so this must not read as "Fest" (a prior version wrongly hardcoded
// `true`, which mislabeled every ARP-only device including ordinary DHCP
// clients whose lease didn't get matched as statically assigned). The Geräte
// view branches on `hasLease` first precisely to keep this case visually distinct
// from both "Fest" and "Dynamisch".
isStatic: false,
hasLease: false,
leaseID: nil,
dhcpServerName: nil,
resolvedPort: port,
networkHint: network,
comment: nil,
rawFields: item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
))
}
return devices.sorted { isIPLess(ipSortKey($0.ipAddress), ipSortKey($1.ipAddress)) }
}
/// Groups devices by physical port for the per-port tables, including physical ports with
/// zero devices (so unused ports are visible too) and a catch-all group for devices whose
/// port isn't known exactly (only a bridge/network see `LanDevice.networkHint`).
nonisolated static func buildPortGroups(devices: [LanDevice], interfaces: [RouterOSMenuItem]) -> [DevicePortGroup] {
let physicalTypes: Set<String> = ["ether", "wlan", "wifi", "wireless", "sfp", "sfp-sfpplus", "sfp28", "combo"]
var physicalPortNames = interfaces
.filter { physicalTypes.contains($0.fields["type"] ?? "") }
.compactMap { $0.fields["name"] }
physicalPortNames.sort(by: naturalPortOrder)
var devicesByPort: [String: [LanDevice]] = [:]
var otherDevices: [LanDevice] = []
for device in devices {
if let port = device.resolvedPort {
devicesByPort[port, default: []].append(device)
} else {
otherDevices.append(device)
}
}
var groups = physicalPortNames.map { port in
DevicePortGroup(id: port, title: port, devices: devicesByPort[port] ?? [])
}
// A resolved port RouterOS reported that isn't in the physical-interface snapshot
// (shouldn't normally happen) still gets its own group, so no device silently vanishes.
let knownPortNames = Set(physicalPortNames)
for (port, portDevices) in devicesByPort.sorted(by: { $0.key < $1.key }) where !knownPortNames.contains(port) {
groups.append(DevicePortGroup(id: port, title: port, devices: portDevices))
}
if !otherDevices.isEmpty {
groups.append(DevicePortGroup(id: "unbekannt", title: "Unbekannter Port", devices: otherDevices))
}
return groups
}
private nonisolated static func naturalPortOrder(_ lhs: String, _ rhs: String) -> Bool {
func split(_ value: String) -> (prefix: String, number: Int) {
let digits = value.reversed().prefix(while: \.isNumber)
let numberPart = String(digits.reversed())
let prefix = String(value.dropLast(numberPart.count))
return (prefix, Int(numberPart) ?? 0)
}
let left = split(lhs)
let right = split(rhs)
return left.prefix == right.prefix ? left.number < right.number : left.prefix < right.prefix
}
private nonisolated static func ipSortKey(_ address: String) -> [Int] {
address.split(separator: ".").compactMap { Int($0) }
}
private nonisolated static func isIPLess(_ lhs: [Int], _ rhs: [Int]) -> Bool {
for (left, right) in zip(lhs, rhs) where left != right {
return left < right
}
return lhs.count < rhs.count
}
}
@@ -19,6 +19,7 @@ private final class MockTransport: RouterOSTransport {
func fetchInterfaces() async throws -> [NetworkInterface] { [] }
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { FirewallRuleCounts(filterRuleCount: 0, natRuleCount: 0) }
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] { [] }
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> { [] }
func apply(_ command: RouterOSCommand) async throws {}
func disconnect() async {}
}
@@ -0,0 +1,174 @@
import XCTest
@testable import RouterOSAssistant
final class DevicesViewModelTests: XCTestCase {
private func item(_ fields: [String: String], id: String = "*1") -> RouterOSMenuItem {
RouterOSMenuItem(id: id, fields: fields)
}
func testDynamicLeaseResolvesExactPortViaBridgeHost() {
let devices = DevicesViewModel.buildDevices(
leases: [item([
"address": "192.168.88.50", "mac-address": "AA:BB:CC:DD:EE:01",
"host-name": "MacBook", "server": "dhcp1"
], id: "*5")],
arpEntries: [item(["address": "192.168.88.50", "mac-address": "AA:BB:CC:DD:EE:01", "interface": "bridge-lan"])],
bridgeHosts: [item(["mac-address": "aa:bb:cc:dd:ee:01", "bridge": "bridge-lan", "on-interface": "ether3"])],
interfaces: [item(["name": "bridge-lan", "type": "bridge"])],
dhcpServers: [item(["name": "dhcp1", "interface": "bridge-lan"])]
)
XCTAssertEqual(devices.count, 1)
let device = devices[0]
XCTAssertEqual(device.resolvedPort, "ether3")
XCTAssertEqual(device.displayPort, "Port: ether3")
XCTAssertTrue(device.isExactPort)
XCTAssertFalse(device.isStatic)
XCTAssertEqual(device.leaseID, "*5")
}
func testArpOnlyBridgeInterfaceIsNotExactPortWithoutBridgeHost() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.51", "mac-address": "AA:BB:CC:DD:EE:02", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.51", "mac-address": "AA:BB:CC:DD:EE:02", "interface": "bridge-lan"])],
bridgeHosts: [],
interfaces: [item(["name": "bridge-lan", "type": "bridge"])],
dhcpServers: [item(["name": "dhcp1", "interface": "bridge-lan"])]
)
XCTAssertEqual(devices[0].networkHint, "bridge-lan")
XCTAssertEqual(devices[0].displayPort, "Netz: bridge-lan")
XCTAssertFalse(devices[0].isExactPort)
}
func testDirectPortArpInterfaceIsExact() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.52", "mac-address": "AA:BB:CC:DD:EE:03", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.52", "mac-address": "AA:BB:CC:DD:EE:03", "interface": "ether5"])],
bridgeHosts: [],
interfaces: [item(["name": "ether5", "type": "ether"])],
dhcpServers: [item(["name": "dhcp1", "interface": "ether5"])]
)
XCTAssertEqual(devices[0].resolvedPort, "ether5")
XCTAssertTrue(devices[0].isExactPort)
}
func testLeaseNotInStaticMacSetIsDynamic() {
// Confirmed live on a real hEX: "/ip dhcp-server lease print terse" never emits a
// "dynamic" key at all, in either state reading fields can't distinguish static from
// dynamic for this menu. Static/dynamic comes from `staticMacAddresses` (RouterOS' own
// `find dynamic=no`, matched by MAC not `.id`, confirmed live to mis-pair for this menu
// via `fetchMenuItems`' positional overlay), not from anything in the lease's own fields.
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.53", "mac-address": "AA:BB:CC:DD:EE:04", "server": "dhcp1"], id: "*3")],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: [],
staticMacAddresses: ["aa:bb:cc:dd:ee:09"]
)
XCTAssertFalse(devices[0].isStatic)
}
func testLeaseInStaticMacSetIsStatic() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.54", "mac-address": "AA:BB:CC:DD:EE:07", "server": "dhcp1"], id: "*4")],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: [],
staticMacAddresses: ["aa:bb:cc:dd:ee:07"]
)
XCTAssertTrue(devices[0].isStatic)
}
func testStaticDetectionIgnoresIDEntirelyEvenWhenMismatched() {
// Regression guard for the exact bug found live: two leases where the "static" `.id`
// happened to get attached to the WRONG row by the id-overlay must not both show static.
let devices = DevicesViewModel.buildDevices(
leases: [
item(["address": "192.168.88.1", "mac-address": "AA:AA:AA:AA:AA:01", "server": "dhcp1"], id: "*4"),
item(["address": "192.168.88.2", "mac-address": "AA:AA:AA:AA:AA:02", "server": "dhcp1"], id: "*4")
],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: [],
staticMacAddresses: ["aa:aa:aa:aa:aa:02"]
)
XCTAssertFalse(devices[0].isStatic)
XCTAssertTrue(devices[1].isStatic)
}
func testArpOnlyDeviceHasNoLeaseAndCannotBeMadeStatic() {
let devices = DevicesViewModel.buildDevices(
leases: [],
arpEntries: [item(["address": "192.168.88.60", "mac-address": "AA:BB:CC:DD:EE:05", "interface": "ether5"])],
bridgeHosts: [], interfaces: [item(["name": "ether5", "type": "ether"])], dhcpServers: []
)
XCTAssertEqual(devices.count, 1)
XCTAssertFalse(devices[0].hasLease)
XCTAssertNil(devices[0].leaseID)
// No lease at all means "static" doesn't apply either must not read as "Fest".
XCTAssertFalse(devices[0].isStatic)
}
func testLeaseAndArpEntryForSameMacAreNotDuplicated() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.61", "mac-address": "AA:BB:CC:DD:EE:06", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.61", "mac-address": "aa:bb:cc:dd:ee:06", "interface": "ether5"])],
bridgeHosts: [], interfaces: [item(["name": "ether5", "type": "ether"])],
dhcpServers: [item(["name": "dhcp1", "interface": "ether5"])]
)
XCTAssertEqual(devices.count, 1)
XCTAssertTrue(devices[0].hasLease)
}
func testDevicesAreSortedByIPAddress() {
let devices = DevicesViewModel.buildDevices(
leases: [
item(["address": "192.168.88.20", "mac-address": "AA:00:00:00:00:02"]),
item(["address": "192.168.88.5", "mac-address": "AA:00:00:00:00:01"])
],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: []
)
XCTAssertEqual(devices.map(\.ipAddress), ["192.168.88.5", "192.168.88.20"])
}
func testPortGroupsIncludeEmptyPortsAndSortNaturally() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.10", "mac-address": "AA:00:00:00:00:10", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.10", "mac-address": "aa:00:00:00:00:10", "interface": "ether10"])],
bridgeHosts: [],
interfaces: [
item(["name": "ether2", "type": "ether"]),
item(["name": "ether10", "type": "ether"])
],
dhcpServers: [item(["name": "dhcp1", "interface": "ether10"])]
)
let groups = DevicesViewModel.buildPortGroups(
devices: devices,
interfaces: [
item(["name": "ether2", "type": "ether"]),
item(["name": "ether10", "type": "ether"])
]
)
XCTAssertEqual(groups.map(\.title), ["ether2", "ether10"])
XCTAssertEqual(groups[0].devices.count, 0)
XCTAssertEqual(groups[1].devices.count, 1)
}
func testPortGroupsBucketUnresolvedDevicesSeparately() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.11", "mac-address": "AA:00:00:00:00:11", "server": "dhcp1"])],
arpEntries: [],
bridgeHosts: [],
interfaces: [item(["name": "bridge-lan", "type": "bridge"])],
dhcpServers: [item(["name": "dhcp1", "interface": "bridge-lan"])]
)
let groups = DevicesViewModel.buildPortGroups(devices: devices, interfaces: [item(["name": "bridge-lan", "type": "bridge"])])
XCTAssertEqual(groups.count, 1)
XCTAssertEqual(groups[0].id, "unbekannt")
XCTAssertEqual(groups[0].devices.count, 1)
}
}
@@ -74,4 +74,17 @@ final class RouterOSCommandBuilderTests: XCTestCase {
XCTAssertEqual(command.cliLine, "/interface wireless set [find name=wlan1] ssid=Home")
}
func testCliLineRendersFindLookupForAction() {
let command = RouterOSCommand.action(
menuPath: "/ip dhcp-server lease",
restPath: "ip/dhcp-server/lease",
name: "make-static",
matchField: ".id",
matchValue: "*7",
summary: "test"
)
XCTAssertEqual(command.cliLine, "/ip dhcp-server lease make-static [find .id=*7]")
}
}