M17-M19: Bekannte Router, Live-Traffic-Anzeige, Übersicht-Animation+Drag
M17: "Bekannte Router" im Verbinden-Tab (SavedRouter/SavedRoutersStore), Standort-Freitextfeld, Scroll-Cap ab 4 Einträgen. Bugfix: Umbenennen- TextField steckte in einem sich selbst deaktivierenden Button. M18: Live-Traffic-Punkt an Interfaces (InterfaceTrafficMonitor, eigene SSH-Verbindung, monitor-traffic-Polling). Dabei zwei reale CLI-Parser-Bugs gefunden und gefixt: running/disabled-Flags werden als Buchstaben vor dem ersten Feld codiert, nicht als key=value; monitor-traffic liefert "50.7kbps" statt einer reinen Zahl. M19: Übersicht-Tab — animierte Flussrichtung auf allen Verbindungslinien (TimelineView+dashPhase), frei verschiebbare Knoten mit Live-folgenden Linien, Zurücksetzen-Button. Zusätzlich (noch nicht live getestet, nur Build+Unit-Tests grün): LAN-Port-Konflikt-Prüfung im Einrichten-Assistenten mit doppelter Sicherheitsbestätigung, "Fertig"-Button nach erfolgreichem Anwenden. 82 Tests grün. HANDOFF.md/README.md/Manual.md/CHATLOG.md aktualisiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
+57
@@ -889,3 +889,60 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
|
||||
visuelle Live-Prüfung des Diagramms selbst steht noch aus (nur die
|
||||
Logik ist getestet), auf ausdrücklichen Nutzerwunsch trotzdem
|
||||
gespeichert.
|
||||
- "beim Einrichten unter Einrichten/Experte, wenn man ein neues LAN
|
||||
anlegt, mache vorher eine prüfung, ob der entsprechende port frei
|
||||
ist..." → LAN-Port-Konflikt-Prüfung gebaut: `PortConflict`-Modell,
|
||||
`ConnectionService.checkPortConflict(interfaceName:)` (liest Bridge-
|
||||
Mitgliedschaft/IP-Adresse/DHCP-Client/PPPoE-Client parallel per
|
||||
`async let`), `LanStepView`s neue Warnbox mit zwei aufeinanderfolgenden
|
||||
Bestätigungsdialogen. "bitte nach erfolgreichen Einrichten einen
|
||||
'Fertig'-Button" → `SetupViewModel.finish()` setzt den Assistenten
|
||||
nach erfolgreichem Anwenden zurück. Beides nur Build+Unit-Test-
|
||||
verifiziert, noch nicht live getestet.
|
||||
- "ich würde gerne mehrere Logins hinterlegen können, quasi eine Liste
|
||||
bekannter Router..." → M17: `SavedRouter`/`SavedRoutersStore`
|
||||
(JSON in UserDefaults), Anzeigename defaultet auf `RouterDeviceInfo.
|
||||
boardName` beim ersten Connect, bleibt danach unverändert außer bei
|
||||
explizitem Umbenennen. "ich sehe 'Bekannte Router' nicht" → Liste
|
||||
füllt sich nur bei einer neuen Verbindung nach dem Rebuild, nicht
|
||||
rückwirkend. "das umbenennen funktioniert nicht" → TextField steckte
|
||||
in einem `.disabled(isRenaming)`-Button, der auch das TextField
|
||||
deaktivierte — Fix: kein umschließender Button mehr, `.onTapGesture`
|
||||
für Zeilen-Auswahl. "ein Standort... müssten noch als Freitextfelder"
|
||||
→ `SavedRouter.location` ergänzt, Menü "Umbenennen"→"Bearbeiten"
|
||||
(Name+Standort zusammen), rückwärtskompatibles Decoding. "wenn die
|
||||
Liste länger als 4 bekannte Router wird, mach einen Scrollbalken" →
|
||||
eigene `ScrollView` mit Höhen-Cap statt das Formular wachsen zu
|
||||
lassen. Alle live bestätigt außer dem Scroll-Cap (kein explizites
|
||||
Feedback, kein Einwand).
|
||||
- "kannst du die Punkt vor der Interfaces-Bezeichnung einfärben oder
|
||||
sogar animieren (grün), wenn sie aktuell in Verwendung sind und Daten
|
||||
übertragen?" → M18: neue `InterfaceTraffic`/`InterfaceTrafficMonitor`
|
||||
(eigene dedizierte SSH-Verbindung, `/interface monitor-traffic` hat
|
||||
kein REST-Äquivalent), 3s-Polling. "alle buttons sind grau, keine
|
||||
Animation" → Nutzer bot direkten Router-Zugriff an; da ich keine
|
||||
Zugangsdaten habe und ungern ein Passwort im Chat sehen wollte, um
|
||||
zwei Diagnose-Befehle gebeten (`/interface print without-paging
|
||||
terse`, `/interface monitor-traffic ... once`) statt zu raten. Zwei
|
||||
reale Bugs gefunden: (1) `running=`/`disabled=` existieren in echtem
|
||||
Terse-Output gar nicht — RouterOS codiert das als Buchstaben-Flags vor
|
||||
dem ersten Feld (R/X/S), ein vorbestehender, nie zuvor aufgefallener
|
||||
Parser-Bug; (2) `monitor-traffic` liefert `"50.7kbps"` statt einer
|
||||
reinen Zahl, `Int(...)` scheiterte lautlos zu 0. Beide gefixt, 82
|
||||
Tests grün (inkl. 2 Tests mit dem exakten Nutzer-Output). Live
|
||||
bestätigt ("sehr gut").
|
||||
- "im Übersicht-tab... die Linien auch animiert haben, mit einer
|
||||
animierten Flußrichtung" → M19a: `EdgesCanvas` in `TimelineView(.
|
||||
animation)` gewickelt, `dashPhase` wandert über die Zeit in Richtung
|
||||
"von→nach". "kann man die einzelnen Kästchen verschiebbar machen? ...
|
||||
ein Button für den Reset" → M19b: `nodeOffsets`-State +
|
||||
`@GestureState` fürs laufende Draggen, `effectivePositions` kombiniert
|
||||
beides für Knoten-Karten UND `EdgesCanvas`. "der Button ... fehlt" →
|
||||
`Label` (Icon+Text) vermutlich in Toolbar-Overflow verschwunden, Fix
|
||||
auf Icon-only. "verwirrend, ähnlich wie Refresh" → auf reinen
|
||||
Text-Button "Zurücksetzen" umgestellt (wie der bereits funktionierende
|
||||
"100%"-Button). Alle drei Teile live bestätigt. "commit+push, bitte
|
||||
die Readme.md nicht vergessen" → HANDOFF.md/README.md/Manual.md/
|
||||
CHATLOG.md aktualisiert, Commit + Push (inkl. der noch nicht live
|
||||
getesteten Port-Konflikt-Prüfung + Fertig-Button, auf ausdrücklichen
|
||||
Nutzerwunsch mitgespeichert).
|
||||
|
||||
+137
@@ -1066,6 +1066,143 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und
|
||||
Fehlertexte, keine kuratierten UI-Strings; offen bleiben die übrigen
|
||||
vier Tabs (Einrichten/Übersicht/Geräte/Sicherungen), die noch auf
|
||||
festen deutschen String-Literalen laufen.
|
||||
- ✅ M17: "Bekannte Router" im Verbinden-Tab — Nutzerwunsch: "mehrere
|
||||
Logins hinterlegen können, quasi eine Liste bekannter Router". Neue
|
||||
`SavedRouter`/`SavedRoutersStore` (JSON-Array in UserDefaults, kein
|
||||
eigenes Verzeichnis wie bei Backups nötig) merkt sich Host+Benutzername
|
||||
nach jeder erfolgreichen Verbindung, mit einem beim ersten Mal
|
||||
automatisch gesetzten Anzeigenamen — `RouterDeviceInfo.boardName`
|
||||
(RouterOS' "board-name", die Marketing-Bezeichnung wie "hEX", die
|
||||
bereits beim Verbinden geladen wird), als nächstliegende Auslegung von
|
||||
"werksmäßige Bezeichnung". Der Name bleibt bei späteren Verbindungen
|
||||
zum selben Host/Benutzernamen unverändert, auch wenn sich die erkannte
|
||||
Modellbezeichnung ändern sollte — nur explizites Umbenennen überschreibt
|
||||
ihn. Passwörter bleiben unverändert im macOS-Schlüsselbund (`Keychain-
|
||||
Service`, Schlüssel "Benutzername@Host"), diese Liste speichert nur
|
||||
Host/Benutzername/Anzeigename/letzte Verbindungszeit. Klick auf einen
|
||||
Eintrag füllt Host/Benutzername/Passwort ins Formular, ohne sofort zu
|
||||
verbinden; ein "…"-Menü pro Eintrag bietet Umbenennen (inline) und
|
||||
Entfernen. **Live bestätigt** ("ok, funktioniert jetzt") — allerdings
|
||||
erst im zweiten Anlauf: die Liste füllt sich nur bei einer *neuen*
|
||||
erfolgreichen Verbindung nach dem Rebuild, eine bereits laufende
|
||||
Alt-Verbindung zählt nicht rückwirkend — das war der erste
|
||||
Verwirrungspunkt beim Testen ("ich sehe 'Bekannte Router' nicht").
|
||||
74 Tests grün (5 neue `SavedRoutersStoreTests` + 5 neue
|
||||
`ConnectionServiceTests`-Ergänzungen für die parallel gebaute
|
||||
Port-Konflikt-Prüfung, siehe unten).
|
||||
- **Bugfix, live gefunden:** Umbenennen tat nichts — das TextField steckte
|
||||
innerhalb eines `Button(action: onSelect)`, dessen `.disabled(isRenaming)`
|
||||
auch das TextField selbst deaktivierte. Fix: kein umschließender Button
|
||||
mehr, `.onTapGesture` übernimmt die Zeilen-Auswahl, TextField bekommt
|
||||
eigenen `@FocusState`. **Live bestätigt** ("funktioniert").
|
||||
- **Erweiterung, Nutzerwunsch:** freies Standort-Feld (Etage/Raum/Zweck)
|
||||
pro Eintrag, "damit macht die Zuordnung bei mehreren Geräten einfacher".
|
||||
`SavedRouter.location: String` (leer=nicht gesetzt), eigene
|
||||
`SavedRoutersStore.updateLocation(_:to:)`. Menüpunkt "Umbenennen" wurde
|
||||
zu "Bearbeiten" (öffnet Name + Standort gemeinsam). Rückwärtskompatibles
|
||||
Decoding (`init(from:)` mit `decodeIfPresent`) für bereits gespeicherte
|
||||
Listen ohne `location`-Feld. 76 Tests grün (2 neue). **Live bestätigt**
|
||||
("funktioniert").
|
||||
- **Erweiterung, Nutzerwunsch:** Liste auf ca. 4 sichtbare Einträge
|
||||
gedeckelt (eigene `ScrollView` mit `maxHeight`-Cap statt das ganze
|
||||
Formular wachsen zu lassen) — "die Liste der gespeicherten Router soll
|
||||
nicht zu lang werden". Noch nicht separat live bestätigt (kein
|
||||
ausdrückliches Feedback, aber auch kein Einwand in den folgenden
|
||||
Test-Runden).
|
||||
|
||||
**Gleichzeitig gebaut, noch nicht live bestätigt** (nur Build+Unit-Tests
|
||||
grün, wartet auf Test durch den Nutzer in Xcode):
|
||||
- **LAN-Port-Konflikt-Prüfung im Einrichten-Assistenten** —
|
||||
Nutzerwunsch: vor dem Anlegen eines neuen LAN prüfen, ob der gewählte
|
||||
Port frei ist (nicht in einer Bridge, keine bestehende IP-Adresse, kein
|
||||
WAN-DHCP-Client/PPPoE), und bei einem Konflikt mit doppelter
|
||||
Sicherheitsbestätigung nachfragen, bevor die App den Port selbständig
|
||||
freimacht. Neu: `PortConflict`-Modell (Core/Models), `ConnectionService.
|
||||
checkPortConflict(interfaceName:)` (liest `/interface bridge port`,
|
||||
`/ip address`, `/ip dhcp-client`, `/interface pppoe-client` parallel
|
||||
per `async let`), `SetupViewModel.checkPortConflict(for:)`/
|
||||
`acknowledgePortConflict(for:)`/`hasUnresolvedPortConflict(for:)`,
|
||||
`LanStepView`s neue `PortConflictWarningView` mit zwei aufeinander
|
||||
folgenden `.confirmationDialog`s (Konsequenzen erklären, dann "Wirklich
|
||||
sicher?"). Tatsächlich ausgeführt wird die Port-Freigabe erst beim
|
||||
finalen "Jetzt anwenden" im Review-Schritt, nicht sofort bei der
|
||||
Bestätigung. "Weiter" bleibt gesperrt, bis jeder Konflikt entweder
|
||||
bestätigt oder durch Wahl eines anderen Ports vermieden wurde.
|
||||
- **"Fertig"-Button nach erfolgreichem Anwenden** — vorher gab es nach
|
||||
"Jetzt anwenden" nur einen (weiterhin aktiven) "Zurück"-Button und den
|
||||
jetzt dauerhaft deaktivierten "Jetzt anwenden"-Button, keinen klaren
|
||||
Abschluss. `SetupViewModel.finish()` setzt den Assistenten komplett
|
||||
zurück (frische Default-Werte, `prepareDefaults` erneut gegen die
|
||||
aktuellen Live-Interfaces) für einen sauberen nächsten Durchlauf.
|
||||
|
||||
- ✅ M18: Live-Traffic-Anzeige im Verbinden-Tab — Nutzerwunsch: der Punkt
|
||||
vor jedem Interface soll erkennen lassen, ob es "aktuell in Verwendung
|
||||
ist und Daten überträgt", nicht nur ob der Link steht. Neue
|
||||
`InterfaceTraffic`/`InterfaceTrafficMonitor` (Actor, eigene dedizierte
|
||||
SSH-Verbindung unabhängig von REST/SSH-Hauptverbindung — RouterOS' `/
|
||||
interface monitor-traffic` ist ein reiner CLI-Befehl ohne REST-
|
||||
Äquivalent, gleiche Begründung wie bei `BackupService`/`UpdateService`),
|
||||
pollt alle 3s pro Interface. `ConnectViewModel.startTrafficPolling(...)`/
|
||||
`stopTrafficPolling()`, angestoßen über `.onChange(of: connectionService.
|
||||
state)`. Punkt: grau = kein Link, grün (fest) = Link aber keine Daten,
|
||||
grün pulsierend = überträgt gerade tatsächlich Daten.
|
||||
- **Zwei reale Bugs beim ersten Live-Test gefunden** ("alle buttons
|
||||
sind grau, keine Animation") — auf Bitte des Nutzers zwei Diagnose-
|
||||
Befehle direkt am Router ausgeführt statt zu raten:
|
||||
1. `RouterOSCliParser.parseInterfaces` suchte `running=`/`disabled=`
|
||||
als `key=value`-Paare — die gibt es in echtem `/interface print
|
||||
terse`-Output gar nicht. RouterOS codiert das stattdessen als
|
||||
Buchstaben-Flags vor dem ersten Feld (`"0 R name=ether1 ..."`,
|
||||
`"2 S name=ether3 ..."`: R=running, X=disabled, S=Bridge-Slave)
|
||||
— **jedes Interface las `running` bisher immer als `false`**, ein
|
||||
vorbestehender, nie zuvor aufgefallener Bug (nicht durch M18
|
||||
verursacht, nur durch M18 erstmals sichtbar geworden). Fix: neue
|
||||
`flagsColumn(of:)`-Hilfsfunktion isoliert den Flag-Bereich vor dem
|
||||
ersten "=", prüft dort auf "R"/"X" statt auf nicht existierende
|
||||
Schlüssel.
|
||||
2. `/interface monitor-traffic ... once` liefert Werte wie
|
||||
`"50.7kbps"` (mit Einheit und Dezimalpunkt), keine reine Zahl —
|
||||
`Int(...)` scheiterte daran lautlos zu 0. Fix: `SSHTransport.
|
||||
parseBitsPerSecond(_:)` erkennt Gbps/Mbps/kbps/bps-Suffixe
|
||||
(längere Suffixe zuerst geprüft, da "kbps" selbst auf "bps"
|
||||
endet).
|
||||
- 82 Tests grün (2 neue Parser-Tests mit dem exakten vom Nutzer
|
||||
eingefügten Live-Output + 5 neue Bits-pro-Sekunde-Tests). **Live
|
||||
bestätigt** ("sehr gut").
|
||||
|
||||
- ✅ M19: Übersicht-Tab — animierte Flussrichtung + verschiebbare Knoten.
|
||||
- **Animierte Flussrichtung** (Nutzerwunsch: "die Linien im Übersicht-
|
||||
Tab auch animiert... mit einer animierten Flußrichtung"): alle
|
||||
Verbindungslinien laufen jetzt gestrichelt mit über die Zeit
|
||||
wandernder `dashPhase` in Richtung "von → nach". Technisch über
|
||||
`TimelineView(.animation)` um den bestehenden `Canvas` gelegt — ein
|
||||
reiner `@State`-Wert mit `withAnimation(.repeatForever)` hätte den
|
||||
(immediate-mode) `Canvas` nicht laufend neu gezeichnet,
|
||||
`TimelineView` ruft den Draw-Closure dagegen bei jedem Frame mit
|
||||
frischem Datum neu auf.
|
||||
- **Verschiebbare Knoten + Zurücksetzen-Button** (Nutzerwunsch: "kann
|
||||
man die einzelnen Kästchen verschiebbar machen? ... Die Linien
|
||||
sollten den Kästchen automatisch folgen. ein Button für den Reset
|
||||
wäre super"): neuer `nodeOffsets`-State pro Knoten-ID plus ein
|
||||
`@GestureState` für die gerade laufende Drag-Bewegung, kombiniert in
|
||||
`effectivePositions` — sowohl Knoten-Karten als auch `EdgesCanvas`
|
||||
zeichnen auf dieser verschobenen Position, Linien folgen live mit.
|
||||
`.simultaneousGesture` (nicht `.gesture`) fürs Draggen, damit der
|
||||
bestehende Klick-zum-Auswählen weiter funktioniert. Toolbar-Button
|
||||
"Zurücksetzen" leert `nodeOffsets`, nur aktiv wenn etwas verschoben
|
||||
wurde.
|
||||
- **Zwei kleine UI-Nachbesserungen, beide live gefunden:** Der
|
||||
Reset-Button erschien zunächst gar nicht (`Label` mit Text+Icon
|
||||
braucht mehr Platz als die reinen Icon-Buttons davor, vermutlich
|
||||
von macOS in die Toolbar-Overflow verschoben) — behoben durch
|
||||
Icon-only, dann von Nutzer als "verwirrend, ähnlich wie Refresh"
|
||||
zurückgemeldet — endgültig auf einen reinen Text-Button
|
||||
"Zurücksetzen" umgestellt (wie der bereits funktionierende
|
||||
"100%"-Button).
|
||||
- 82 Tests grün (unverändert — reine SwiftUI-Interaktions-/Animations-
|
||||
Logik ohne neue reine Parser-Funktionen). **Live bestätigt**
|
||||
("das vierschieben funktioniert super, die Linien folgen auch" /
|
||||
"ok, der button ist da und funktioniert" / "funktioniert").
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
|
||||
@@ -62,6 +62,19 @@ schrittweise.
|
||||
separat, mit "Jetzt neu starten"-Button danach.
|
||||
- Schnell-Backup-Button direkt im Verbinden-Tab.
|
||||
- "Trennen"-Button beendet die Verbindung sauber.
|
||||
- **Bekannte Router**: nach jeder erfolgreichen Verbindung merkt sich die
|
||||
App Host und Benutzername (Passwort bleibt wie gewohnt im macOS-
|
||||
Schlüsselbund). Anzeigename wird beim ersten Mal automatisch auf die
|
||||
Hardware-Bezeichnung gesetzt (z.B. "hEX"), lässt sich aber jederzeit
|
||||
über "Bearbeiten" ändern — dort auch ein freies Standort-Feld (z.B.
|
||||
"Keller, Serverschrank" oder "1. OG") zur besseren Unterscheidung
|
||||
mehrerer Router. Klick auf einen Eintrag füllt Host/Benutzername/
|
||||
Passwort ins Formular, ohne sofort zu verbinden. Ab ca. 4 Einträgen
|
||||
scrollt die Liste in sich selbst.
|
||||
- **Live-Traffic-Anzeige**: der Punkt vor jedem Interface in der
|
||||
Geräte-Übersicht ist grau (kein Link), grün (Link, aber kein
|
||||
Datenverkehr) oder pulsierend grün (überträgt gerade tatsächlich
|
||||
Daten).
|
||||
|
||||
## 2. Einrichten (Wizard)
|
||||
|
||||
@@ -111,6 +124,11 @@ Grafisches Diagramm des kompletten aktuellen Router-Zustands:
|
||||
Bearbeiten-Button, da RouterOS solche Einträge selbst verwaltet.
|
||||
- Aktualisiert sich automatisch, sobald du in den Tab wechselst.
|
||||
- Zoom-Buttons (−/100%/+) für die Diagrammgröße.
|
||||
- Verbindungslinien laufen animiert in Flussrichtung ("von → nach").
|
||||
- **Kästchen lassen sich frei verschieben** (anklicken zum Auswählen
|
||||
funktioniert weiterhin parallel) — Verbindungslinien folgen live mit.
|
||||
"Zurücksetzen"-Button in der Toolbar stellt die ursprüngliche
|
||||
Spalten-Anordnung wieder her.
|
||||
|
||||
## 4. Geräte
|
||||
|
||||
@@ -193,6 +211,17 @@ entire Connect tab. More tabs will follow incrementally.
|
||||
separately, with a "Reboot now" button afterwards.
|
||||
- Quick-backup button right in the Connect tab.
|
||||
- "Disconnect" button cleanly ends the session.
|
||||
- **Known Routers**: after every successful connection, the app remembers
|
||||
the host and username (the password stays in macOS Keychain as usual).
|
||||
The display name defaults to the hardware designation (e.g. "hEX") the
|
||||
first time, but can be changed anytime via "Edit" — which also has a
|
||||
free-text location field (e.g. "Basement, server rack" or "1st Floor")
|
||||
to tell multiple routers apart. Clicking an entry fills in host/
|
||||
username/password without connecting yet. The list scrolls in place
|
||||
past about 4 entries.
|
||||
- **Live traffic indicator**: the dot in front of each interface in the
|
||||
device overview is grey (no link), green (link but no traffic), or
|
||||
pulsing green (actually carrying data right now).
|
||||
|
||||
## 2. Setup Wizard
|
||||
|
||||
@@ -240,6 +269,10 @@ Graphical diagram of the router's complete current state:
|
||||
deliberately show no Edit button, since RouterOS manages those itself.
|
||||
- Refreshes automatically whenever you switch to this tab.
|
||||
- Zoom buttons (−/100%/+) for the diagram size.
|
||||
- Connection lines run an animated flow direction ("from → to").
|
||||
- **Cards can be freely dragged** (click-to-select still works alongside
|
||||
it) — connection lines follow live. A "Reset" button in the toolbar
|
||||
restores the original column layout.
|
||||
|
||||
## 4. Devices
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
|
||||
- **Verbinden** — REST-zuerst/SSH-Fallback-Verbindung, Zertifikats-/
|
||||
SSH-Hostkey-TOFU, Geräte- und Routerboard-Infos (Modell, Seriennummer,
|
||||
Firmware), RouterOS-Software-Update-Check inkl. Installation,
|
||||
Routerboard-Firmware-Update, Neustart-Button.
|
||||
Routerboard-Firmware-Update, Neustart-Button. **Bekannte Router**:
|
||||
merkt sich Host/Benutzername nach jeder erfolgreichen Verbindung mit
|
||||
editierbarem Namen (Standard: Hardware-Bezeichnung wie "hEX") und
|
||||
freiem Standort-Feld (Etage/Raum/Zweck), Liste scrollt ab ca. 4
|
||||
Einträgen in sich selbst. Interface-Liste zeigt **Live-Traffic**: Punkt
|
||||
grau/grün/pulsierend-grün je nachdem, ob der Port Verkehr führt.
|
||||
- **Einrichten** — geführter Wizard (WAN → LAN → VLAN → WLAN → Firewall
|
||||
→ Review/Apply) mit Einfach- und Experte-Modus. Mehrere LAN-Interfaces
|
||||
mit eigenem DHCP-Server und optionaler Netzwerk-Isolation
|
||||
@@ -39,7 +44,9 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
|
||||
Ein Knoten (IP-Adresse, Pool, DHCP-Server/-Netzwerk/-Client, Route,
|
||||
Firewall-Filter/-NAT-Regel, WireGuard-Peer) lässt sich direkt über
|
||||
denselben Dialog wie im Experte-Tab bearbeiten und ans Gerät
|
||||
zurückschreiben.
|
||||
zurückschreiben. Verbindungslinien laufen animiert in Flussrichtung
|
||||
("von → nach"); Kästchen lassen sich frei verschieben (Linien folgen
|
||||
live mit), ein Button setzt die Anordnung zurück.
|
||||
- **Geräte** — LAN-Scanner (DHCP-Leases + ARP + Bridge-Host-Tabelle),
|
||||
eine Tabelle pro physischem Port. Rechtsklick weist einem Gerät eine
|
||||
feste IP-Adresse zu (RouterOS' "Make Static") oder entfernt sie wieder.
|
||||
@@ -112,6 +119,10 @@ darauf bauen Übersicht-, Geräte- und Experte-Tab gemeinsam auf.
|
||||
| M14 | Update-Check (Software + Firmware) | ✅ live verifiziert |
|
||||
| M15 | Übersicht-Tab: Diagramm-Fixes + Direktes Bearbeiten | ✅ live verifiziert |
|
||||
| M16 | Zweisprachigkeit (DE/EN), begonnen im Experte-Tab | ✅ live verifiziert |
|
||||
| M17 | Bekannte Router (Verbinden-Tab), Standort-Feld, Scroll-Cap | ✅ live verifiziert |
|
||||
| M18 | Live-Traffic-Anzeige an Interfaces (Verbinden-Tab) | ✅ live verifiziert |
|
||||
| M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert |
|
||||
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
|
||||
|
||||
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
|
||||
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
|
||||
@@ -132,6 +143,9 @@ RouterOS/
|
||||
│ │ │ ├── RouterOSSchema.swift / RouterOSSchemaCatalog.swift — kuratierte Formular-Schemas (Experte-Tab)
|
||||
│ │ │ ├── OverviewGraph.swift — Knoten/Kanten-Modell fürs Übersicht-Diagramm
|
||||
│ │ │ ├── LanDevice.swift — ein Gerät im Geräte-Tab (Lease+ARP+Bridge-Host verschmolzen)
|
||||
│ │ │ ├── SavedRouter.swift — ein Eintrag in "Bekannte Router" (Host/Name/Standort)
|
||||
│ │ │ ├── PortConflict.swift — LAN-Port-Konflikt-Prüfung (Bridge/Adresse/DHCP-Client/PPPoE)
|
||||
│ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample für die Traffic-Anzeige
|
||||
│ │ │ └── RouterOSModels.swift — Credentials, DeviceInfo, RouterBoardInfo, PackageUpdateInfo, Fehler
|
||||
│ │ ├── Networking/
|
||||
│ │ │ ├── RouterOSTransport.swift — Protocol: connect/fetch.../apply/disconnect
|
||||
@@ -144,7 +158,9 @@ RouterOS/
|
||||
│ │ ├── BackupService.swift — Sicherung erstellen/auflisten/wiederherstellen
|
||||
│ │ ├── FactoryResetService.swift — Werksreset ("Gefahrenzone")
|
||||
│ │ ├── UpdateService.swift — Software-/Firmware-Update, Neustart
|
||||
│ │ └── KeychainService.swift — Passwort-Speicherung
|
||||
│ │ ├── KeychainService.swift — Passwort-Speicherung
|
||||
│ │ ├── SavedRoutersStore.swift — "Bekannte Router"-Liste (JSON in UserDefaults)
|
||||
│ │ └── InterfaceTrafficMonitor.swift — pollt Live-Durchsatz über eigene SSH-Verbindung
|
||||
│ ├── Core/Localization/
|
||||
│ │ └── L10n.swift — DE/EN-Übersetzungshelfer (Dictionary-Lookup, kein String Catalog)
|
||||
│ ├── Features/
|
||||
|
||||
@@ -613,6 +613,14 @@ enum L10n {
|
||||
"Neu starten": "Restart",
|
||||
"Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.":
|
||||
"Restarts the router immediately. The connection will briefly drop — reconnect in the \"Connect\" tab afterwards.",
|
||||
"Neustart fehlgeschlagen": "Restart Failed"
|
||||
"Neustart fehlgeschlagen": "Restart Failed",
|
||||
"Bekannte Router": "Known Routers",
|
||||
"Aktuell ausgefüllt": "Currently filled in",
|
||||
"Umbenennen": "Rename",
|
||||
"Bearbeiten": "Edit",
|
||||
"Standort (optional, z.B. Keller, 1. OG)": "Location (optional, e.g. Basement, 1st Floor)",
|
||||
"Entfernen": "Remove",
|
||||
"Fertig": "Done",
|
||||
"Name": "Name"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// A single live throughput sample for one interface, from RouterOS' `/interface monitor-traffic
|
||||
/// <name> once` — used to distinguish "link up" (`NetworkInterface.running`, already shown) from
|
||||
/// "actually carrying data right now", which `running` alone can't: a port stays `running` as soon
|
||||
/// as its link is up/negotiated, even sitting completely idle.
|
||||
struct InterfaceTraffic: Equatable {
|
||||
let rxBitsPerSecond: Int
|
||||
let txBitsPerSecond: Int
|
||||
|
||||
var isActive: Bool { rxBitsPerSecond > 0 || txBitsPerSecond > 0 }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Pre-existing configuration on a physical port that the Setup wizard's LAN step would silently
|
||||
/// override if the user picks that port for a new LAN/DHCP network — surfaced instead so the user
|
||||
/// can either choose a different, actually-free port or explicitly (twice-confirmed) opt in to
|
||||
/// clearing it. `DhcpServerCommandBuilder` already unconditionally detaches a port from any bridge
|
||||
/// as a safety net (see its own doc comment) — this check runs *before* that, to make the same
|
||||
/// fact visible up front instead of only implicitly, and additionally covers cases that builder
|
||||
/// doesn't touch at all (an existing IP address, or the port already being used as a WAN dial-up).
|
||||
struct PortConflict: Equatable {
|
||||
enum Reason: Equatable {
|
||||
case bridgeMember(bridgeName: String)
|
||||
case hasAddresses([String])
|
||||
case dhcpClient
|
||||
case pppoeClient
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .bridgeMember(let bridgeName):
|
||||
return "Ist Mitglied der Bridge \"\(bridgeName)\""
|
||||
case .hasAddresses(let addresses):
|
||||
return "Trägt bereits die IP-Adresse\(addresses.count == 1 ? "" : "n") \(addresses.joined(separator: ", "))"
|
||||
case .dhcpClient:
|
||||
return "Ist als WAN-DHCP-Client konfiguriert (bezieht selbst eine Adresse aus dem Internet)"
|
||||
case .pppoeClient:
|
||||
return "Wird von einer PPPoE-Einwahl verwendet"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let interfaceName: String
|
||||
let reasons: [Reason]
|
||||
|
||||
/// Commands that clear each found conflict so the port is actually free before
|
||||
/// `DhcpServerCommandBuilder`'s own commands run. Bridge membership is deliberately excluded
|
||||
/// here — `DhcpServerCommandBuilder` already removes it unconditionally regardless of whether
|
||||
/// this check ran or was acknowledged, so repeating it would just be a harmless duplicate
|
||||
/// `.remove` at best; excluding it keeps this list to exactly what wouldn't otherwise happen.
|
||||
func resolutionCommands() -> [RouterOSCommand] {
|
||||
reasons.flatMap { reason -> [RouterOSCommand] in
|
||||
switch reason {
|
||||
case .bridgeMember:
|
||||
return []
|
||||
case .hasAddresses:
|
||||
return [RouterOSCommand.remove(
|
||||
menuPath: "/ip address", restPath: "ip/address",
|
||||
matchField: "interface", matchValue: interfaceName,
|
||||
summary: "Bestehende IP-Adresse(n) auf \(interfaceName) entfernen"
|
||||
)]
|
||||
case .dhcpClient:
|
||||
return [RouterOSCommand.remove(
|
||||
menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client",
|
||||
matchField: "interface", matchValue: interfaceName,
|
||||
summary: "WAN-DHCP-Client auf \(interfaceName) entfernen"
|
||||
)]
|
||||
case .pppoeClient:
|
||||
return [RouterOSCommand.remove(
|
||||
menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client",
|
||||
matchField: "interface", matchValue: interfaceName,
|
||||
summary: "PPPoE-Einwahl auf \(interfaceName) entfernen"
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// A router this app has connected to successfully before, remembered in the Verbinden-Tab for
|
||||
/// quick reconnect — the app's own "known devices" list. Login credentials stay exactly where
|
||||
/// they already did (the macOS Keychain, keyed by "username@host" — see `KeychainService`); this
|
||||
/// only remembers which host/username pairs exist, a user-editable display name, and when it was
|
||||
/// last used.
|
||||
struct SavedRouter: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
var host: String
|
||||
var username: String
|
||||
/// Editable bookmark name. Defaults to the router's own hardware marketing name (e.g. "hEX")
|
||||
/// the first time a connection to this host/username succeeds — RouterDeviceInfo.boardName,
|
||||
/// the closest thing to a "werksmäßige Bezeichnung" (factory designation) this app already
|
||||
/// reads — but is never overwritten afterwards, preserving whatever the user renames it to.
|
||||
var name: String
|
||||
/// Free-text location/purpose (e.g. "Keller, Serverschrank" or "1. OG, Gästezimmer") — added
|
||||
/// per explicit request to tell multiple saved routers apart at a glance. Always
|
||||
/// user-supplied, no default; empty means "not set", never shown then.
|
||||
var location: String
|
||||
var lastConnectedAt: Date
|
||||
|
||||
init(id: UUID = UUID(), host: String, username: String, name: String, location: String = "", lastConnectedAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.lastConnectedAt = lastConnectedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, host, username, name, location, lastConnectedAt
|
||||
}
|
||||
|
||||
/// Custom decoding so an already-saved list from before `location` existed keeps loading
|
||||
/// (missing key -> "") instead of the whole list silently vanishing — `SavedRoutersStore.
|
||||
/// load()` treats any decode failure as "no saved routers at all".
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
host = try container.decode(String.self, forKey: .host)
|
||||
username = try container.decode(String.self, forKey: .username)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
location = try container.decodeIfPresent(String.self, forKey: .location) ?? ""
|
||||
lastConnectedAt = try container.decode(Date.self, forKey: .lastConnectedAt)
|
||||
}
|
||||
}
|
||||
@@ -27,19 +27,42 @@ enum RouterOSCliParser {
|
||||
/// whose repeated keys (name=, type=, ...) then overwrote each other in the fields
|
||||
/// dictionary, leaving only the last interface (`lo`) in the result. Found via live testing.
|
||||
static func parseInterfaces(_ raw: String) -> [NetworkInterface] {
|
||||
raw.split(whereSeparator: \.isNewline).compactMap { line in
|
||||
let fields = keyValues(from: String(line))
|
||||
raw.split(whereSeparator: \.isNewline).compactMap { rawLine in
|
||||
let line = String(rawLine)
|
||||
let fields = keyValues(from: line)
|
||||
guard let name = fields["name"] else { return nil }
|
||||
// "running"/"disabled" are never present as key=value pairs here — RouterOS encodes
|
||||
// them as single-letter flags in a fixed-width column before the first key=value pair
|
||||
// instead (e.g. "0 R name=ether1 ..." or "2 S name=ether3 ..."). Confirmed live
|
||||
// (2026-09-15, hEX/RouterOS 7.x): "X" = disabled, "R" = running, "S" = slave (bridge
|
||||
// port) — every interface's `running` silently read as `false` before this fix, since
|
||||
// `isTrue(fields["running"])` always found no such key.
|
||||
let flags = flagsColumn(of: line)
|
||||
return NetworkInterface(
|
||||
name: name,
|
||||
type: fields["type"] ?? "unbekannt",
|
||||
running: isTrue(fields["running"]),
|
||||
disabled: isTrue(fields["disabled"]),
|
||||
running: flags.contains("R"),
|
||||
disabled: flags.contains("X"),
|
||||
macAddress: fields["mac-address"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The index+flags prefix of a `print terse` line, up to (excluding) its first `key=value`
|
||||
/// pair — e.g. "0 R " out of "0 R name=ether1 type=ether ...". Robust to the flag column's
|
||||
/// exact width/character order since it just isolates everything before the first "=", then
|
||||
/// callers check for individual flag letters within it.
|
||||
private static func flagsColumn(of line: String) -> String {
|
||||
guard let equalsIndex = line.firstIndex(of: "=") else { return line }
|
||||
var keyStart = equalsIndex
|
||||
while keyStart > line.startIndex {
|
||||
let previous = line.index(before: keyStart)
|
||||
if line[previous].isWhitespace { break }
|
||||
keyStart = previous
|
||||
}
|
||||
return String(line[line.startIndex..<keyStart])
|
||||
}
|
||||
|
||||
/// Parses `<any menu> print without-paging terse` output generically — used by the Expert
|
||||
/// tool for menus without a curated parser (i.e. almost all of them). Each line's `.id=*N`
|
||||
/// field (present in terse output) becomes the item's `id`; everything else becomes `fields`.
|
||||
|
||||
@@ -49,6 +49,39 @@ final class SSHTransport: RouterOSTransport {
|
||||
return RouterOSCliParser.parseInterfaces(output)
|
||||
}
|
||||
|
||||
/// Live throughput for one interface — RouterOS' `/interface monitor-traffic <name> once` is
|
||||
/// a standard, stable CLI command (documented single-interface usage), not a menu item, so
|
||||
/// it's implemented directly here rather than through the generic `fetchMenuItems` machinery.
|
||||
/// Not exposed over REST: this is a CLI-only command with no documented REST equivalent, so
|
||||
/// callers needing it (see `InterfaceTrafficMonitor`) always use a dedicated SSH connection,
|
||||
/// same reasoning as `BackupService`/`UpdateService`.
|
||||
func fetchInterfaceTraffic(interfaceName: String) async throws -> InterfaceTraffic {
|
||||
let output = try await run("/interface monitor-traffic \(interfaceName) once")
|
||||
let fields = RouterOSCliParser.parseSingletonItem(output).fields
|
||||
return InterfaceTraffic(
|
||||
rxBitsPerSecond: Self.parseBitsPerSecond(fields["rx-bits-per-second"]),
|
||||
txBitsPerSecond: Self.parseBitsPerSecond(fields["tx-bits-per-second"])
|
||||
)
|
||||
}
|
||||
|
||||
/// RouterOS reports these as human-formatted strings with a unit suffix (e.g. "50.7kbps",
|
||||
/// "34.0kbps", or plain "0" when idle), never a bare integer — confirmed live (2026-09-15,
|
||||
/// hEX/RouterOS 7.x). Longer/more specific suffixes are checked before shorter ones ("kbps"
|
||||
/// before "bps") since "kbps" itself ends with "bps" too.
|
||||
static func parseBitsPerSecond(_ raw: String?) -> Int {
|
||||
guard let trimmed = raw?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { return 0 }
|
||||
let unitsBySpecificity: [(suffix: String, multiplier: Double)] = [
|
||||
("Gbps", 1_000_000_000), ("Mbps", 1_000_000), ("kbps", 1_000), ("bps", 1)
|
||||
]
|
||||
for unit in unitsBySpecificity where trimmed.hasSuffix(unit.suffix) {
|
||||
let numberPart = trimmed.dropLast(unit.suffix.count)
|
||||
if let value = Double(numberPart) {
|
||||
return Int(value * unit.multiplier)
|
||||
}
|
||||
}
|
||||
return Int(trimmed) ?? 0
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
try? await client?.close()
|
||||
client = nil
|
||||
|
||||
@@ -112,6 +112,39 @@ final class ConnectionService: ObservableObject {
|
||||
return try await activeTransport.fetchFieldValues(menuPath: menuPath, restPath: restPath, whereField: whereField, whereValue: whereValue, returnField: returnField)
|
||||
}
|
||||
|
||||
/// Live, read-only check for the Setup wizard's LAN step: whether `interfaceName` already
|
||||
/// carries configuration that assigning it its own LAN/DHCP role would silently override
|
||||
/// (bridge membership, an existing IP address, or already being a WAN dial-up). "bridge"
|
||||
/// itself is never flagged — it's the app's own shared-LAN interface, never "someone else's"
|
||||
/// config. Never modifies anything; the caller decides what, if anything, to remove.
|
||||
func checkPortConflict(interfaceName: String) async throws -> PortConflict? {
|
||||
guard interfaceName != "bridge" else { return nil }
|
||||
|
||||
async let bridgePorts = fetchMenuItems(menuPath: "/interface bridge port", restPath: "interface/bridge/port")
|
||||
async let addresses = fetchMenuItems(menuPath: "/ip address", restPath: "ip/address")
|
||||
async let dhcpClients = fetchMenuItems(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client")
|
||||
async let pppoeClients = fetchMenuItems(menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client")
|
||||
|
||||
var reasons: [PortConflict.Reason] = []
|
||||
if let bridgeName = try await bridgePorts.first(where: { $0.fields["interface"] == interfaceName })?.fields["bridge"] {
|
||||
reasons.append(.bridgeMember(bridgeName: bridgeName))
|
||||
}
|
||||
let matchingAddresses = try await addresses
|
||||
.filter { $0.fields["interface"] == interfaceName }
|
||||
.compactMap { $0.fields["address"] }
|
||||
if !matchingAddresses.isEmpty {
|
||||
reasons.append(.hasAddresses(matchingAddresses))
|
||||
}
|
||||
if try await dhcpClients.contains(where: { $0.fields["interface"] == interfaceName }) {
|
||||
reasons.append(.dhcpClient)
|
||||
}
|
||||
if try await pppoeClients.contains(where: { $0.fields["interface"] == interfaceName }) {
|
||||
reasons.append(.pppoeClient)
|
||||
}
|
||||
|
||||
return reasons.isEmpty ? nil : PortConflict(interfaceName: interfaceName, reasons: reasons)
|
||||
}
|
||||
|
||||
private func finishConnecting(using transport: RouterOSTransport) async {
|
||||
activeTransport = transport
|
||||
do {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// Polls live per-interface throughput so the Verbinden-Tab's interface list can show which
|
||||
/// ports are actually carrying traffic right now, not just "link up" (`NetworkInterface.running`
|
||||
/// stays true as soon as a link is negotiated, even sitting completely idle).
|
||||
///
|
||||
/// Always opens its own dedicated SSH connection, independent of whichever transport (REST or
|
||||
/// SSH) the live session is actually using — same reasoning as `BackupService`/`UpdateService`:
|
||||
/// `/interface monitor-traffic` is a CLI-only RouterOS command with no REST equivalent. The
|
||||
/// connection is kept open across repeated `fetchTraffic` calls (one new SSH connection per
|
||||
/// poll tick would be far too slow for a few-times-a-second-feeling refresh) and only reopened
|
||||
/// if the credentials change or a previous attempt failed.
|
||||
actor InterfaceTrafficMonitor {
|
||||
private var transport: SSHTransport?
|
||||
private var connectedCredentials: RouterOSCredentials?
|
||||
|
||||
/// Best-effort per interface — one failing/renamed-mid-session interface doesn't take down
|
||||
/// the whole poll, it's just missing from the result (callers treat "no entry" as "unknown",
|
||||
/// not "idle").
|
||||
func fetchTraffic(interfaceNames: [String], for credentials: RouterOSCredentials) async -> [String: InterfaceTraffic] {
|
||||
if connectedCredentials != credentials {
|
||||
await disconnect()
|
||||
}
|
||||
if transport == nil {
|
||||
let newTransport = SSHTransport(credentials: credentials)
|
||||
guard (try? await newTransport.connect()) != nil else { return [:] }
|
||||
transport = newTransport
|
||||
connectedCredentials = credentials
|
||||
}
|
||||
guard let transport else { return [:] }
|
||||
|
||||
var result: [String: InterfaceTraffic] = [:]
|
||||
for name in interfaceNames {
|
||||
if let traffic = try? await transport.fetchInterfaceTraffic(interfaceName: name) {
|
||||
result[name] = traffic
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await transport?.disconnect()
|
||||
transport = nil
|
||||
connectedCredentials = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
/// Persists the Verbinden-Tab's "known routers" list (host/username/editable name/last-connected)
|
||||
/// as JSON in UserDefaults — small, structured data, no reason for its own file the way backups
|
||||
/// get a directory. Passwords themselves stay exactly where they already did: the macOS Keychain
|
||||
/// via `KeychainService`, keyed by "username@host", untouched by this store.
|
||||
struct SavedRoutersStore {
|
||||
private static let key = "RouterOSAssistant.SavedRouters"
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// Most recently used first.
|
||||
func load() -> [SavedRouter] {
|
||||
sortedByRecency(loadRaw())
|
||||
}
|
||||
|
||||
private func loadRaw() -> [SavedRouter] {
|
||||
guard let data = defaults.data(forKey: Self.key),
|
||||
let routers = try? JSONDecoder().decode([SavedRouter].self, from: data) else { return [] }
|
||||
return routers
|
||||
}
|
||||
|
||||
private func sortedByRecency(_ routers: [SavedRouter]) -> [SavedRouter] {
|
||||
routers.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
|
||||
}
|
||||
|
||||
/// Persists in whatever order given, then returns the recency-sorted view — every public
|
||||
/// method's return value has the same "most recently used first" order as `load()`, so a
|
||||
/// caller can always assign it straight to a displayed list without a separate re-sort.
|
||||
@discardableResult
|
||||
private func save(_ routers: [SavedRouter]) -> [SavedRouter] {
|
||||
if let data = try? JSONEncoder().encode(routers) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
return sortedByRecency(routers)
|
||||
}
|
||||
|
||||
/// Called after a successful connection: adds a new entry (name defaulting to `defaultName`)
|
||||
/// if this host/username pair isn't known yet, or just bumps `lastConnectedAt` on the existing
|
||||
/// one. An existing entry's `name` is never touched here — that's what preserves a user's own
|
||||
/// rename across later reconnects.
|
||||
@discardableResult
|
||||
func recordSuccessfulConnection(host: String, username: String, defaultName: String) -> [SavedRouter] {
|
||||
var routers = loadRaw()
|
||||
if let index = routers.firstIndex(where: { $0.host == host && $0.username == username }) {
|
||||
routers[index].lastConnectedAt = Date()
|
||||
} else {
|
||||
routers.append(SavedRouter(host: host, username: username, name: defaultName))
|
||||
}
|
||||
return save(routers)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func rename(_ id: SavedRouter.ID, to newName: String) -> [SavedRouter] {
|
||||
var routers = loadRaw()
|
||||
guard let index = routers.firstIndex(where: { $0.id == id }) else { return sortedByRecency(routers) }
|
||||
routers[index].name = newName
|
||||
return save(routers)
|
||||
}
|
||||
|
||||
/// Free-text location/purpose (e.g. "Keller" or "1. OG") — kept as its own method rather than
|
||||
/// folded into `rename` since a caller may want to update just one of the two, and it mirrors
|
||||
/// `rename`'s exact shape.
|
||||
@discardableResult
|
||||
func updateLocation(_ id: SavedRouter.ID, to newLocation: String) -> [SavedRouter] {
|
||||
var routers = loadRaw()
|
||||
guard let index = routers.firstIndex(where: { $0.id == id }) else { return sortedByRecency(routers) }
|
||||
routers[index].location = newLocation
|
||||
return save(routers)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(_ id: SavedRouter.ID) -> [SavedRouter] {
|
||||
var routers = loadRaw()
|
||||
routers.removeAll { $0.id == id }
|
||||
return save(routers)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,44 @@ struct OverviewView: View {
|
||||
return viewModel.graph.highlightedNodeIDs(startingAt: id)
|
||||
}
|
||||
|
||||
/// Manual per-node drag adjustments, on top of `OverviewLayout`'s computed grid position —
|
||||
/// per explicit request: nodes can be dragged around for a clearer layout, connection lines
|
||||
/// follow automatically, and a toolbar button resets back to the original arrangement. Kept
|
||||
/// as plain view state (not persisted) — "Ausgangszustand" only ever means this session's
|
||||
/// freshly computed column layout, not something remembered across relaunches.
|
||||
@State private var nodeOffsets: [String: CGSize] = [:]
|
||||
/// The node currently mid-drag, if any, plus its live (not-yet-committed) translation — kept
|
||||
/// separate from `nodeOffsets` so `EdgesCanvas`/the dragged card itself update every frame of
|
||||
/// the drag, not just once it ends.
|
||||
@GestureState private var activeDrag: ActiveNodeDrag?
|
||||
|
||||
private struct ActiveNodeDrag: Equatable {
|
||||
let nodeID: String
|
||||
let translation: CGSize
|
||||
}
|
||||
|
||||
private func offset(forNodeID id: String) -> CGSize {
|
||||
var result = nodeOffsets[id] ?? .zero
|
||||
if let activeDrag, activeDrag.nodeID == id {
|
||||
result.width += activeDrag.translation.width
|
||||
result.height += activeDrag.translation.height
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// `OverviewLayout`'s computed grid positions, shifted by each node's current (committed +
|
||||
/// live-dragging) offset — what both the node cards and `EdgesCanvas`'s line endpoints
|
||||
/// actually draw at.
|
||||
private var effectivePositions: [String: CGPoint] {
|
||||
var result: [String: CGPoint] = [:]
|
||||
result.reserveCapacity(layout.positions.count)
|
||||
for (id, base) in layout.positions {
|
||||
let off = offset(forNodeID: id)
|
||||
result[id] = CGPoint(x: base.x + off.width, y: base.y + off.height)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
_viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService))
|
||||
@@ -103,6 +141,11 @@ struct OverviewView: View {
|
||||
Button { scale = max(0.4, scale - 0.15) } label: { Image(systemName: "minus.magnifyingglass") }
|
||||
Button { scale = 1.0 } label: { Text("100%") }
|
||||
Button { scale = min(2.0, scale + 0.15) } label: { Image(systemName: "plus.magnifyingglass") }
|
||||
Button("Zurücksetzen") {
|
||||
nodeOffsets = [:]
|
||||
}
|
||||
.disabled(nodeOffsets.isEmpty)
|
||||
.help("Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -149,7 +192,7 @@ struct OverviewView: View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
EdgesCanvas(
|
||||
graph: viewModel.graph,
|
||||
positions: layout.positions,
|
||||
positions: effectivePositions,
|
||||
highlightedNodeIDs: highlightedNodeIDs,
|
||||
hoveredEdge: $hoveredEdge,
|
||||
hoverPoint: $hoverPoint,
|
||||
@@ -168,7 +211,7 @@ struct OverviewView: View {
|
||||
}
|
||||
|
||||
ForEach(viewModel.graph.nodes) { node in
|
||||
if let point = layout.positions[node.id] {
|
||||
if let point = effectivePositions[node.id] {
|
||||
NodeCardView(
|
||||
node: node,
|
||||
isSelected: node.id == viewModel.selectedNodeID,
|
||||
@@ -183,6 +226,21 @@ struct OverviewView: View {
|
||||
.onHover { isHovering in
|
||||
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
|
||||
}
|
||||
// `.simultaneousGesture` (not `.gesture`) so this doesn't steal
|
||||
// the tap above — a small `minimumDistance` lets a plain click
|
||||
// still register as a tap-to-select instead of a zero-length drag.
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 2, coordinateSpace: .local)
|
||||
.updating($activeDrag) { value, state, _ in
|
||||
state = ActiveNodeDrag(nodeID: node.id, translation: value.translation)
|
||||
}
|
||||
.onEnded { value in
|
||||
var committed = nodeOffsets[node.id] ?? .zero
|
||||
committed.width += value.translation.width
|
||||
committed.height += value.translation.height
|
||||
nodeOffsets[node.id] = committed
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,40 +357,68 @@ private struct EdgesCanvas: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One "on, off" cycle of the flow animation's dash pattern, in points.
|
||||
private static let flowDash: [CGFloat] = [5, 5]
|
||||
/// How fast the dash pattern travels along a line, in points/second — slow enough to read as
|
||||
/// "gentle flow", not a distracting marquee.
|
||||
private static let flowSpeed: CGFloat = 12
|
||||
|
||||
/// Per explicit request: every connection line gets an animated flow direction (from "from"
|
||||
/// to "to", the same direction the diagram's own dependency arrows already document) rather
|
||||
/// than a static stroke. `TimelineView(.animation)` re-invokes the `Canvas` closure on every
|
||||
/// display frame with a fresh `timeline.date`, which is what lets a `dashPhase` computed from
|
||||
/// elapsed time actually animate — a plain `@State` `withAnimation(.repeatForever)` value
|
||||
/// wouldn't: `Canvas` is immediate-mode, so nothing outside of `TimelineView` re-triggers its
|
||||
/// drawing closure on every frame. The phase decreases over time, which moves the visible
|
||||
/// dashes in the direction the path is stroked (start → end, i.e. "from" → "to").
|
||||
private func dashPhase(at date: Date) -> CGFloat {
|
||||
let cycleLength = Self.flowDash.reduce(0, +)
|
||||
let elapsed = CGFloat(date.timeIntervalSinceReferenceDate)
|
||||
return -(elapsed * Self.flowSpeed).truncatingRemainder(dividingBy: cycleLength)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, _ in
|
||||
for geometry in geometries {
|
||||
let edge = geometry.edge
|
||||
let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to)
|
||||
let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected
|
||||
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
|
||||
var path = Path()
|
||||
path.move(to: geometry.start)
|
||||
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
|
||||
let baseColor = OverviewStyle.color(for: edge.kind)
|
||||
context.stroke(
|
||||
path,
|
||||
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
|
||||
lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3)
|
||||
)
|
||||
}
|
||||
}
|
||||
.onContinuousHover { phase in
|
||||
switch phase {
|
||||
case .active(let location):
|
||||
hoverPoint = location
|
||||
hoveredEdge = stickyNearestEdge(to: location)
|
||||
case .ended:
|
||||
hoveredEdge = nil
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
SpatialTapGesture()
|
||||
.onEnded { value in
|
||||
guard let edge = nearestEdge(to: value.location) else { return }
|
||||
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
|
||||
TimelineView(.animation) { timeline in
|
||||
let phase = dashPhase(at: timeline.date)
|
||||
Canvas { context, _ in
|
||||
for geometry in geometries {
|
||||
let edge = geometry.edge
|
||||
let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to)
|
||||
let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected
|
||||
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
|
||||
var path = Path()
|
||||
path.move(to: geometry.start)
|
||||
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
|
||||
let baseColor = OverviewStyle.color(for: edge.kind)
|
||||
context.stroke(
|
||||
path,
|
||||
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
|
||||
style: StrokeStyle(
|
||||
lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3),
|
||||
lineCap: .round,
|
||||
dash: Self.flowDash,
|
||||
dashPhase: phase
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.onContinuousHover { phase in
|
||||
switch phase {
|
||||
case .active(let location):
|
||||
hoverPoint = location
|
||||
hoveredEdge = stickyNearestEdge(to: location)
|
||||
case .ended:
|
||||
hoveredEdge = nil
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
SpatialTapGesture()
|
||||
.onEnded { value in
|
||||
guard let edge = nearestEdge(to: value.location) else { return }
|
||||
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Edges eligible for hover right now — every edge normally, but narrowed down to just the
|
||||
|
||||
@@ -9,6 +9,14 @@ struct ConnectView: View {
|
||||
@State private var showRebootConfirmation = false
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
/// How many "Bekannte Router" rows are visible before the list scrolls in place — per
|
||||
/// explicit request, that list shouldn't be allowed to grow indefinitely. `savedRouterRowHeight`
|
||||
/// is an estimate (name + optional location line + "user@host" caption, plus vertical padding)
|
||||
/// good enough for a scroll-height cap; it doesn't need to match exactly since `ScrollView`
|
||||
/// only ever caps at `maxHeight`, never forces it when content is shorter.
|
||||
private static let savedRoutersMaxVisibleRows: CGFloat = 4
|
||||
private static let savedRouterRowHeight: CGFloat = 60
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
_viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService))
|
||||
@@ -26,6 +34,38 @@ struct ConnectView: View {
|
||||
private var mainContent: some View {
|
||||
NavigationSplitView {
|
||||
Form {
|
||||
if !viewModel.savedRouters.isEmpty {
|
||||
Section(L10n.t("Bekannte Router", appLanguage)) {
|
||||
// Capped height + its own ScrollView instead of letting the ForEach grow
|
||||
// the outer Form indefinitely — per explicit request, the saved-router
|
||||
// list shouldn't be allowed to get long; past ~4 entries it scrolls in
|
||||
// place instead. `maxHeight` only ever caps, so with 4 or fewer entries
|
||||
// the ScrollView still just sizes to fit its content, no dead space.
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(viewModel.savedRouters.enumerated()), id: \.element.id) { index, router in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
}
|
||||
SavedRouterRow(
|
||||
router: router,
|
||||
isCurrent: router.host == viewModel.host && router.username == viewModel.username,
|
||||
appLanguage: appLanguage,
|
||||
onSelect: { viewModel.selectSavedRouter(router) },
|
||||
onSave: { newName, newLocation in
|
||||
viewModel.renameSavedRouter(router.id, to: newName)
|
||||
viewModel.updateSavedRouterLocation(router.id, to: newLocation)
|
||||
},
|
||||
onDelete: { viewModel.removeSavedRouter(router.id) }
|
||||
)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: Self.savedRoutersMaxVisibleRows * Self.savedRouterRowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
Section(L10n.t("Verbindung", appLanguage)) {
|
||||
TextField(L10n.t("IP-Adresse oder Hostname", appLanguage), text: $viewModel.host)
|
||||
.help(L10n.t("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.", appLanguage))
|
||||
@@ -54,6 +94,16 @@ struct ConnectView: View {
|
||||
.onAppear {
|
||||
viewModel.onAppear()
|
||||
backupViewModel.load()
|
||||
if case .connected = connectionService.state, let credentials = connectionService.credentials {
|
||||
viewModel.startTrafficPolling(credentials: credentials)
|
||||
}
|
||||
}
|
||||
.onChange(of: connectionService.state) { _, newState in
|
||||
if case .connected = newState, let credentials = connectionService.credentials {
|
||||
viewModel.startTrafficPolling(credentials: credentials)
|
||||
} else {
|
||||
viewModel.stopTrafficPolling()
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.t("Unbekanntes Zertifikat", appLanguage),
|
||||
@@ -282,9 +332,10 @@ struct ConnectView: View {
|
||||
Section("Interfaces") {
|
||||
ForEach(connectionService.interfaces) { interface in
|
||||
HStack {
|
||||
Image(systemName: interface.running ? "circle.fill" : "circle")
|
||||
.foregroundStyle(interface.running ? .green : .secondary)
|
||||
.font(.caption)
|
||||
InterfaceActivityDot(
|
||||
running: interface.running,
|
||||
isActive: viewModel.interfaceTraffic[interface.name]?.isActive ?? false
|
||||
)
|
||||
VStack(alignment: .leading) {
|
||||
Text(interface.name).bold()
|
||||
Text(interface.type).font(.caption).foregroundStyle(.secondary)
|
||||
@@ -435,6 +486,123 @@ private extension View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Status dot in front of an interface's name in the Verbinden-Tab's "Interfaces" list. Grey =
|
||||
/// link down, solid green = link up but idle, pulsing green = actually carrying traffic right
|
||||
/// now (per `InterfaceTrafficMonitor`'s live `/interface monitor-traffic` polling) — `running`
|
||||
/// alone can't tell "idle" from "busy", it only reflects link negotiation.
|
||||
private struct InterfaceActivityDot: View {
|
||||
let running: Bool
|
||||
let isActive: Bool
|
||||
|
||||
@State private var pulseDown = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(running ? Color.green : Color.secondary)
|
||||
.frame(width: 8, height: 8)
|
||||
.opacity(isActive && pulseDown ? 0.35 : 1.0)
|
||||
.onChange(of: isActive, initial: true) { _, active in
|
||||
if active {
|
||||
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
|
||||
pulseDown = true
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
pulseDown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row in the Verbinden-Tab's "Bekannte Router" list — click fills in the host/username/
|
||||
/// remembered password without connecting yet (the normal "Verbinden" button still does that),
|
||||
/// so a wrong pick is a no-op. Editing (name + free-text location, e.g. "Keller" or "1. OG") is
|
||||
/// inline (tap "Bearbeiten", edit both fields, commit) rather than a separate sheet.
|
||||
private struct SavedRouterRow: View {
|
||||
let router: SavedRouter
|
||||
let isCurrent: Bool
|
||||
let appLanguage: String
|
||||
let onSelect: () -> Void
|
||||
let onSave: (_ name: String, _ location: String) -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
@State private var isEditing = false
|
||||
@State private var draftName = ""
|
||||
@State private var draftLocation = ""
|
||||
@FocusState private var isNameFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
// Not wrapped in a Button — a `Button(action: onSelect)` around this whole VStack
|
||||
// previously nested the edit TextField *inside* a Button, and `.disabled(isEditing)`
|
||||
// on that Button also disabled every descendant, including the TextField itself:
|
||||
// nothing could be typed. Confirmed live (2026-09-15): "das umbenennen funktioniert
|
||||
// nicht". `.onTapGesture` (guarded to skip while editing) gets the same "click row to
|
||||
// select" behavior without disabling anything.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if isEditing {
|
||||
TextField(L10n.t("Name", appLanguage), text: $draftName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($isNameFieldFocused)
|
||||
.onSubmit(commitEdits)
|
||||
TextField(L10n.t("Standort (optional, z.B. Keller, 1. OG)", appLanguage), text: $draftLocation)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(commitEdits)
|
||||
} else {
|
||||
Text(router.name).bold()
|
||||
if !router.location.isEmpty {
|
||||
Label(router.location, systemImage: "mappin.and.ellipse")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("\(router.username)@\(router.host)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard !isEditing else { return }
|
||||
onSelect()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isCurrent {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.help(L10n.t("Aktuell ausgefüllt", appLanguage))
|
||||
}
|
||||
|
||||
Menu {
|
||||
if isEditing {
|
||||
Button(L10n.t("Fertig", appLanguage), action: commitEdits)
|
||||
} else {
|
||||
Button(L10n.t("Bearbeiten", appLanguage)) {
|
||||
draftName = router.name
|
||||
draftLocation = router.location
|
||||
isEditing = true
|
||||
isNameFieldFocused = true
|
||||
}
|
||||
}
|
||||
Button(L10n.t("Entfernen", appLanguage), role: .destructive, action: onDelete)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
private func commitEdits() {
|
||||
let trimmedName = draftName.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedLocation = draftLocation.trimmingCharacters(in: .whitespaces)
|
||||
onSave(trimmedName.isEmpty ? router.name : trimmedName, trimmedLocation)
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConnectView(connectionService: ConnectionService())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ final class ConnectViewModel: ObservableObject {
|
||||
@Published var username: String = "admin"
|
||||
@Published var password: String = ""
|
||||
@Published var rememberPassword: Bool = true
|
||||
@Published private(set) var savedRouters: [SavedRouter] = []
|
||||
|
||||
@Published private(set) var packageUpdateInfo: PackageUpdateInfo?
|
||||
@Published private(set) var isCheckingForUpdates = false
|
||||
@@ -23,20 +24,46 @@ final class ConnectViewModel: ObservableObject {
|
||||
@Published var rebootError: String?
|
||||
@Published private(set) var didSendReboot = false
|
||||
|
||||
@Published private(set) var interfaceTraffic: [String: InterfaceTraffic] = [:]
|
||||
|
||||
let connectionService: ConnectionService
|
||||
private let keychain = KeychainService()
|
||||
private let updateService = UpdateService()
|
||||
private let savedRoutersStore = SavedRoutersStore()
|
||||
private let trafficMonitor = InterfaceTrafficMonitor()
|
||||
private var trafficPollingTask: Task<Void, Never>?
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
savedRouters = savedRoutersStore.load()
|
||||
if let saved = keychain.loadPassword(forHost: host, username: username) {
|
||||
password = saved
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills in a known router's host/username (and its remembered password, if any) without
|
||||
/// connecting yet — the user still reviews/confirms via the normal "Verbinden" button.
|
||||
func selectSavedRouter(_ router: SavedRouter) {
|
||||
host = router.host
|
||||
username = router.username
|
||||
password = keychain.loadPassword(forHost: router.host, username: router.username) ?? ""
|
||||
}
|
||||
|
||||
func renameSavedRouter(_ id: SavedRouter.ID, to newName: String) {
|
||||
savedRouters = savedRoutersStore.rename(id, to: newName)
|
||||
}
|
||||
|
||||
func updateSavedRouterLocation(_ id: SavedRouter.ID, to newLocation: String) {
|
||||
savedRouters = savedRoutersStore.updateLocation(id, to: newLocation)
|
||||
}
|
||||
|
||||
func removeSavedRouter(_ id: SavedRouter.ID) {
|
||||
savedRouters = savedRoutersStore.remove(id)
|
||||
}
|
||||
|
||||
func connect() {
|
||||
let credentials = RouterOSCredentials(host: host, username: username, password: password)
|
||||
if rememberPassword {
|
||||
@@ -44,9 +71,43 @@ final class ConnectViewModel: ObservableObject {
|
||||
}
|
||||
Task {
|
||||
await connectionService.connect(with: credentials)
|
||||
if case .connected = connectionService.state {
|
||||
let defaultName = connectionService.deviceInfo?.boardName ?? host
|
||||
savedRouters = savedRoutersStore.recordSuccessfulConnection(
|
||||
host: host, username: username, defaultName: defaultName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts (or restarts, if already running) periodic traffic polling for whatever interfaces
|
||||
/// `connectionService.interfaces` currently lists — re-read every tick so newly-appearing
|
||||
/// interfaces (e.g. a VLAN added via the Setup wizard) get picked up without restarting the
|
||||
/// poll. 3s cadence: fast enough to feel "live" for a status dot, not so fast it noticeably
|
||||
/// loads a home router's CPU with a constant stream of SSH round-trips.
|
||||
func startTrafficPolling(credentials: RouterOSCredentials) {
|
||||
stopTrafficPolling()
|
||||
trafficPollingTask = Task {
|
||||
while !Task.isCancelled {
|
||||
let names = connectionService.interfaces.map(\.name)
|
||||
if !names.isEmpty {
|
||||
let traffic = await trafficMonitor.fetchTraffic(interfaceNames: names, for: credentials)
|
||||
if !Task.isCancelled {
|
||||
interfaceTraffic = traffic
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopTrafficPolling() {
|
||||
trafficPollingTask?.cancel()
|
||||
trafficPollingTask = nil
|
||||
interfaceTraffic = [:]
|
||||
Task { await trafficMonitor.disconnect() }
|
||||
}
|
||||
|
||||
func trustAndRetry(fingerprint: String) {
|
||||
Task {
|
||||
await connectionService.trustCurrentCertificateAndRetry(fingerprint: fingerprint)
|
||||
|
||||
@@ -14,6 +14,26 @@ struct LanStepView: View {
|
||||
}
|
||||
}
|
||||
.help("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).")
|
||||
.onChange(of: config.interfaceName) { _, _ in
|
||||
viewModel.checkPortConflict(for: config.id)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.checkPortConflict(for: config.id)
|
||||
}
|
||||
|
||||
if viewModel.isCheckingPortConflict.contains(config.id) {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Prüfe, ob der Port frei ist…").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else if let conflict = viewModel.lanPortConflicts[config.id] {
|
||||
PortConflictWarningView(
|
||||
conflict: conflict,
|
||||
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
|
||||
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
|
||||
)
|
||||
}
|
||||
|
||||
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $config.routerAddress)
|
||||
.help("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.")
|
||||
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $config.networkAddress)
|
||||
@@ -72,6 +92,79 @@ struct LanStepView: View {
|
||||
&& !config.networkAddress.isEmpty
|
||||
&& !config.poolRangeStart.isEmpty
|
||||
&& !config.poolRangeEnd.isEmpty
|
||||
&& !viewModel.hasUnresolvedPortConflict(for: config.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown inline under a LAN config's port Picker once `SetupViewModel.checkPortConflict(for:)`
|
||||
/// finds the chosen port already carries other configuration. Requires two separate confirmations
|
||||
/// before the app is allowed to clear it — per explicit request: this silently overriding a port's
|
||||
/// existing role (an active WAN dial-up, a bridge membership, a manually-set address) previously
|
||||
/// wasn't visible to the user at all beyond `DhcpServerCommandBuilder`'s own unconditional bridge
|
||||
/// detach; this makes the consequences explicit and opt-in instead.
|
||||
private struct PortConflictWarningView: View {
|
||||
let conflict: PortConflict
|
||||
let isAcknowledged: Bool
|
||||
let onConfirm: () -> Void
|
||||
|
||||
@State private var showConsequencesConfirmation = false
|
||||
@State private var showFinalConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
if isAcknowledged {
|
||||
Label("\(conflict.interfaceName) wird beim Anwenden freigemacht", systemImage: "checkmark.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Port \(conflict.interfaceName) ist nicht frei", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.red)
|
||||
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
|
||||
Text("• \(reason.description)")
|
||||
.font(.caption)
|
||||
}
|
||||
Text("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Port jetzt freimachen…", role: .destructive) {
|
||||
showConsequencesConfirmation = true
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
|
||||
.confirmationDialog(
|
||||
"Port \(conflict.interfaceName) freimachen?",
|
||||
isPresented: $showConsequencesConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Fortfahren", role: .destructive) {
|
||||
showFinalConfirmation = true
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text(consequenceText)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Wirklich sicher?",
|
||||
isPresented: $showFinalConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Ja, endgültig freimachen", role: .destructive) {
|
||||
onConfirm()
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var consequenceText: String {
|
||||
(["Folgendes wird entfernt, bevor \(conflict.interfaceName) als neues Netzwerk eingerichtet wird:"]
|
||||
+ conflict.reasons.map { "• \($0.description)" }
|
||||
+ ["Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen."])
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,18 +48,23 @@ struct ReviewApplyView: View {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
.disabled(viewModel.isApplying)
|
||||
Spacer()
|
||||
Button {
|
||||
if let credentials {
|
||||
viewModel.apply(credentials: credentials)
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isApplying {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Jetzt anwenden")
|
||||
if viewModel.didApplySuccessfully {
|
||||
Button("Fertig") { viewModel.finish() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
Button {
|
||||
if let credentials {
|
||||
viewModel.apply(credentials: credentials)
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isApplying {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Jetzt anwenden")
|
||||
}
|
||||
}
|
||||
.disabled(credentials == nil || viewModel.isApplying)
|
||||
}
|
||||
.disabled(credentials == nil || viewModel.isApplying || viewModel.didApplySuccessfully)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ final class SetupViewModel: ObservableObject {
|
||||
@Published private(set) var isLoadingFirewallRuleCounts = false
|
||||
@Published private(set) var firewallRuleCountsError: String?
|
||||
|
||||
/// Live port-conflict result per LAN config, once checked — nil means either "not checked
|
||||
/// yet" or "checked, port is free". See `checkPortConflict(for:)`.
|
||||
@Published private(set) var lanPortConflicts: [LanDhcpConfig.ID: PortConflict] = [:]
|
||||
@Published private(set) var isCheckingPortConflict: Set<LanDhcpConfig.ID> = []
|
||||
/// A conflict the user has explicitly (twice-confirmed) opted to clear — only these get their
|
||||
/// `resolutionCommands()` added to `plannedCommands`. Cleared whenever the port selection
|
||||
/// changes, so switching to a different, already-acknowledged port re-asks.
|
||||
@Published var acknowledgedPortConflicts: Set<LanDhcpConfig.ID> = []
|
||||
|
||||
@Published private(set) var isApplying = false
|
||||
@Published private(set) var applyLog: [String] = []
|
||||
@Published private(set) var applyError: String?
|
||||
@@ -49,7 +58,12 @@ final class SetupViewModel: ObservableObject {
|
||||
|
||||
var plannedCommands: [RouterOSCommand] {
|
||||
wanConfig.buildCommands()
|
||||
+ lanConfigs.flatMap { $0.buildCommands() }
|
||||
+ lanConfigs.flatMap { config -> [RouterOSCommand] in
|
||||
let resolution = acknowledgedPortConflicts.contains(config.id)
|
||||
? (lanPortConflicts[config.id]?.resolutionCommands() ?? [])
|
||||
: []
|
||||
return resolution + config.buildCommands()
|
||||
}
|
||||
+ vlans.flatMap { $0.buildCommands() }
|
||||
+ wifiNetworks.flatMap { $0.buildCommands() }
|
||||
+ (firewallSectionEnabled
|
||||
@@ -117,6 +131,39 @@ final class SetupViewModel: ObservableObject {
|
||||
func removeLan(_ id: LanDhcpConfig.ID) {
|
||||
guard lanConfigs.count > 1 else { return }
|
||||
lanConfigs.removeAll { $0.id == id }
|
||||
lanPortConflicts[id] = nil
|
||||
acknowledgedPortConflicts.remove(id)
|
||||
isCheckingPortConflict.remove(id)
|
||||
}
|
||||
|
||||
/// Live-checks whether the port currently picked for this LAN config already carries other
|
||||
/// configuration (bridge membership, an existing address, WAN dial-up) — called when the LAN
|
||||
/// step appears and whenever its interface Picker selection changes. Any prior acknowledgement
|
||||
/// is dropped: switching ports means re-asking, since a previously-cleared conflict on the old
|
||||
/// port says nothing about the newly picked one. Best-effort — a failed check (e.g. transient
|
||||
/// connection hiccup) must not block the wizard; the user simply doesn't get the extra warning
|
||||
/// for that attempt, same as before this feature existed.
|
||||
func checkPortConflict(for configID: LanDhcpConfig.ID) {
|
||||
guard let config = lanConfigs.first(where: { $0.id == configID }) else { return }
|
||||
acknowledgedPortConflicts.remove(configID)
|
||||
lanPortConflicts[configID] = nil
|
||||
isCheckingPortConflict.insert(configID)
|
||||
Task {
|
||||
defer { isCheckingPortConflict.remove(configID) }
|
||||
lanPortConflicts[configID] = try? await connectionService.checkPortConflict(interfaceName: config.interfaceName)
|
||||
}
|
||||
}
|
||||
|
||||
/// The user has been shown what's on this port and, after two explicit confirmations, chose
|
||||
/// to have the app clear it — see `PortConflictWarningView` in `LanStepView.swift`.
|
||||
func acknowledgePortConflict(for configID: LanDhcpConfig.ID) {
|
||||
acknowledgedPortConflicts.insert(configID)
|
||||
}
|
||||
|
||||
/// Blocks "Weiter" on the LAN step until every conflicting port has either been acknowledged
|
||||
/// (app will clear it) or the user picked a different, actually-free port instead.
|
||||
func hasUnresolvedPortConflict(for configID: LanDhcpConfig.ID) -> Bool {
|
||||
lanPortConflicts[configID] != nil && !acknowledgedPortConflicts.contains(configID)
|
||||
}
|
||||
|
||||
/// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones
|
||||
@@ -200,6 +247,34 @@ final class SetupViewModel: ObservableObject {
|
||||
setFirewallSectionEnabled(true)
|
||||
}
|
||||
|
||||
/// Ends the wizard after a successful apply — without this, the completed review screen just
|
||||
/// sits there with a disabled "Jetzt anwenden" and no way forward except "Zurück" (which would
|
||||
/// re-walk now-stale steps against the router state this apply just changed). Resets to a
|
||||
/// fresh run and re-reads live interface defaults, so a follow-up run (e.g. adding one more
|
||||
/// LAN afterwards) starts from the router's actual current state rather than this session's
|
||||
/// now-outdated in-memory one.
|
||||
func finish() {
|
||||
step = .mode
|
||||
mode = .simple
|
||||
wanConfig = WanConfig(interfaceName: "ether1")
|
||||
lanConfigs = [LanDhcpConfig()]
|
||||
vlanSectionEnabled = false
|
||||
vlans = []
|
||||
wifiNetworks = []
|
||||
unsupportedWifiInterfaces = []
|
||||
firewallSectionEnabled = false
|
||||
existingFirewallRuleCounts = nil
|
||||
isLoadingFirewallRuleCounts = false
|
||||
firewallRuleCountsError = nil
|
||||
lanPortConflicts = [:]
|
||||
isCheckingPortConflict = []
|
||||
acknowledgedPortConflicts = []
|
||||
applyLog = []
|
||||
applyError = nil
|
||||
didApplySuccessfully = false
|
||||
prepareDefaults(from: connectionService.interfaces)
|
||||
}
|
||||
|
||||
func apply(credentials: RouterOSCredentials) {
|
||||
isApplying = true
|
||||
applyError = nil
|
||||
|
||||
@@ -5,6 +5,11 @@ private final class MockTransport: RouterOSTransport {
|
||||
let kind: RouterOSTransportKind
|
||||
var connectError: Error?
|
||||
var deviceInfo = RouterDeviceInfo(boardName: "Mock", routerOSVersion: "7.0", architecture: "arm64", uptime: "1h")
|
||||
/// Canned `fetchMenuItems` responses, keyed by menu path — lets a test simulate whatever the
|
||||
/// router currently reports for `/interface bridge port`, `/ip address`, etc. without a real
|
||||
/// connection. Menu paths not present here return an empty list, same as a genuinely empty
|
||||
/// menu on the router.
|
||||
var menuItemsByPath: [String: [RouterOSMenuItem]] = [:]
|
||||
|
||||
init(kind: RouterOSTransportKind, connectError: Error? = nil) {
|
||||
self.kind = kind
|
||||
@@ -18,7 +23,7 @@ private final class MockTransport: RouterOSTransport {
|
||||
func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo }
|
||||
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 fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] { menuItemsByPath[menuPath] ?? [] }
|
||||
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 {}
|
||||
@@ -79,4 +84,61 @@ final class ConnectionServiceTests: XCTestCase {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - checkPortConflict (Setup wizard LAN step pre-check)
|
||||
|
||||
private func connectedService(menuItemsByPath: [String: [RouterOSMenuItem]]) async -> ConnectionService {
|
||||
let service = ConnectionService()
|
||||
let transport = MockTransport(kind: .rest)
|
||||
transport.menuItemsByPath = menuItemsByPath
|
||||
await service.connect(with: credentials, makeRestTransport: { transport }, makeSSHTransport: { transport })
|
||||
return service
|
||||
}
|
||||
|
||||
func testCheckPortConflictReturnsNilWhenPortIsFree() async throws {
|
||||
let service = await connectedService(menuItemsByPath: [:])
|
||||
let conflict = try await service.checkPortConflict(interfaceName: "ether4")
|
||||
XCTAssertNil(conflict)
|
||||
}
|
||||
|
||||
func testCheckPortConflictNeverFlagsTheSharedBridgeItself() async throws {
|
||||
// "bridge" is this app's own shared-LAN interface, not "someone else's" configuration —
|
||||
// even if it happens to already carry addresses (the normal case), it must never be
|
||||
// reported as a conflict.
|
||||
let service = await connectedService(menuItemsByPath: [
|
||||
"/ip address": [RouterOSMenuItem(id: "*1", fields: ["interface": "bridge", "address": "192.168.88.1/24"])]
|
||||
])
|
||||
let conflict = try await service.checkPortConflict(interfaceName: "bridge")
|
||||
XCTAssertNil(conflict)
|
||||
}
|
||||
|
||||
func testCheckPortConflictDetectsBridgeMembership() async throws {
|
||||
let service = await connectedService(menuItemsByPath: [
|
||||
"/interface bridge port": [RouterOSMenuItem(id: "*1", fields: ["interface": "ether4", "bridge": "bridge"])]
|
||||
])
|
||||
let conflict = try await service.checkPortConflict(interfaceName: "ether4")
|
||||
XCTAssertEqual(conflict, PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge")]))
|
||||
}
|
||||
|
||||
func testCheckPortConflictDetectsExistingAddressDhcpClientAndPppoeClientTogether() async throws {
|
||||
let service = await connectedService(menuItemsByPath: [
|
||||
"/ip address": [RouterOSMenuItem(id: "*1", fields: ["interface": "ether1", "address": "10.0.0.1/24"])],
|
||||
"/ip dhcp-client": [RouterOSMenuItem(id: "*2", fields: ["interface": "ether1"])],
|
||||
"/interface pppoe-client": [RouterOSMenuItem(id: "*3", fields: ["interface": "ether1"])]
|
||||
])
|
||||
let conflict = try await service.checkPortConflict(interfaceName: "ether1")
|
||||
XCTAssertEqual(conflict, PortConflict(
|
||||
interfaceName: "ether1",
|
||||
reasons: [.hasAddresses(["10.0.0.1/24"]), .dhcpClient, .pppoeClient]
|
||||
))
|
||||
}
|
||||
|
||||
func testCheckPortConflictIgnoresEntriesForOtherInterfaces() async throws {
|
||||
let service = await connectedService(menuItemsByPath: [
|
||||
"/interface bridge port": [RouterOSMenuItem(id: "*1", fields: ["interface": "ether2", "bridge": "bridge"])],
|
||||
"/ip address": [RouterOSMenuItem(id: "*2", fields: ["interface": "ether2", "address": "192.168.88.1/24"])]
|
||||
])
|
||||
let conflict = try await service.checkPortConflict(interfaceName: "ether5")
|
||||
XCTAssertNil(conflict)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import XCTest
|
||||
@testable import RouterOSAssistant
|
||||
|
||||
final class PortConflictTests: XCTestCase {
|
||||
func testBridgeMembershipProducesNoResolutionCommand() {
|
||||
// DhcpServerCommandBuilder already unconditionally detaches any bridge membership —
|
||||
// repeating it here would just be a harmless duplicate at best, so it's deliberately
|
||||
// excluded from this list.
|
||||
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge")])
|
||||
XCTAssertTrue(conflict.resolutionCommands().isEmpty)
|
||||
}
|
||||
|
||||
func testExistingAddressIsRemovedByInterfaceMatch() {
|
||||
let conflict = PortConflict(interfaceName: "ether4", reasons: [.hasAddresses(["10.0.0.1/24"])])
|
||||
let commands = conflict.resolutionCommands()
|
||||
|
||||
XCTAssertEqual(commands.count, 1)
|
||||
XCTAssertEqual(commands[0].menuPath, "/ip address")
|
||||
XCTAssertEqual(commands[0].operation, .remove(matchField: "interface", matchValue: "ether4"))
|
||||
}
|
||||
|
||||
func testDhcpClientAndPppoeClientAreBothRemoved() {
|
||||
let conflict = PortConflict(interfaceName: "ether1", reasons: [.dhcpClient, .pppoeClient])
|
||||
let commands = conflict.resolutionCommands()
|
||||
|
||||
XCTAssertEqual(commands.count, 2)
|
||||
XCTAssertEqual(commands[0].menuPath, "/ip dhcp-client")
|
||||
XCTAssertEqual(commands[0].operation, .remove(matchField: "interface", matchValue: "ether1"))
|
||||
XCTAssertEqual(commands[1].menuPath, "/interface pppoe-client")
|
||||
XCTAssertEqual(commands[1].operation, .remove(matchField: "interface", matchValue: "ether1"))
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,54 @@ final class RouterOSCliParserTests: XCTestCase {
|
||||
XCTAssertEqual(info.uptime, "1w2d3h4m5s")
|
||||
}
|
||||
|
||||
/// "running"/"disabled" are never present as `key=value` pairs in real `/interface print
|
||||
/// terse` output — confirmed live (2026-09-15, hEX/RouterOS 7.x) against a router with a mix
|
||||
/// of running, bridge-slave, and (synthetically added here) disabled ports:
|
||||
/// 0 R name=ether1 ... (running)
|
||||
/// 2 S name=ether3 ... (not running, bridge slave)
|
||||
/// RouterOS encodes them as single-letter flags in a fixed-width column before the first
|
||||
/// key=value pair instead ("X" = disabled, "R" = running, "S" = slave). An earlier version of
|
||||
/// this parser (and this test) assumed invented `running=`/`disabled=` keys that never
|
||||
/// actually appear — every interface's `running` silently read as `false` as a result.
|
||||
func testParseInterfaces() {
|
||||
let raw = """
|
||||
0 R name="ether1" type="ether" mtu=1500 mac-address="AA:BB:CC:DD:EE:01" running=true disabled=no
|
||||
1 name="ether2" type="ether" mtu=1500 mac-address="AA:BB:CC:DD:EE:02" running=false disabled=yes
|
||||
0 R name=ether1 type=ether mac-address=AA:BB:CC:DD:EE:01
|
||||
1 S name=ether2 type=ether mac-address=AA:BB:CC:DD:EE:02
|
||||
2 X name=ether3 type=ether mac-address=AA:BB:CC:DD:EE:03
|
||||
"""
|
||||
|
||||
let interfaces = RouterOSCliParser.parseInterfaces(raw)
|
||||
|
||||
XCTAssertEqual(interfaces.count, 2)
|
||||
XCTAssertEqual(interfaces.count, 3)
|
||||
XCTAssertEqual(interfaces[0].name, "ether1")
|
||||
XCTAssertTrue(interfaces[0].running)
|
||||
XCTAssertFalse(interfaces[0].disabled)
|
||||
XCTAssertEqual(interfaces[1].name, "ether2")
|
||||
XCTAssertFalse(interfaces[1].running)
|
||||
XCTAssertTrue(interfaces[1].disabled)
|
||||
XCTAssertFalse(interfaces[1].disabled)
|
||||
XCTAssertEqual(interfaces[2].name, "ether3")
|
||||
XCTAssertFalse(interfaces[2].running)
|
||||
XCTAssertTrue(interfaces[2].disabled)
|
||||
}
|
||||
|
||||
/// The exact live output pasted by the user (2026-09-15, hEX) that surfaced the flag-column
|
||||
/// bug — a mix of running/slave/plain ports plus the router's own bridge and loopback.
|
||||
func testParseInterfacesMatchesLiveHexOutput() {
|
||||
let raw = """
|
||||
0 R name=ether1 default-name=ether1 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D5 last-link-up-time=2026-09-15 20:19:19 link-downs=0
|
||||
1 RS name=ether2 default-name=ether2 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D6 last-link-up-time=2026-09-15 20:19:19 link-downs=0
|
||||
2 S name=ether3 default-name=ether3 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D7 link-downs=0
|
||||
3 R name=ether4 default-name=ether4 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D8 last-link-up-time=2026-09-15 20:19:19 link-downs=0
|
||||
4 S name=ether5 default-name=ether5 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D9 link-downs=0
|
||||
5 R comment=defconf name=bridge type=bridge mtu=auto actual-mtu=1500 l2mtu=1596 vrf=main mac-address=F4:1E:57:1B:37:D6 last-link-up-time=2026-09-15 20:19:27 link-downs=0
|
||||
6 R name=lo type=loopback mtu=65536 actual-mtu=65536 vrf=main mac-address=00:00:00:00:00:00 last-link-up-time=2026-09-15 20:19:15 link-downs=0
|
||||
"""
|
||||
|
||||
let interfaces = RouterOSCliParser.parseInterfaces(raw)
|
||||
|
||||
XCTAssertEqual(interfaces.map(\.name), ["ether1", "ether2", "ether3", "ether4", "ether5", "bridge", "lo"])
|
||||
XCTAssertEqual(interfaces.map(\.running), [true, true, false, true, false, true, true])
|
||||
XCTAssertTrue(interfaces.allSatisfy { !$0.disabled })
|
||||
}
|
||||
|
||||
/// Regression test for a real bug found on a physical hEX device: its SSH output didn't
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
@testable import RouterOSAssistant
|
||||
|
||||
/// RouterOS' `/interface monitor-traffic <name> once` reports throughput as human-formatted
|
||||
/// strings with a unit suffix, never a bare integer — confirmed live (2026-09-15, hEX/RouterOS
|
||||
/// 7.x): "50.7kbps", "34.0kbps". The original implementation did `Int(fields[...] ?? "0") ?? 0`,
|
||||
/// which silently parsed every real value to 0 (a numeric string followed by letters isn't a
|
||||
/// valid `Int`), so the traffic indicator never showed as active regardless of real traffic.
|
||||
final class SSHTransportTrafficParsingTests: XCTestCase {
|
||||
func testParsesKbpsWithDecimalPoint() {
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("50.7kbps"), 50_700)
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("34.0kbps"), 34_000)
|
||||
}
|
||||
|
||||
func testParsesMbpsAndGbps() {
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("1.5Mbps"), 1_500_000)
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("2Gbps"), 2_000_000_000)
|
||||
}
|
||||
|
||||
/// "kbps" itself ends with "bps" — the plain "bps" suffix must not be matched first, or
|
||||
/// "50.7kbps" would parse as if it were "50.7" followed by a stray "k".
|
||||
func testDoesNotConfuseKbpsWithPlainBps() {
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("500bps"), 500)
|
||||
}
|
||||
|
||||
func testIdleReportsZero() {
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("0"), 0)
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("0bps"), 0)
|
||||
}
|
||||
|
||||
func testMissingOrUnparsableFallsBackToZero() {
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond(nil), 0)
|
||||
XCTAssertEqual(SSHTransport.parseBitsPerSecond("n/a"), 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import XCTest
|
||||
@testable import RouterOSAssistant
|
||||
|
||||
final class SavedRoutersStoreTests: XCTestCase {
|
||||
/// A dedicated suite name per test avoids bleeding state between tests / real app defaults.
|
||||
private func makeStore() -> SavedRoutersStore {
|
||||
let suiteName = "SavedRoutersStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
return SavedRoutersStore(defaults: defaults)
|
||||
}
|
||||
|
||||
func testFirstSuccessfulConnectionAddsEntryWithDefaultName() {
|
||||
let store = makeStore()
|
||||
let routers = store.recordSuccessfulConnection(host: "192.168.88.1", username: "admin", defaultName: "hEX")
|
||||
|
||||
XCTAssertEqual(routers.count, 1)
|
||||
XCTAssertEqual(routers[0].host, "192.168.88.1")
|
||||
XCTAssertEqual(routers[0].username, "admin")
|
||||
XCTAssertEqual(routers[0].name, "hEX")
|
||||
XCTAssertEqual(routers[0].location, "")
|
||||
}
|
||||
|
||||
func testUpdateLocationRoundTrip() {
|
||||
let store = makeStore()
|
||||
store.recordSuccessfulConnection(host: "192.168.88.1", username: "admin", defaultName: "hEX")
|
||||
let id = store.load()[0].id
|
||||
|
||||
let updated = store.updateLocation(id, to: "Keller, Serverschrank")
|
||||
XCTAssertEqual(updated.first?.location, "Keller, Serverschrank")
|
||||
// Renaming afterwards must not clobber the location, and vice versa.
|
||||
let renamed = store.rename(id, to: "Hauptrouter")
|
||||
XCTAssertEqual(renamed.first?.name, "Hauptrouter")
|
||||
XCTAssertEqual(renamed.first?.location, "Keller, Serverschrank")
|
||||
}
|
||||
|
||||
/// A list saved by an earlier app version (before `location` existed) must keep loading
|
||||
/// instead of the whole list silently vanishing — see `SavedRouter.init(from:)`.
|
||||
func testDecodingAnEntryWithoutALocationFieldDefaultsToEmpty() {
|
||||
let suiteName = "SavedRoutersStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
let legacyJSON = """
|
||||
[{"id":"\(UUID().uuidString)","host":"192.168.88.1","username":"admin","name":"hEX","lastConnectedAt":0}]
|
||||
"""
|
||||
defaults.set(Data(legacyJSON.utf8), forKey: "RouterOSAssistant.SavedRouters")
|
||||
|
||||
let store = SavedRoutersStore(defaults: defaults)
|
||||
let routers = store.load()
|
||||
|
||||
XCTAssertEqual(routers.count, 1)
|
||||
XCTAssertEqual(routers[0].name, "hEX")
|
||||
XCTAssertEqual(routers[0].location, "")
|
||||
}
|
||||
|
||||
func testReconnectingToKnownRouterUpdatesRecencyNotName() {
|
||||
let store = makeStore()
|
||||
store.recordSuccessfulConnection(host: "192.168.88.1", username: "admin", defaultName: "hEX")
|
||||
store.rename(store.load()[0].id, to: "Büro-Router")
|
||||
|
||||
// A later connection reports a possibly-different board name (e.g. after a hardware
|
||||
// swap behind the same IP) — the user's own rename must survive regardless.
|
||||
let routers = store.recordSuccessfulConnection(host: "192.168.88.1", username: "admin", defaultName: "RB750Gr3")
|
||||
|
||||
XCTAssertEqual(routers.count, 1)
|
||||
XCTAssertEqual(routers[0].name, "Büro-Router")
|
||||
}
|
||||
|
||||
func testDifferentUsernameOnSameHostIsATwoEntries() {
|
||||
let store = makeStore()
|
||||
store.recordSuccessfulConnection(host: "192.168.88.1", username: "admin", defaultName: "hEX")
|
||||
let routers = store.recordSuccessfulConnection(host: "192.168.88.1", username: "gast", defaultName: "hEX")
|
||||
|
||||
XCTAssertEqual(routers.count, 2)
|
||||
}
|
||||
|
||||
func testRenameAndRemoveRoundTrip() {
|
||||
let store = makeStore()
|
||||
store.recordSuccessfulConnection(host: "192.168.88.1", username: "admin", defaultName: "hEX")
|
||||
let id = store.load()[0].id
|
||||
|
||||
let renamed = store.rename(id, to: "Wohnzimmer")
|
||||
XCTAssertEqual(renamed.first?.name, "Wohnzimmer")
|
||||
|
||||
let removed = store.remove(id)
|
||||
XCTAssertTrue(removed.isEmpty)
|
||||
XCTAssertTrue(store.load().isEmpty)
|
||||
}
|
||||
|
||||
func testLoadOrdersMostRecentlyConnectedFirst() {
|
||||
let store = makeStore()
|
||||
store.recordSuccessfulConnection(host: "10.0.0.1", username: "admin", defaultName: "Router A")
|
||||
store.recordSuccessfulConnection(host: "10.0.0.2", username: "admin", defaultName: "Router B")
|
||||
// Reconnecting to the first one should move it back to the front.
|
||||
let routers = store.recordSuccessfulConnection(host: "10.0.0.1", username: "admin", defaultName: "Router A")
|
||||
|
||||
XCTAssertEqual(routers.first?.host, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user