From e2b303963a65e35fe8a0c4be4195b927c9ef4214 Mon Sep 17 00:00:00 2001 From: Kay Date: Mon, 14 Sep 2026 13:55:46 +0200 Subject: [PATCH] =?UTF-8?q?M12:=20Ger=C3=A4te-Tab=20=E2=80=94=20LAN-Scanne?= =?UTF-8?q?r=20mit=20Static-IP-Zuweisung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ --- CHATLOG.md | 74 ++++ HANDOFF.md | 178 ++++++++-- .../App/RouterOSAssistantApp.swift | 2 + RouterOSAssistant/Core/Models/LanDevice.swift | 63 ++++ .../Core/Models/RouterOSCommand.swift | 33 ++ .../Core/Networking/RestTransport.swift | 27 +- .../Core/Networking/RouterOSTransport.swift | 13 + .../Core/Networking/SSHTransport.swift | 20 ++ .../Core/Services/ConnectionService.swift | 6 + .../Features/Devices/DevicesView.swift | 259 ++++++++++++++ .../Features/Devices/DevicesViewModel.swift | 319 ++++++++++++++++++ .../ConnectionServiceTests.swift | 1 + .../DevicesViewModelTests.swift | 174 ++++++++++ .../RouterOSCommandBuilderTests.swift | 13 + 14 files changed, 1155 insertions(+), 27 deletions(-) create mode 100644 RouterOSAssistant/Core/Models/LanDevice.swift create mode 100644 RouterOSAssistant/Features/Devices/DevicesView.swift create mode 100644 RouterOSAssistant/Features/Devices/DevicesViewModel.swift create mode 100644 RouterOSAssistantTests/DevicesViewModelTests.swift diff --git a/CHATLOG.md b/CHATLOG.md index 12c4eaf..afd8714 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -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 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 14–17, 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 14–17, 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). diff --git a/HANDOFF.md b/HANDOFF.md index f4f92ba..82a58b2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 7–13). 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 7–13 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 14–17 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" (16px–1024px) 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 [ 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** — ` 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 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 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 14–17, 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 [ find]` und ` 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. diff --git a/RouterOSAssistant/App/RouterOSAssistantApp.swift b/RouterOSAssistant/App/RouterOSAssistantApp.swift index f48fe78..64c71fd 100644 --- a/RouterOSAssistant/App/RouterOSAssistantApp.swift +++ b/RouterOSAssistant/App/RouterOSAssistantApp.swift @@ -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) diff --git a/RouterOSAssistant/Core/Models/LanDevice.swift b/RouterOSAssistant/Core/Models/LanDevice.swift new file mode 100644 index 0000000..394e048 --- /dev/null +++ b/RouterOSAssistant/Core/Models/LanDevice.swift @@ -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] +} diff --git a/RouterOSAssistant/Core/Models/RouterOSCommand.swift b/RouterOSAssistant/Core/Models/RouterOSCommand.swift index a3741c8..8334658 100644 --- a/RouterOSAssistant/Core/Models/RouterOSCommand.swift +++ b/RouterOSAssistant/Core/Models/RouterOSCommand.swift @@ -13,6 +13,18 @@ struct RouterOSCommand: Equatable, Identifiable { /// Deletes an existing item matched by one field's value (SSH: ` remove [find /// field=value]`; REST again needs a GET-for-id first, then `DELETE restPath/`). 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 /` with `{"numbers": }`) 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))]" } } diff --git a/RouterOSAssistant/Core/Networking/RestTransport.swift b/RouterOSAssistant/Core/Networking/RestTransport.swift index 946d878..4cb53fe 100644 --- a/RouterOSAssistant/Core/Networking/RestTransport.swift +++ b/RouterOSAssistant/Core/Networking/RestTransport.swift @@ -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 { + 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 ` numbers=` 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)" diff --git a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift index 23b7881..92346df 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift @@ -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 func apply(_ command: RouterOSCommand) async throws func disconnect() async } diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index c1e8973..6530796 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -95,6 +95,26 @@ final class SSHTransport: RouterOSTransport { return items } + /// `:foreach i in=[ find whereField=whereValue] do={:put [ 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 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 [ find ...]` list). + func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set { + 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 diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 8729f0c..3ee3e46 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -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 { + 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 { diff --git a/RouterOSAssistant/Features/Devices/DevicesView.swift b/RouterOSAssistant/Features/Devices/DevicesView.swift new file mode 100644 index 0000000..aeeb37f --- /dev/null +++ b/RouterOSAssistant/Features/Devices/DevicesView.swift @@ -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()) +} diff --git a/RouterOSAssistant/Features/Devices/DevicesViewModel.swift b/RouterOSAssistant/Features/Devices/DevicesViewModel.swift new file mode 100644 index 0000000..8c9177e --- /dev/null +++ b/RouterOSAssistant/Features/Devices/DevicesViewModel.swift @@ -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 = [] + ) -> [LanDevice] { + var bridgeNames = Set() + 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() + + 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 = ["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 + } +} diff --git a/RouterOSAssistantTests/ConnectionServiceTests.swift b/RouterOSAssistantTests/ConnectionServiceTests.swift index 94fe1db..603dbc3 100644 --- a/RouterOSAssistantTests/ConnectionServiceTests.swift +++ b/RouterOSAssistantTests/ConnectionServiceTests.swift @@ -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 { [] } func apply(_ command: RouterOSCommand) async throws {} func disconnect() async {} } diff --git a/RouterOSAssistantTests/DevicesViewModelTests.swift b/RouterOSAssistantTests/DevicesViewModelTests.swift new file mode 100644 index 0000000..78566a3 --- /dev/null +++ b/RouterOSAssistantTests/DevicesViewModelTests.swift @@ -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) + } +} diff --git a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift index 811fec6..e1bf591 100644 --- a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift +++ b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift @@ -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]") + } }