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:
Kay
2026-09-15 21:40:46 +02:00
co-authored by Claude Sonnet 5
parent 9b26677dc2
commit 31607b7270
24 changed files with 1400 additions and 61 deletions
+57
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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
+19 -3
View File
@@ -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")
}
}