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:
+74
@@ -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 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).
|
||||
|
||||
+152
-26
@@ -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 [<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 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 [<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]")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user