diff --git a/CHATLOG.md b/CHATLOG.md index 94390bb..20c7d1f 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -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). diff --git a/HANDOFF.md b/HANDOFF.md index 22886b5..9f98f21 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/Manual.md b/Manual.md index 3a8a7db..eddc964 100644 --- a/Manual.md +++ b/Manual.md @@ -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 diff --git a/README.md b/README.md index f4ab715..cc53836 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/RouterOSAssistant/Core/Localization/L10n.swift b/RouterOSAssistant/Core/Localization/L10n.swift index c69e1d8..4795baa 100644 --- a/RouterOSAssistant/Core/Localization/L10n.swift +++ b/RouterOSAssistant/Core/Localization/L10n.swift @@ -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" ] } diff --git a/RouterOSAssistant/Core/Models/InterfaceTraffic.swift b/RouterOSAssistant/Core/Models/InterfaceTraffic.swift new file mode 100644 index 0000000..63bd687 --- /dev/null +++ b/RouterOSAssistant/Core/Models/InterfaceTraffic.swift @@ -0,0 +1,12 @@ +import Foundation + +/// A single live throughput sample for one interface, from RouterOS' `/interface monitor-traffic +/// 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 } +} diff --git a/RouterOSAssistant/Core/Models/PortConflict.swift b/RouterOSAssistant/Core/Models/PortConflict.swift new file mode 100644 index 0000000..65475f8 --- /dev/null +++ b/RouterOSAssistant/Core/Models/PortConflict.swift @@ -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" + )] + } + } + } +} diff --git a/RouterOSAssistant/Core/Models/SavedRouter.swift b/RouterOSAssistant/Core/Models/SavedRouter.swift new file mode 100644 index 0000000..ed8ee9c --- /dev/null +++ b/RouterOSAssistant/Core/Models/SavedRouter.swift @@ -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) + } +} diff --git a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift index b72078c..f480614 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift @@ -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.. 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`. diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index 5923d0a..1a78dda 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -49,6 +49,39 @@ final class SSHTransport: RouterOSTransport { return RouterOSCliParser.parseInterfaces(output) } + /// Live throughput for one interface — RouterOS' `/interface monitor-traffic 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 diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 74b6fad..732dad0 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -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 { diff --git a/RouterOSAssistant/Core/Services/InterfaceTrafficMonitor.swift b/RouterOSAssistant/Core/Services/InterfaceTrafficMonitor.swift new file mode 100644 index 0000000..cb739c4 --- /dev/null +++ b/RouterOSAssistant/Core/Services/InterfaceTrafficMonitor.swift @@ -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 + } +} diff --git a/RouterOSAssistant/Core/Services/SavedRoutersStore.swift b/RouterOSAssistant/Core/Services/SavedRoutersStore.swift new file mode 100644 index 0000000..bcc6f23 --- /dev/null +++ b/RouterOSAssistant/Core/Services/SavedRoutersStore.swift @@ -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) + } +} diff --git a/RouterOSAssistant/Features/Overview/OverviewView.swift b/RouterOSAssistant/Features/Overview/OverviewView.swift index c7b93cb..408b450 100644 --- a/RouterOSAssistant/Features/Overview/OverviewView.swift +++ b/RouterOSAssistant/Features/Overview/OverviewView.swift @@ -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 diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift index 0dac8d4..5c4f11b 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift @@ -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()) } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift index 86a5c72..107f0a9 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift @@ -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? 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) diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift index 96c57fa..c9b840e 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift @@ -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") + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift index 8f5d49e..dafdb1d 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift @@ -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) } } } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index 2271ea6..1ba2ebb 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -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 = [] + /// 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 = [] + @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 diff --git a/RouterOSAssistantTests/ConnectionServiceTests.swift b/RouterOSAssistantTests/ConnectionServiceTests.swift index 603dbc3..b339954 100644 --- a/RouterOSAssistantTests/ConnectionServiceTests.swift +++ b/RouterOSAssistantTests/ConnectionServiceTests.swift @@ -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 { [] } 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) + } } diff --git a/RouterOSAssistantTests/PortConflictTests.swift b/RouterOSAssistantTests/PortConflictTests.swift new file mode 100644 index 0000000..9bb50e2 --- /dev/null +++ b/RouterOSAssistantTests/PortConflictTests.swift @@ -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")) + } +} diff --git a/RouterOSAssistantTests/RouterOSCliParserTests.swift b/RouterOSAssistantTests/RouterOSCliParserTests.swift index 1060607..8f68897 100644 --- a/RouterOSAssistantTests/RouterOSCliParserTests.swift +++ b/RouterOSAssistantTests/RouterOSCliParserTests.swift @@ -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 diff --git a/RouterOSAssistantTests/SSHTransportTrafficParsingTests.swift b/RouterOSAssistantTests/SSHTransportTrafficParsingTests.swift new file mode 100644 index 0000000..d3a432a --- /dev/null +++ b/RouterOSAssistantTests/SSHTransportTrafficParsingTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import RouterOSAssistant + +/// RouterOS' `/interface monitor-traffic 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) + } +} diff --git a/RouterOSAssistantTests/SavedRoutersStoreTests.swift b/RouterOSAssistantTests/SavedRoutersStoreTests.swift new file mode 100644 index 0000000..4f98b18 --- /dev/null +++ b/RouterOSAssistantTests/SavedRoutersStoreTests.swift @@ -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") + } +}