Compare commits

..
Author SHA1 Message Date
adminandClaude Sonnet 5 e20e6c1574 Roadmap: Ablaufplan zur Priorisierung der Mac-Paritaets-Milestones
Kombiniert bestehende v1.x-Milestones und die neun M-*-Milestones aus
dem Mac-Feature-Vergleich in eine begruendete Reihenfolge (Abhaengigkeiten
wie M-SSH als Voraussetzung fuer M-Netzwerk-Tools/M-Live-Traffic beruecksichtigt),
plus einen wiederkehrenden Abgleichs-Rhythmus zwischen beiden unabhaengigen
Implementierungen statt eines einmaligen Abgleichs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:36:06 +02:00
adminandClaude Sonnet 5 62dda8a13d Roadmap: neun Milestones aus Mac-Feature-Vergleich ergaenzt
Feature-Vergleich mit der parallel entwickelten macOS/SwiftUI-Version
(unabhaengiges Repository, keine gemeinsame Codebasis) durchgefuehrt und
die Luecken als neue Gitea-Milestones (M-SSH, M-Experte, M-Geraete,
M-Netzwerk-Tools, M-Uebersicht-Edit, M-Bekannte-Router, M-Live-Traffic,
M-Backup-Wartung, M-i18n) samt Startissues angelegt. Dabei zusaetzlich
einen eigenstaendigen Korrektheits-Bug gefunden (Firewall-/NAT-Regeln
duplizieren sich bei wiederholtem Wizard-Lauf, Issue #19).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:24:51 +02:00
163 changed files with 750 additions and 17396 deletions

No files matched your search

@@ -1,113 +0,0 @@
# Session Handoff: LAN-Scanner, Backup-Verwaltung, Sidebar-Umbau
**Datum:** 2026-09-17
**Projekt:** R:\CC-Projekte\Mikrotik-Installer (Gitea: `admin/Mikrotik-Installer`)
**Sitzungsdauer:** ganzer Tag, sehr umfangreich (ein komplettes neues Feature + eine größere Umstrukturierung + mehrere iterative UX-Korrekturen)
## Aktueller Stand
**Aufgabe:** Phase 2 der Roadmap (Mac-Paritätsfeatures ohne SSH-Abhängigkeit) begonnen, während v1.0 (Realgerät-Validierung) weiter auf Testhardware wartet.
**Phase:** Mehrere Features komplett fertig, vom Nutzer live getestet und bestätigt. Sauberer Abschlusspunkt für heute.
**Fortschritt:** Alles committet und nach Gitea gepusht (`origin/main`, aktueller HEAD `12ce7be`). Core-Testsuite: 138 Tests grün. App-Build sauber.
⚠️ **Wichtig zuerst prüfen:** Laut Memory (`project_v1_realgeraet_wartet_auf_hardware`) wurde die Testhardware für **heute (2026-09-17)** erwartet. In dieser Session kam das Thema nicht auf — zu Beginn der nächsten Session klären, ob die Hardware inzwischen angekommen ist. Falls ja, hat `v1.0 – Realgerät-Validierung` (Issues #1, #2) oberste Priorität vor weiteren Phase-2-Features (siehe Ablaufplan in README).
## Was wir gemacht haben
### 1. M-Geräte – LAN-Scanner (Issue #22, abgeschlossen)
Neuer Wizard-Schritt „Geräte im Netzwerk": führt DHCP-Leases, ARP-Tabelle und Bridge-Host-Tabelle per MAC-Adresse zu einer nach Anschluss gruppierten Geräteliste zusammen (physischer Anschluss kommt bewusst aus der Bridge-Host-Tabelle, nicht aus ARP/Lease). Aktion „Feste IP-Adresse zuweisen" (RouterOS „Make Static") inkl. Rückgängig-Pfad. Nutzer-Feedback dabei behoben: Nach „Rückgängig machen" wirkte die Zeile wie kaputt (Button ausgegraut ohne Erklärung) — jetzt zeigt sie „Feste IP entfernt" mit Hinweistext, warum das Gerät sich erst nach Trennen/Neuverbinden wieder meldet. Folge-Issue #30 (offen): `make-static`-Aufrufparameter (`.id` vs. `numbers`) gegen echtes Gerät verifizieren.
### 2. M-Backup-Wartung – Backup-Wiederherstellung (Issue #27, mehrere Iterationen, abgeschlossen)
Größtes Stück der Session, mehrfach auf Nutzer-Feedback erweitert:
- **v1:** Backup-Wiederherstellung als dritte Option im Moduswahl-Schritt, Modell-Abgleich (Dateiname trägt Gerätemodell) als Brick-Schutz.
- **v2:** Seriennummer zusätzlich zum Modell eingebettet und hat Vorrang beim Prüfen — erkennt ein baugleiches, aber physisch anderes Gerät (gleiches Modell, andere Seriennummer), das ein reiner Modell-Abgleich fälschlich durchließe.
- **v3 (#33):** Architektur als dritte Prüfstufe, vierstufige Kompatibilität: **Match** / **SameModelDifferentDevice** (gelb, „auf eigene Gefahr" mit Bestätigungs-Checkbox) / **Mismatch** (rot, hart blockiert, kein Override) / **Unknown** (Bestätigungs-Checkbox).
- **v4 (#34):** Kompletter Bereich aus dem Interview gelöst — neuer Schritt „Was möchtest du tun?" direkt nach der Verbindung (vor Geräte-Erkennung!), bei „Backup verwalten" werden alle Konfigurations-/Analyse-Schritte übersprungen, eigenständiger `BackupManagementStepViewModel` liest das Gerät selbst aus. Alte „Backup wiederherstellen"-Option in der Moduswahl entfernt (kein Parallel-Mechanismus).
- **Manuelles Backup erstellen:** eigener „Neues Backup erstellen"-Button unabhängig vom Wiederherstellen.
- Folge-Issue #32 (offen): `/system/backup/load`-Verhalten (Dateiname-Format, Reboot, Passwort-Parameter) gegen echtes Gerät verifizieren.
### 3. Sidebar-Umbau (Issues #35, #36, abgeschlossen)
- **#35 (Bug):** Sidebar-Schrittliste hatte keinen ScrollViewer — bei jetzt 13 Schritten wurde „RouterOS-Update" (letzter Schritt) abgeschnitten. Fix: Logo bleibt fix, Schrittliste läuft in eigenem ScrollViewer.
- **#36 (Nutzer-Wunsch „Optik-Renew", eigener Entwurf nach Rückfrage):** Sidebar zeigt jetzt dauerhaft eine Verbindungs-Karte. Nicht verbunden: Kurzauswahl bekannter Router, ein Klick verbindet direkt und springt automatisch weiter. Verbunden: grüner Punkt + Gerätename/-modell + „Wechseln"-Button.
- **Folge-Bereinigung:** Nutzer wollte keine Dopplung — „Bekannte Router" komplett aus dem Verbindungs-Schritt (Hauptfenster) entfernt, lebt nur noch in der Sidebar. Dort per Rückfrage ergänzt: kleines Papierkorb-Symbol zum Entfernen je Eintrag; Umbenennen bewusst nicht wiederhergestellt (Name kommt automatisch vom Gerätemodell).
### 4. Demo-Zugang: drei virtuelle Testgeräte statt einem
Auf Wunsch des Nutzers („damit ich das besser beurteilen kann"): `DemoRouterOsClient` unterstützt jetzt mehrere `DemoDeviceProfile`s (alle `admin`/`admin`):
- `127.127.127.127` — Test-Router / CHR (Simulator) — bestehend
- `127.127.127.128` — Büro-Router / hAP ac2 (RouterBOARD)
- `127.127.127.129` — Keller-Switch / CRS326-24G-2S+ (RouterBOARD)
Alle drei wurden bereits einmal real verbunden und stehen unter „Bekannte Router" (lokale Datei `%AppData%\MikroTik-Installer\saved-routers.json`, nicht Teil des Git-Repos).
### 5. Sidequest: Gitea-Milestone-Fälligkeitsdaten
Alle 12 offenen Milestones auf Fälligkeitsdatum 2026-09-30 gesetzt (Nutzerwunsch für bessere eigene Struktur).
## Entscheidungen (mit Begründung)
- **Seriennummer > Architektur > Modell als Prüf-Hierarchie beim Backup-Abgleich** — die Seriennummer identifiziert das konkrete physische Gerät und ist damit die stärkste Prüfung; Architektur/Modell sind Fallbacks für ältere Backups oder Geräte ohne Seriennummer (z. B. virtuelle Testgeräte). Alle drei werden per `_` getrennt in den Dateinamen eingebettet (`_` erzeugt `SanitizeForFileName` nie, Trennung bleibt eindeutig).
- **`AppArea`-Enum + `ShouldSkip` statt neuer Navigations-Architektur** — für den Backup-Bereich wurde bewusst der bereits bestehende `ShouldSkip`-Mechanismus wiederverwendet (Schritt „Was möchtest du tun?" setzt `WizardSession.SelectedArea`, alle Konfigurations-Schritte prüfen das), statt eine komplett neue, parallele Navigations-Ebene zu bauen. Deckt sich bewusst mit dem für **M-Experte (Issue #21)** ohnehin vorgesehenen Bedarf an einer Navigation außerhalb der linearen Schritt-Kette — beide sollten bei einem künftigen größeren Umbau gemeinsam betrachtet werden, nicht als zwei parallele Mechanismen.
- **`BackupManagementStepViewModel` liest das Gerät selbst aus** — unabhängig von `DeviceOverviewStepViewModel` (der in diesem Bereich übersprungen wird), damit „Backup verwalten" wirklich direkt nach der Verbindung funktioniert, ohne Umweg.
- **Manuelle Formular-Eingabe bleibt auf der Verbindungs-Schritt-Seite** — passt nicht kompakt in die schmale Sidebar (260px); nur die Kurzauswahl bekannter Router zog um.
- **found.md als Backlog-Kanal etabliert** — der Nutzer schreibt Feedback/Ideen in eine lokale `found.md` im Projekt-Root (nicht Teil des Git-Repos, bewusst nicht committet) und bittet mich, sie in Gitea-Issues umzuwandeln und den Status direkt in der Datei nachzuführen. Funktioniert jetzt für 5 Punkte (siehe unten). **Bei Sessionstart immer prüfen, ob `found.md` neue, noch unbearbeitete Einträge enthält.**
- **Scratch-Test-Harness für WPF-Smoke-Tests** — da es keine GUI-Automatisierung für native WPF-Fenster gibt, wurde wiederholt ein kleines Konsolenprogramm im Scratchpad (`wpf-smoke/Program.cs`) genutzt: `[STAThread]` + `new App(); app.InitializeComponent();` + ViewModels/Views direkt instanziieren und durch `MainViewModel.GoNextCommand` navigieren. Funktioniert gut für ViewModel-Logik; **volle `MainWindow`-Instanzen lassen sich darin NICHT rendern** (Pack-URI-Ressourcenauflösung für `Assets/app.ico` schlägt in der isolierten Harness fehl) — für rein visuelle/Layout-Änderungen bleibt nur der Start der echten App zum manuellen Prüfen.
- **Scratch-Harness nutzt echte lokale Nutzerdaten** — Vorsicht: Der Harness läuft im selben Prozess-Kontext und schreibt bei vollständiger Navigation durch `DeviceOverviewStepViewModel` in die *echte* `%AppData%\MikroTik-Installer\saved-routers.json` und den echten Windows-Anmeldeinformationsspeicher (nicht sandboxed). Bisher unproblematisch (nur Demo-Zugangsdaten betroffen, bestehende Nutzer-Anpassungen wie `Location: "Test dingens"` blieben unangetastet), aber beim nächsten Mal bewusst im Hinterkopf behalten.
## Code-Struktur (wichtigste Ergänzungen/Änderungen heute)
```
src/MikrotikInstaller.Core/
Devices/LanScan/ NEU — DiscoveredDevice, LanScanService
Backup/BackupFileInfo.cs Model/Architecture/SerialNumber (war nur FileName/CreationTime)
Backup/BackupRestoreService.cs NEU — CheckCompatibility (4-stufig), RestoreAsync, ListBackupsAsync
Backup/RouterBackupService.cs CreateBackupAsync bettet Modell/Architektur/Seriennummer ein
Connectivity/Demo/DemoDeviceProfile.cs NEU — Profile-Record für virtuelle Testgeräte
Connectivity/Demo/DemoRouterOsClient.cs Mehrere Profile statt einem einzigen Testzugang
src/MikrotikInstaller.App/
ViewModels/AppArea.cs NEU — SetupWizard | BackupManagement
ViewModels/Steps/AreaSelectionStepViewModel.cs NEU — "Was möchtest du tun?" (Schritt 2)
ViewModels/Steps/BackupManagementStepViewModel.cs NEU — eigenständiger Backup-Bereich
ViewModels/Steps/BackupSelectionItemViewModel.cs 4-stufige Kompatibilitäts-Anzeige
ViewModels/Steps/LanScan*.cs NEU — LAN-Scanner-Step + Row/Group-ViewModels
ViewModels/Steps/ModeSelectionStepViewModel.cs Zurück auf 2 Optionen (Restore-Option entfernt)
ViewModels/Steps/SummaryStepViewModel.cs Restore-Zweig entfernt, wieder rein für geplante Änderungen
ViewModels/Steps/ConnectionStepViewModel.cs "Bekannte Router"-Editieren entfernt, ConnectToSavedRouterCommand NEU
ViewModels/MainViewModel.cs Connection-Property, ConnectedDeviceLabel, QuickConnect-/SwitchConnection-Commands
MainWindow.xaml Sidebar: ScrollViewer + dauerhafte Verbindungs-Karte
Views/Steps/LanScanStepView.xaml NEU
Views/Steps/BackupManagementStepView.xaml NEU
Views/Steps/AreaSelectionStepView.xaml NEU
Views/Steps/ConnectionStepView.xaml "Bekannte Router"-Liste entfernt
```
## Bekannte Stolperfallen (bereits gelöst, aber gut zu wissen)
- **`Run.Opacity` existiert nicht in WPF** — mehrfach reingelaufen, immer mit `Foreground="{DynamicResource TextFillColorSecondaryBrush}"` statt `Opacity` auf `<Run>`-Elementen lösen.
- **CommunityToolkit `[RelayCommand]`-CanExecute bei parametrisierten Item-Commands** — reagiert nicht automatisch auf Property-Änderungen am gebundenen Item (kein `NotifyCanExecuteChangedFor` möglich, da das Item nicht das ViewModel selbst ist). Lösung: `IsEnabled` in XAML direkt an eine berechnete Property auf dem Item binden (z. B. `CanToggleStatic`), statt sich auf die Command-eigene `CanExecute`-Logik zu verlassen.
- **`Border` erlaubt nur ein Kind** — beim Verschachteln von zwei `Visibility`-umschaltbaren `StackPanel`s in einer `Border` erst einen `Grid`-Wrapper einziehen.
- **Namespace-Verschachtelung in C#** — `MikrotikInstaller.App.ViewModels.Steps` sieht Typen aus `MikrotikInstaller.App.ViewModels` automatisch (kein `using` nötig), da es sich um eine echte Namespace-Verschachtelung handelt, nicht nur eine Namenskonvention.
- **`xUnit TheoryData<T>` hat keinen `IEnumerable<T>`-Konstruktor** in der hier verwendeten Version — Einträge einzeln per `.Add()` in einer kleinen Hilfsmethode aufbauen statt `new TheoryData<int>(Enumerable.Range(...))`.
## Kontext zum Merken
- **Feste Arbeitsweise für dieses Projekt** (vom Nutzer explizit angewiesen): nach jedem grünen Testlauf automatisch committen + nach **Gitea** pushen (nie GitHub), danach README + Dokumentation aktualisieren. Weiterhin gültig, siehe Memory `feedback_commit_push_docs_workflow`.
- **found.md-Workflow** (neu diese Session, siehe oben) — Notizen dort mit Zeile 1 „Alles hier reingeschrieben, bitte umwandeln für die KI, sowie mit Status versehen" sind eine Aufforderung, sie in Gitea-Issues umzuwandeln und den Status in der Datei selbst nachzuführen.
- **Nutzer testet aktiv selbst live** und gibt präzises, umsetzbares Feedback — bei jeder UI-Änderung die echte App neu starten (`Get-Process ... | Stop-Process -Force` davor, sonst `MSB3027`-Build-Fehler) und den Nutzer aktiv zum Testen einladen.
- **Rückfragen bei echten Design-Weichenstellungen zahlen sich aus** — sowohl bei „Backup-Bereich eigenständig machen" (#34) als auch beim Sidebar-Umfang (#36) und bei der Bekannte-Router-Bereinigung (heute zuletzt) wurde vor der Umsetzung kurz per `AskUserQuestion` nachgefragt, statt zu raten — hat in allen Fällen zur richtigen Lösung im ersten Versuch geführt.
## Offene Fragen / Nächste Schritte
1. [ ] **Zuerst prüfen: ist die Testhardware inzwischen angekommen?** Falls ja, hat `v1.0 – Realgerät-Validierung` (Issues #1, #2) Vorrang vor weiteren Phase-2-Features.
2. [ ] Rest von Phase 2: `M-Backup-Wartung`-Rest (Werksreset-Notfallschalter + Firmware-Update, Issue #28) oder `v1.3` SFTP-Backup-Download (Issues #8, #9) — beide noch komplett offen.
3. [ ] Issue #31 (aus found.md, noch offen): Verbindungs-Schritt — Benutzername/Passwort nebeneinander statt untereinander, damit man im Standardfenster nicht scrollen muss.
4. [ ] Drei Folge-Issues für Echtgerät-Verifizierung, alle bisher nur gegen den Demo-Client getestet: #30 (`make-static`-Parameter), #32 (`/system/backup/load`-Verhalten), #17 (Route-zu-Interface-Zuordnung, aus vorheriger Session).
5. [ ] `M-Übersicht-Edit` (Issue #24) — Nodify-Diagramm bearbeitbar machen, baut auf bereits verifizierter Nodify-Integration auf.
6. [ ] Größerer Umbau irgendwann fällig: `M-Experte` (Issue #21, neue Wizard-unabhängige Navigation) und die in #34 hinterlegte Notiz, dass dessen Navigations-Bedarf sich mit dem heute gebauten `AppArea`-Mechanismus überschneidet — beim Angehen von #21 unbedingt zusammen betrachten.
## Dateien zum Wiedereinstieg
- `README.md` — vollständig aktuell (Schrittliste inkl. „Was möchtest du tun?"/„Backup verwalten", Roadmap-Tabelle, Ablaufplan)
- `found.md` (Projekt-Root, nicht im Git) — Backlog-Status, bei Sessionstart auf neue Einträge prüfen
- `src/MikrotikInstaller.App/ViewModels/Steps/BackupManagementStepViewModel.cs` + `src/MikrotikInstaller.Core/Backup/BackupRestoreService.cs` — Kern der heutigen Backup-Arbeit
- `src/MikrotikInstaller.App/MainWindow.xaml` — Sidebar-Struktur (Verbindungs-Karte + scrollbare Schrittliste)
- `.claude/handoffs/2026-09-15-experten-modus-und-ist-zustand-analyse.md` — vorheriger Handoff, Kontext zu Experten-Modus/IST-Zustand-Analyse weiterhin gültig
@@ -1,95 +0,0 @@
# Session Handoff: Mac-Abgleich v1.11.4 komplett nachgezogen (P1–P42)
**Datum:** 2026-10-16
**Projekt:** R:\CC-Projekte\Mikrotik-Installer (Gitea: `admin/Mikrotik-Installer`)
**Sitzungsdauer:** ganzer Tag, Full-Auto-Modus (Nutzer war arbeiten)
## Aktueller Stand
**Aufgabe:** Windows-Version auf den Stand der Mac-Version von Kay bringen (`kay/RouterOS` auf `git.focus72.de`, v1.11.4), ohne alles rund um App Store/Verkaufsversion.
**Phase:** Umsetzung abgeschlossen. Alle Pakete P1–P42 sind fertig, committet und nach Gitea gepusht (HEAD `b303f25`).
**Tests:** 209 Core-Tests grün. Smoke-Test-Harness (siehe unten) DE grün, EN nur mit den erwarteten Fehlern bei deutschen Textvergleichen.
**Wichtig:** Alles Neue ist nur gegen die Testumgebung (Demo-Client) und Unit-Tests geprüft. Die Echtgerät-Checkliste steht in **Issue #71**.
## Was wir gemacht haben
- Plan `docs/mac-paritaet-plan.md` mit den Paketen P1–P42, Milestone **M-Mac-Abgleich-v1.11**, Issues #37–#70. Alle sind geschlossen, ebenso #8, #9, #21, #23, #24, #28, #29 und #31.
- **Sicherheit/Robustheit:**
- `NetworkIsolation`: Input-Sperre für die Router-Verwaltung aus isolierten Netzen, Connection-Flush, Kommentare an den Regeln.
- `RouterOsTimeouts` je Zugriffsart.
- Binäre API läuft serialisiert und liest nach `!trap` das folgende `!done` mit. Das war ein echter Fehler.
- „Passwort merken“.
- Zertifikats-TOFU über `CertificateTrust` und `known-hosts.json`. Vorher wurde jedes Zertifikat akzeptiert.
- **Backup & Wartung:**
- Neustart, RouterBOARD-Firmware, Werksreset (`RouterMaintenanceService`).
- Automatisches Wiederverbinden (`RouterOsReconnector`, `WizardSession.ReconnectAsync`) und Herzschlag alle 30 s.
- Backup-Passwort mit RouterOS-eigener Verschlüsselung und `-enc` im Dateinamen.
- Handsicherung ersetzt 10 Minuten lang die automatische; Gruppierung nach Tag.
- SFTP-Download mit SSH.NET 2024.2.0 und Hostkey-TOFU.
- **Einstellungen/Start:** Einstellungen-Fenster (`AppSettings`/`UserSettings`), Einführung, Testumgebung auch im Release, Start maximiert, Startbildschirm.
- **Verbinden/Geräte:** MNDP-Router-Suche, Link-Status-Punkt, Netzwerk-Werkzeuge (Ping/Traceroute/DNS über `IRouterOsClient.RunAsync`, Port-Scan vom PC), DNS-Namen im LAN-Scanner, Easter Egg.
- **Assistent:**
- Lease-Zeit als Jahre/Tage/Stunden (`LeaseTime`).
- Vorhandenes LAN vorladen (`ExistingLanReader`).
- `LanConfigurator` per `RouterOsUpsert`; Fix: `domain` statt `domain-name`.
- Port-Konflikte („Port freimachen?“), Fortschrittsbalken mit Abschluss-Übersicht.
- **Experte-Bereich:**
- Dritte `AppArea.Expert`, nur mit „Professioneller Netzwerker“.
- 50 Menüs mit 219 Feldhilfen aus Kays Katalog, per Skript übertragen (`Expert/menu-catalog.json`).
- Gemeinsamer Editor `ExpertEditorViewModel` mit Befehlsvorschau.
- Werkzeuge: Isolieren, Neues LAN, Port in Bridge, Pakete (`PackageService`).
- „Bearbeiten“ in der Übersicht.
- **Hilfe:** 49 Begriffe (`Help/glossary.json`, DE/EN/PT), Handbuch `docs/Handbuch.md` und `docs/Handbook.en.md`, Hilfe-Fenster.
- **Sprachen DE/EN/PT:**
- `L.T`/`L.F` in `Core/Localization/L.cs`, `{l:T '…'}` über `App/Localization/TExtension.cs`.
- `translations.json` mit 1007 Schlüsseln: Katalog aus Kays L10n übernommen, den Rest neu übersetzt.
- Sprachwahl in Einstellungen und Einführung, wirksam nach Neustart.
- **Design:** Port-Farben und Zebra-Liste im LAN-Scanner.
## Entscheidungen (mit Begründung)
- **Keine SSH-Befehlsschicht.** Die binäre API deckt alte Geräte schon ab, SSH.NET dient nur für SFTP. #20 ist umgewidmet (kommentiert) und noch offen, du entscheidest.
- **Live-Traffic verworfen.** Die Mac-Version hat ihn wegen Router-Last wieder entfernt. #26 ist zur Schließung vorgeschlagen, aber noch offen, du entscheidest.
- **Binäres Backup statt Text-Export.** Kays Restore-Fallen (Abbruch beim ersten Fehler, Klartext-Login im Skript, fehlender WLAN-Schlüssel) betreffen uns nicht.
- **Backup-Verschlüsselung durch RouterOS** (`password=`, `encryption=aes-sha256`) statt eigener Kryptografie. Sie schützt auch die Datei auf dem Gerät.
- **Testumgebung im Release.** Die Adressen 127.127.127.x sind Loopback, also nie ein echter Router. Je Host gibt es eine Demo-Instanz für die Laufzeit der App.
- **Sprachwechsel erst nach Neustart.** Die XAML-Markup-Erweiterung wird beim Laden aufgelöst. Das ist einfacher und robuster als ein Live-Wechsel.
- **RouterOS-Daten nie übersetzen.** Isolations-Kommentare dienen dem Wiedererkennen über `StartsWith("Isolation")`, und Statusvergleiche müssen auf den Originaltexten laufen.
## Bekannte Stolperfallen (gelöst, aber gut zu wissen)
- **`SystemThemeWatcher.UnWatch`** wirft im `MainWindow`-Konstruktor („not yet loaded“). Danach startete die App nur mit einer Fehlermeldung, gefunden erst beim echten Start. Ein modaler Dialog gehört in `ContentRendered`, nicht in `Loaded`.
- **`.en.md` als EmbeddedResource** landet in einer Satelliten-Assembly. Abhilfe: `WithCulture="false"`.
- **Port-Scan unter Windows:** Ein abgelehnter Verbindungsversuch dauert rund 2 s, weil das SYN wiederholt wird. Das Zeitlimit liegt deshalb bei 3 s.
- **Bash-Heredocs** mit Python-Code scheitern hier sporadisch („unexpected EOF“). Edit-Skripte deshalb mit dem Write-Tool ins Scratchpad legen und dann ausführen.
- **Demo-Daten** brauchten `.id` je Zeile und `/interface/bridge` wie ein echter Router. Sonst wurde aus „ändern“ ein „anlegen“, und `.Last()` stürzte ab.
## Werkzeuge der Session (Scratchpad, nicht im Repo)
- **`wpf-smoke/`:** Konsolen-Harness. Er lädt alle Views inklusive `MainWindow`; `ResourceAssembly` wird per Reflection gesetzt, `ShutdownMode` ist `OnExplicitShutdown`. Er spielt Szenarien gegen die Testumgebung durch und rendert Views als PNG (`RenderTargetBitmap`, Vordergrundfarbe aus dem Theme). Parameter `en` startet ihn auf Englisch.
- **`convert_catalog.py` / `convert_glossary.py` / `extract_kay_l10n.py`:** übertragen Kays Swift-Daten nach JSON.
- **`i18n_transform.py`, `i18n_words.py`, `collect_keys.py`:** Umbau auf Übersetzungen. `collect_keys.py` erzeugt `translations.json` und zeigt fehlende Schlüssel.
- **Echte App:** starten und Fenster per UI Automation prüfen (Prozess-`MainWindowHandle` bzw. `AutomationElement`).
## Offene Fragen / nächste Schritte
1. [ ] **Testhardware da?** Dann zuerst Issue #71 (Echtgerät-Checkliste) zusammen mit #1, #2, #17, #30 und #32 abarbeiten.
2. [ ] Entscheiden: #26 (Live-Traffic) schließen? #20 (SSH-Transport) schließen, nachdem der SFTP-Teil erledigt ist?
3. [ ] Englisch und Portugiesisch einmal von Hand durchklicken und verbliebene deutsche Texte nachtragen. Ablauf: `collect_keys.py` ausführen, Übersetzung ergänzen.
4. [ ] Release-Housekeeping v1.1 (#3, #4, #5), danach Tag/Release.
5. [ ] #18: optisches Feintuning der Übersicht.
6. [ ] Mit Kay abgleichen: Seine Datei `Windows-Version.md` ist für Funde aus der Windows-Version gedacht. Unsere Funde wären etwa der Trap/Done-Fehler der binären API und die fehlenden Zertifikatsprüfungen. Nur auf Wunsch eintragen, es ist sein Repo.
## Kontext zum Merken
- **Arbeitsweise:** nach grünen Tests committen und nach Gitea pushen (nie GitHub), dann README und Dokumentation nachziehen.
- **Neue sichtbare Texte** brauchen `L.T`/`{l:T}` und einen Eintrag in `translations.json`.
- **Echte Nutzerdaten:** Der Harness schreibt in die echten AppData-Dateien. In dieser Session ist keine Einstellung geändert worden; `IntroCompleted` ist noch false, die Einführung erscheint beim nächsten Start.
## Dateien zum Wiedereinstieg
- `docs/mac-paritaet-plan.md`: Plan mit Issue-Zuordnung
- `README.md`: vollständige Beschreibung aller Bereiche
- `src/MikrotikInstaller.App/ViewModels/WizardSession.cs`: Wiederverbinden, Sicherung vor Änderungen, Zustand
- `src/MikrotikInstaller.App/ViewModels/Steps/ExpertTools.cs`, `ExpertEditorViewModel.cs`: Experte-Bereich
- `src/MikrotikInstaller.Core/Localization/L.cs` und `translations.json`: Sprachen
@@ -1,72 +0,0 @@
# Session Handoff: UI-Rework Lösung A (Bereichs-Navigation)
**Datum:** 2026-10-18 **Projekt:** `R:\CC-Projekte\Mikrotik-Installer` **Commit:** `f2dd7e7` (gepusht nach Gitea `origin`, schließt #72)
## Aktueller Stand
**Aufgabe:** Das Interview (lineare Schrittkette mit Bereichswahl) wurde durch eine Seitenleiste mit frei anklickbaren Bereichen ersetzt.
**Phase:** Fertig umgesetzt und gegen die Testumgebung geprüft, noch nicht am echten Router.
**Fortschritt:** 100 % Code und Doku. Die Release-Exe ist noch **nicht** neu gebaut.
## Was gemacht wurde
Der Nutzer hat die HTML-Vorschau (Artifact https://claude.ai/artifact/MFPPizmpZjn4LqBsLpdkz7) freigegeben („bau es als lösung a“). Daraufhin wurden `MainViewModel` und `MainWindow` neu geschrieben, die Einrichtungsschritte in einen eigenen Assistenten (`SetupWizardViewModel`) verschoben und Hilfe/Einstellungen von Fenstern zu Seiten umgebaut. README, Handbuch DE/EN und die Übersetzungen (EN/PT) sind nachgezogen.
## Entscheidungen
- **Bereiche statt Interview:** Start, Einrichten, Übersicht, Geräte, Backup & Wartung, Experte. Hilfe und Einstellungen stehen unten. Experte ist nur mit „Professioneller Netzwerker“ (`UserSettings.ProfessionalMode`) sichtbar.
- **Ohne Verbindung** zeigt `CurrentPage` die Verbinden-Seite. Die Bereiche sind gesperrt, Hilfe und Einstellungen gehen immer.
- **Pages werden bei jeder Verbindung neu erzeugt** (privater record `Pages` in `MainViewModel`), und `WizardSession.ResetPlan()` setzt den Plan zurück. So wird nichts vom vorherigen Gerät angewendet.
- **Zusammenfassung enthält nur besuchte, gültige Schritte**, weil Schritte jetzt übersprungen werden können.
- **Erneuter Klick auf einen Bereich lädt ihn neu** (`NavigateCommand`).
- **RouterOS-Update** steht jetzt auf der Startseite (`StartViewModel` = Gerät + Update).
- **Einrichten scrollt selbst** (`PageScrolls = false`), weil Zurück/Weiter fest unten stehen.
## Code-Änderungen (Commit `f2dd7e7`)
- `src/MikrotikInstaller.App/ViewModels/AppArea.cs`: enum `Start, Setup, Overview, Devices, Backup, Expert, Help, Settings`
- `ViewModels/MainViewModel.cs`: neu. `Connection`, `Area`, `CurrentPage`, `PageTitle`, `PageScrolls`, `ShowExpert`, `NavigateCommand`, `SwitchConnectionCommand`, `QuickConnectCommand`, 30-s-Herzschlag. GoNext/GoBack/Finish/CloseRequested entfernt.
- `ViewModels/SetupWizardViewModel.cs`: neu. Schritte Mode → Wan → Lan → Vlan → Wlan → Firewall → Summary, `StepNavItems` (Haken = besucht), `GoToStepCommand(int)`.
- `ViewModels/StartViewModel.cs`: neu.
- `WizardSession.cs`: `SelectedArea` entfernt, `ResetPlan()` neu.
- Step-ViewModels: `SelectedArea`-Bedingungen aus `ShouldSkip` entfernt. Mode wird ohne ProfessionalMode übersprungen, WLAN ohne WLAN-Hardware.
- `AreaSelectionStepViewModel` und seine View gelöscht.
- `SettingsViewModel`: `Saved`-Event ersetzt durch `StatusMessage` („Gespeichert.“).
- Views: `HelpView.xaml` und `SettingsView.xaml` (vorher Fenster), dazu neu `StartView.xaml` und `SetupWizardView.xaml`. DataTemplates stehen in `App.xaml`.
- `MainWindow.xaml`: `NavItem`-RadioButton-Stil mit `EnumToBooleanConverter`. **Fix:** Der Papierkorb bei bekannten Routern bindet jetzt an `DataContext.Connection.RemoveSavedRouterCommand` (vorher totes Binding).
- Übersetzungen: 12 neue Schlüssel, insgesamt 1007, alle in EN/PT übersetzt. Die alten Schlüssel der Bereichswahl sind entfernt.
- Doku: README (Abschnitt „Bedienung: Bereiche statt Interview“), `docs/Handbuch.md` und `docs/Handbook.en.md` (Kapitel 2 „Bereiche“/„Areas“).
## Tests
- Build: 0 Fehler, 0 Warnungen. 209 Core-Tests grün.
- WPF-Smoke-Harness: auf Deutsch alles grün, auf Englisch nur die drei bekannten Fehlschläge durch deutsche Textvergleiche (10-Minuten-Hinweis, Ping, Port-in-Bridge-Plan).
- **Achtung:** Der Harness liegt nur im Session-Scratchpad (`C:\Users\Daniel\AppData\Local\Temp\claude\R--CC-Projekte-Mikrotik-Installer\05e4763f-93ba-4c02-9f0a-a33e13b15aed\scratchpad\wpf-smoke\`) und ist nicht im Repo. Er kann jederzeit verschwinden. Er nutzt die Helfer `ConnectAsync(host)`, `OpenAsync(vm, AppArea)` und `RenderWindow(window, png)` gegen die Demo-Hosts 127.127.127.128/129.
- Der Harness schreibt in die echten AppData-Dateien. Deshalb stehen die Testrouter 127.127.127.127–129 in „Bekannte Router“. Bei Bedarf in der App entfernen.
## Bekannte Kleinigkeiten
- Ohne Verbindung erscheint „Start“ in der Seitenleiste gedimmt als ausgewählt (rein optisch).
## Kontext
- Gitea `origin` = `ssh://git@192.168.1.222:2222/admin/Mikrotik-Installer.git`, Web http://192.168.1.222:3666. **Nie** nach `github` pushen. `tea` funktioniert (`--repo admin/Mikrotik-Installer`).
- Arbeitsweise: Tests grün → committen → nach origin pushen → README und Doku aktualisieren.
- Antworten auf Deutsch mit Umlauten.
- `found.md` im Projekt-Root ist das lokale Backlog und liegt nicht in Git.
- Vorheriger Handoff: `.claude/handoffs/2026-10-16-mac-abgleich-v1.11.md` (Mac-Abgleich v1.11.4, P1–P42).
## Nächste Schritte
1. [ ] Release-Build neu erstellen: `build-release.ps1` → `src\MikrotikInstaller.App\bin\Release\net8.0-windows\win-x64\publish\MikroTik-Installer.exe`. Vorher eine laufende alte Instanz schließen (sperrt die exe).
2. [ ] Neue Oberfläche in EN und PT von Hand durchklicken.
3. [ ] Optional: die gedimmte „Start“-Auswahl ohne Verbindung beheben.
4. [ ] Echtgerät-Prüfung (#71, außerdem #1, #2, #17, #30, #32), wartet auf Hardware.
5. [ ] Entscheidung zu #26 und #20, v1.1-Housekeeping (#3, #4, #5), #18.
## Dateien zum Einlesen
- `src/MikrotikInstaller.App/ViewModels/MainViewModel.cs`: Navigation und Verbindungslebenszyklus
- `src/MikrotikInstaller.App/ViewModels/SetupWizardViewModel.cs`: Einrichtungsassistent
- `src/MikrotikInstaller.App/MainWindow.xaml`: Seitenleiste und Inhaltsbereich
- `src/MikrotikInstaller.App/WizardSession.cs`: `ResetPlan()`
+7 -8
View File
@@ -7,18 +7,17 @@ on:
jobs:
build:
# Läuft auf dem selbstgehosteten Windows-Runner (siehe README "CI/CD"), im "host"-Modus ohne
# Docker direkt auf dessen eigener PowerShell/Shell mit dem dort installierten .NET 8 SDK.
runs-on: windows-latest
# Passe dieses Label an, falls dein Runner unter einem anderen Namen registriert ist
# (in Gitea unter Repository/Organisation -> Einstellungen -> Actions -> Runner sichtbar).
runs-on: ubuntu-latest
steps:
- name: Code auschecken
uses: actions/checkout@v4
# Kein actions/setup-dotnet-Schritt: der Runner läuft bewusst im User-Modus (kein Admin), und
# setup-dotnet versucht, seine eigene SDK-Kopie nach "C:\Program Files\dotnet" zu installieren —
# dafür fehlen dann die Schreibrechte. Das .NET 8 SDK ist auf diesem Rechner bereits vorhanden.
- name: .NET-SDK-Version prüfen
run: dotnet --version
- name: .NET SDK einrichten
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
# Debug, nicht Release: der interne Demo-Verbindungszugang, den ein Teil der Tests nutzt,
# existiert per #if DEBUG nur in Debug-Builds (siehe RouterOsClientFactory.cs) — in Release
+37 -157
View File
@@ -3,65 +3,34 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Scanned by VirusTotal](https://img.shields.io/badge/Scanned%20by-VirusTotal-blue?logo=virustotal&logoColor=white)](../../releases)
Ein Windows-Programm, mit dem sich MikroTik-Router/-Switches in wenigen, klar geführten Schritten einrichten lassen – auch ohne Netzwerk-Vorkenntnisse. Statt WinBox-Menüs und RouterOS-Fachbegriffen gibt es frei anklickbare Bereiche und einen geführten Einrichtungs-Assistenten: Verbindung herstellen, ein paar verständliche Fragen beantworten, fertig.
Ein Windows-Programm, mit dem sich MikroTik-Router/-Switches in wenigen, klar geführten Schritten einrichten lassen – auch ohne Netzwerk-Vorkenntnisse. Statt WinBox-Menüs und RouterOS-Fachbegriffen gibt es ein Interview: Verbindung herstellen, ein paar verständliche Fragen beantworten, fertig.
> **Hinweis:** Dies ist ein unabhängiges, privates Open-Source-Projekt ohne jede kommerzielle Absicht. Es steht in keiner Verbindung zu MikroTik und wird nicht von MikroTik unterstützt, gesponsert oder geprüft. Siehe [Markenrechte & Haftungsausschluss](#markenrechte--haftungsausschluss).
![Zusammenfassungs-Schritt des Assistenten](docs/screenshot.png)
## Bedienung: Bereiche statt Interview
Die Seitenleiste zeigt oben die Verbindung (bekannte Router zum Anklicken bzw. das verbundene Gerät mit „Wechseln"), darunter die **frei anklickbaren Bereiche** wie in der Mac-Version; unten stehen „Hilfe & Begriffe" und „Einstellungen". Ohne Verbindung zeigt der Inhalt die Verbinden-Seite, und die Gerätebereiche sind gesperrt; Hilfe und Einstellungen gehen immer.
| Bereich | Inhalt |
|---|---|
| **Start** | Schnellzugriffe, Gerätedaten (Modell, RouterOS-Version, Seriennummer, Schnittstellen mit Link-Status-Punkt) und RouterOS-Update |
| **Einrichten** | der Einrichtungs-Assistent (siehe unten), Schritte oben direkt anklickbar, eigene Zurück/Weiter-Leiste |
| **Übersicht** | nach Kategorien gruppierte, anklickbare Grafik der vorhandenen Bridges, VLANs, IP-Adressen, DHCP-Server, WLANs und IP-gebundenen Firewall-/NAT-Regeln, mit „Professioneller Netzwerker" direkt bearbeitbar |
| **Geräte** | LAN-Scanner: alle erkannten Geräte nach Anschluss gruppiert, feste IP zuweisen, Werkzeuge (siehe unten) |
| **Backup & Wartung** | Backups, Neustart, Firmware, Werksreset (siehe unten) |
| **Experte** | nur mit „Professioneller Netzwerker": RouterOS-Menüs und Werkzeuge (siehe unten) |
Nach dem Verbinden liest die App das Gerät einmal aus, trägt es in „Bekannte Router" ein und öffnet „Start". Bei jedem neuen Verbinden beginnt der Einrichtungsplan von vorn, damit nichts vom vorherigen Gerät angewendet wird. Ein erneuter Klick auf einen Bereich lädt ihn neu.
## Was der Assistent einrichtet
Im Bereich „Einrichten", mit Erklär-Tooltips (ⓘ) bei jedem Fachbegriff. Die Schritte lassen sich in beliebiger Reihenfolge anklicken; besuchte Schritte bekommen einen Haken. Die Zusammenfassung enthält nur, was in besuchten Schritten gültig eingestellt wurde.
Schritt für Schritt, mit Erklär-Tooltips (ⓘ) bei jedem Fachbegriff:
Verbunden wird vorher auf der Verbinden-Seite: IP-Adresse, Benutzername, Passwort (optional „Passwort merken"); **„Router im Netzwerk suchen"** findet MikroTik-Geräte per MNDP auch ohne passende IP (siehe unten); bereits erfolgreich verbundene Geräte erscheinen als **„Bekannte Router"** in der Seitenleiste; die **Testumgebung** bietet simulierte Router ohne eigene Hardware.
1. **Verbindung** – IP-Adresse, Benutzername, Passwort
2. **Geräte-Erkennung** – liest Modell, RouterOS-Version und Schnittstellen aus
3. **Vorhandene Netzwerke** – bei einem Bestandsgerät: nach Kategorien gruppierte, anklickbare Grafik der bereits eingerichteten Bridges, VLANs, IP-Adressen, DHCP-Server, WLANs und IP-gebundenen Firewall-/NAT-Regeln (siehe unten); bei werksneuen Geräten erscheint nur ein kurzer Hinweis
4. **Modus wählen** – geführter Assistent (Standard) oder **Experten-Modus**: Letzterer blendet in den folgenden Schritten zusätzliche, technischere RouterOS-Optionen ein (siehe unten)
5. **Internet-Zugang** – WAN-Schnittstelle, DHCP oder feste IP, NAT
6. **Heimnetzwerk (DHCP)** – Bridge, IP-Bereich, DHCP-Server, DNS
7. **VLANs** *(optional)* – zusätzliche, getrennte Netzwerke (z. B. Gäste, IoT), im Experten-Modus wahlweise fest einem oder mehreren Anschlüssen zugeordnet (portbasiert, siehe unten)
8. **WLAN** *(übersprungen, wenn nicht vorhanden – oder bewusst abwählbar, z. B. bei einem reinen Switch)*
9. **Firewall** – sichere Basisregeln, optionale Portfreigaben
10. **Zusammenfassung** – alle geplanten Änderungen im Klartext, automatisches Backup auf dem Gerät vor dem Anwenden, **PDF-Export** mit allen Zugangsdaten inkl. WLAN-QR-Code zum Ausdrucken/Aufbewahren
11. **RouterOS-Update** *(optional)* – prüft auf eine neuere RouterOS-Version und kann sie mit deutlicher Warnung starten
1. **Modus wählen** *(nur mit „Professioneller Netzwerker" in den Einstellungen)* – geführter Assistent (Standard) oder **Experten-Modus**: Letzterer blendet in den folgenden Schritten zusätzliche, technischere RouterOS-Optionen ein (siehe unten)
2. **Internet-Zugang** – WAN-Schnittstelle, DHCP oder feste IP, NAT
3. **Heimnetzwerk (DHCP)** – Bridge, IP-Bereich, DHCP-Server, DNS; vorbelegt mit dem schon vorhandenen Heimnetz des Routers, „Port freimachen?" bei belegten Anschlüssen, Lease-Zeit als Jahre/Tage/Stunden
4. **VLANs** *(optional)* – zusätzliche, getrennte Netzwerke (z. B. Gäste, IoT), im Experten-Modus wahlweise fest einem oder mehreren Anschlüssen zugeordnet (portbasiert, siehe unten)
5. **WLAN** *(übersprungen, wenn nicht vorhanden – oder bewusst abwählbar, z. B. bei einem reinen Switch)*
6. **Firewall** – sichere Basisregeln, optionale Portfreigaben
7. **Zusammenfassung** – alle geplanten Änderungen im Klartext, automatisches Backup auf dem Gerät vor dem Anwenden (eigener, sichtbarer Schritt), Fortschrittsbalken und Abschluss-Übersicht, **PDF-Export** mit allen Zugangsdaten inkl. WLAN-QR-Code zum Ausdrucken/Aufbewahren
Das **RouterOS-Update** steht auf der Startseite: Es prüft auf eine neuere RouterOS-Version und kann sie mit deutlicher Warnung starten.
Nichts wird am Gerät verändert, bevor der Nutzer in der Zusammenfassung ausdrücklich auf „Jetzt einrichten“ klickt (Ausnahmen: die ausdrücklichen Aktionen in „Backup & Wartung“ und „Feste IP zuweisen“ im LAN-Scanner, siehe unten).
### Router im Netzwerk finden (MNDP)
„Router im Netzwerk suchen" im Verbindungs-Schritt schickt eine MNDP-Anfrage (MikroTik Neighbor Discovery, UDP 5678) ins lokale Netz und sammelt vier Sekunden lang die Antworten — dieselbe Liste, die WinBox unter „Neighbors" zeigt. Gefunden wird über die MAC-Adresse, also auch ein Router mit kaputter oder unpassender IP-Konfiguration. „IP übernehmen" trägt die Adresse ins Formular ein; hat ein Router keine IP, bleibt nur WinBox per MAC (Hinweis + „MAC kopieren").
### Testumgebung
Drei simulierte Router (CHR, hAP ac2, CRS326 — alle `admin`/`admin` unter `127.127.127.127`–`.129`) lassen sich im Verbindungs-Schritt per Klick verbinden, auch in der veröffentlichten Version. Es findet keinerlei Netzwerkverbindung statt; Änderungen bleiben bis zum Beenden der App erhalten (auch über einen simulierten Neustart hinweg). Abschaltbar unter Einstellungen → „Testumgebung anbieten".
Nichts wird am Gerät verändert, bevor der Nutzer in der Zusammenfassung ausdrücklich auf „Jetzt einrichten“ klickt.
### Vorhandene Netzwerke (IST-Zustand-Analyse)
Bei einem bereits konfigurierten Bestandsgerät liest der dritte Schritt automatisch aus, was dort schon eingerichtet ist, und zeichnet es als Diagramm: jeder Knoten ist eine Bridge, ein VLAN, eine IP-Adresse, ein DHCP-Server, ein WLAN, eine IP-gebundene Firewall-/NAT-Regel oder der Internet-Zugang. Die Grafik ist in Kategorie-Spalten von links nach rechts gruppiert (Internet → Netzwerk-Schnittstellen → IP-Adressen → DHCP-Server → Firewall-Regeln) für eine klare Übersicht statt eines einzelnen unübersichtlichen Baums. Ein Klick auf einen Knoten hebt alles hervor, was mit ihm verbunden ist — sowohl den Weg („Hops") bis zum Internet-Zugang als auch alles, was von diesem Knoten abhängt (z. B. zeigt ein Klick auf eine Bridge alle angeschlossenen Ports, VLANs, IP-Adressen und DHCP-Server). Rein informativ — verändert nichts am Gerät und fließt nicht in die vom Assistenten geplante Konfiguration ein. Bei einem werksneuen Gerät ohne bestehende Konfiguration erscheint statt der Grafik nur ein kurzer Hinweis.
### Geräte im Netzwerk (LAN-Scanner)
Der LAN-Scanner führt DHCP-Leases (`/ip/dhcp-server/lease`), die ARP-Tabelle (`/ip/arp`) und die Bridge-Host-Tabelle (`/interface/bridge/host`) per MAC-Adresse zu einer Geräteliste zusammen, gruppiert nach dem physischen Anschluss, an dem ein Gerät tatsächlich hängt (aus der Bridge-Host-Tabelle — ARP/Lease zeigen bei gebrückten Setups sonst nur die Bridge selbst, nicht den echten Port). Geräte ohne Hostnamen in der Lease bekommen den Namen, den der Router selbst im DNS eingetragen hat (`/ip/dns/static`, z. B. `nas.lan`). Rein informativ bis auf eine Aktion: Bei einem Gerät mit DHCP-Lease lässt sich diese per Klick fest zuweisen (RouterOS „Make Static") oder wieder entfernen. Ein „Aktualisieren"-Button lädt die Liste jederzeit neu.
**Werkzeuge** je Gerät: **Ping**, **Traceroute** und **DNS-Auflösung** laufen vom Router aus über dieselbe API-Verbindung (kein SSH nötig; die DNS-Auflösung bevorzugt den Eintrag `name.domain` im Router-DNS). Der **Port-Scan** läuft direkt von diesem PC aus und prüft 19 gängige TCP-Ports: rot = offen (dort antwortet ein Dienst), grün = geschlossen, grau = keine Antwort (Firewall oder Gerät aus). Adressen und Namen aus DHCP-Leases kann ein Gerät frei wählen — sie werden vor jeder Verwendung gegen eine Zeichen-Allow-List geprüft.
RouterOS kennt keinen direkten „wieder dynamisch machen"-Befehl — das Rückgängig-Machen entfernt den Lease-Eintrag komplett. Das betroffene Gerät merkt das aber nicht sofort: Es hält seine bisherige IP-Adresse client-seitig weiter, bis es von sich aus eine neue DHCP-Anfrage stellt (Lease-Renewal-Timer) oder der Link neu aufgebaut wird (Kabel/WLAN trennen und neu verbinden, Neustart). Bis dahin zeigt die Zeile „Feste IP entfernt" mit deaktiviertem Button und einem erklärenden Hinweistext, statt kommentarlos auszugrauen. Wie die IST-Zustand-Analyse verändert dieser Schritt nichts an der vom Assistenten geplanten Soll-Konfiguration.
### Experten-Modus
Wer RouterOS kennt, kann im vierten Schritt den Experten-Modus wählen. Die Schritte 5–9 zeigen dann zusätzlich (Laien-Modus bleibt exakt wie zuvor, Defaults entsprechen dem bisherigen Verhalten):
@@ -72,83 +41,17 @@ Wer RouterOS kennt, kann im vierten Schritt den Experten-Modus wählen. Die Schr
- **WLAN:** WPA2/WPA3/gemischt, SSID verstecken, Band/Kanal festlegen
- **Firewall:** Portfreigabe auf eine Quell-IP beschränken, Portbereiche statt Einzelport, Zugriffe protokollieren, IP-/Subnetz-Sperrliste
### Backup & Wartung
Eigenständiger Bereich in der Seitenleiste. Liest das verbundene Gerät selbst aus und zeigt sofort: vorhandene Backups auf dem Gerät (nach Tag gruppiert), einen „Neues Backup erstellen"-Button für eine frische, unabhängige Sicherung (z. B. vor eigenen Änderungen direkt in WinBox — erscheint sofort in der Liste) und „Aktualisieren" zum erneuten Auflisten. Ein Backup lässt sich direkt aus der Liste zurückspielen.
- **Verschlüsselung:** Ist in den Einstellungen ein Backup-Passwort gesetzt, verschlüsselt RouterOS jedes neue Backup selbst (`password=…`, `encryption=aes-sha256`); der Dateiname bekommt die Endung `-enc`, die Liste zeigt ein Schloss, und beim Zurückspielen wird das Passwort mitgegeben. Ohne Passwort weist der Bereich darauf hin, dass Backups unverschlüsselt entstehen. Das Passwort liegt im Windows-Anmeldeinformationsspeicher.
- **Auf PC speichern:** lädt das ausgewählte Backup per SFTP (SSH-Dienst des Routers) in den Backup-Ordner aus den Einstellungen. Beim ersten Mal wird der SSH-Schlüssel des Routers bestätigt und gemerkt (TOFU, `%AppData%\MikroTik-Installer\known-hosts.json`); ein geänderter Schlüssel wird nie still akzeptiert.
- **Handsicherung zählt:** Ein von Hand erstelltes Backup ersetzt 10 Minuten lang die automatische Sicherung vor der nächsten Änderung (Anwenden, Zurückspielen, Werksreset) — kein Backup-Stapel für jeden Handgriff.
- **Wartung:** „Jetzt neu starten"; bei RouterBOARD-Hardware die installierte und die mitgelieferte **Firmware** mit „Firmware aktualisieren und neu starten"; als Notfallschalter **„Auf Werkseinstellungen zurücksetzen"** (nur nach Bestätigung, vorher eigene Sicherung, RouterOS legt zusätzlich selbst eine an; danach ist das Gerät meist unter `192.168.88.1` erreichbar, die Verbindung wird beendet).
- **Automatisches Wiederverbinden:** Nach Neustart, Firmware-Update, Zurückspielen und RouterOS-Update wartet der Assistent, bis das Gerät wieder antwortet (alle 5 s ein Versuch, Zähler in der Seitenleiste, Aufgabe nach etwa 5 Minuten) und liest danach alles neu ein. Zusätzlich prüft ein Herzschlag alle 30 s bei aktivem Fenster, ob die Verbindung noch steht, und verbindet bei einem Abbruch selbst neu.
RouterOS prüft beim Zurückspielen selbst nicht, ob ein Backup zum verbundenen Gerät passt — es importiert klaglos eine Fremd-Konfiguration mit ggf. falsch zugeordneten Interfaces. Der Assistent verhindert das mit einer vierstufigen Prüfung: Jedes hier selbst erstellte Backup trägt Gerätemodell, Architektur **und Seriennummer** im Dateinamen (z. B. `mikrotik-installer-RB750Gr3_arm_414403A029A5-20260917-193000.backup`, jeweils per `_` getrennt).
- **Passt genau** (Modell, Architektur und Seriennummer stimmen überein, oder die Seriennummer ist auf einer Seite nicht bekannt) — direkt wiederherstellbar.
- **Gleiches Modell, anderes physisches Gerät** (Modell und Architektur passen, aber eine bekannte, abweichende Seriennummer) — gelb markiert, nur nach ausdrücklicher Bestätigung „auf eigene Gefahr" wiederherstellbar. Erkennt z. B. ein baugleiches Ersatzgerät, das ein reiner Modell-Abgleich fälschlich als identisch durchließe.
- **Anderes Modell oder andere Architektur** — rot markiert, ohne Override-Möglichkeit blockiert (echtes Risiko falsch zugeordneter Interfaces).
- **Nicht prüfbar** (kein erkennbarer Name, z. B. ein Backup aus WinBox) — nur nach ausdrücklicher Bestätigung, dass es zum Gerät passt.
Wie beim normalen Anwenden wird vor dem Zurückspielen zusätzlich eine Sicherung des *aktuellen* Zustands erstellt — falls das Zurückspielen nicht das gewünschte Ergebnis war, lässt sich so auch das noch rückgängig machen. Das Gerät startet nach dem Zurückspielen automatisch neu.
### Heimnetzwerk vorladen und Port-Konflikte
Der Heimnetz-Schritt liest das bereits vorhandene Netz des Routers (erste Bridge mit Adresse, ihre Anschlüsse, DHCP-Server mit Pool und Lease-Zeit, Domain, DNS) und startet damit statt mit Standardwerten. Beim Anwenden werden vorhandene Einträge aktualisiert statt doppelt angelegt (`RouterOsUpsert`) — vorher scheiterte ein erneuter Lauf an „already have such name". Ein Anschluss, der in eine andere Bridge gehörte, wird umgehängt; ein vorhandener DHCP-Server behält seinen Namen. Hat ein gewählter Anschluss noch eine eigene IP-Adresse, einen DHCP-Client oder DHCP-Server, bleibt „Weiter" gesperrt, bis „Port freimachen" bestätigt oder der Anschluss abgewählt ist; läuft die eigene Verbindung über diese Adresse, warnt der Assistent ausdrücklich.
### Experte
Eigener Bereich (nur mit „Professioneller Netzwerker"): links rund 50 kuratierte RouterOS-Menüs nach Gruppen (Firewall Filter/NAT/Mangle/Raw/Adress-Listen, Interfaces, IP & Dienste, Hotspot, Routing, VPN, WLAN, Queues, System, Werkzeuge) mit insgesamt über 200 Feldhilfen — per Skript aus dem Katalog der Mac-Version übernommen (`src/MikrotikInstaller.Core/Expert/menu-catalog.json`) — plus „Eigener Menüpfad" für jedes weitere Menü. Einträge lassen sich bearbeiten, anlegen und löschen; vor jedem Schreibzugriff zeigt der Bereich den genauen RouterOS-Befehl, vor der ersten Änderung entsteht eine Sicherung. Dynamische und Standard-Einträge sowie feste Listen haben keinen Löschen-Knopf, deaktivierte Einträge sind ausgegraut, Einstellungs-Menüs wie `/ip/dns` werden per `set` ohne ID geschrieben.
Werkzeuge, jeweils mit Plan vorab, Fortschritt und Abschluss-Übersicht:
- **Netzwerke isolieren** — derselbe Baustein wie im VLAN-Schritt; verweigert sich, wenn die eigene Verbindung aus dem zu isolierenden Netz kommt.
- **Neues LAN anlegen** — nimmt einen Anschluss (Kabel oder WLAN) aus seiner Bridge und gibt ihm ein eigenes Netz mit DHCP, auf Wunsch isoliert.
- **Port in Bridge einfügen** — der Rückweg: räumt Adresse, DHCP-Server samt Pool und Netzwerk sowie Isolationsregeln (mit Kommentar oder ältere ohne) auf und hängt den Anschluss wieder ein.
- **Pakete** — RouterOS-Zusatzpakete wie `hotspot` installieren oder entfernen: Liste vom MikroTik-Server, Vormerken (bei einer Ablehnung werden bereits gesetzte Vormerkungen zurückgenommen), `apply-changes`, automatisches Wiederverbinden und Ergebnis je Paket; Speicher- und WLAN-Treiber-Warnung, das Grundpaket ist nie entfernbar.
Fehlt ein Bereich, weil er ein eigenes Paket ist (z. B. Hotspot auf dem hAP lite), erklärt der Bereich das statt einer rohen Fehlermeldung. Derselbe Bearbeiten-Bereich steht in „Vorhandene Netzwerke" über „Bearbeiten" für Interfaces, IP-Adressen, DHCP-Server und Firewall-/NAT-Regeln zur Verfügung.
### Hilfe & Begriffe
„Hilfe & Begriffe" unten in der Seitenleiste zeigt das Handbuch ([`docs/Handbuch.md`](docs/Handbuch.md), in die App eingebettet) und 49 durchsuchbaren Begriffserklärungen (aus dem Glossar der Mac-Version übernommen und an die Windows-Version angepasst, in Deutsch, Englisch und Portugiesisch hinterlegt).
### Sprachen
Deutsch, Englisch und Portugiesisch (Brasilien), wählbar in den Einstellungen und in der Einführung; Standard ist die Windows-Sprache (sonst Englisch). Die neue Sprache gilt nach einem Neustart, den die App gleich anbietet. Technisch wie in der Mac-Version: Der deutsche Text ist der Schlüssel (`L.T("…")` im Code, `{l:T '…'}` in XAML, `L.F` für Texte mit Werten), die Übersetzungen liegen in `src/MikrotikInstaller.Core/Localization/translations.json` (Menükatalog und Überschneidungen aus der Mac-Version übernommen); fehlt Portugiesisch, kommt Englisch. Menükatalog, Begriffe und Handbuch (`docs/Handbook.en.md` für Englisch und Portugiesisch) folgen der Sprache, Datums- und Zahlenformate ebenso. RouterOS-Daten (z. B. die Kommentare der Isolationsregeln) bleiben bewusst unübersetzt. Ein Test prüft, dass alle Übersetzungen dieselben Platzhalter haben wie das Original.
### Isolierte Netzwerke
„Von Heimnetz isolieren" im VLAN-Schritt legt nicht nur Drop-Regeln für die Weiterleitung in beide Richtungen an (mit Kommentar `Isolation vlan20 -> bridge`), sondern sperrt auch die **Router-Verwaltung** (WebFig, SSH, WinBox, API) aus dem isolierten Netz — nur DNS und DHCP zum Router bleiben erlaubt. Ohne das erreichten Gäste die Anmeldeseite des Routers (in der Mac-Version beim Hotspot-Test gefunden). Bereits bestehende Verbindungen des isolierten Netzes werden gekappt, sonst liefen sie trotz neuer Regel über die vorgelagerte „established"-Regel weiter. Ältere Regeln ohne Kommentar werden weiterhin als vorhanden erkannt und nicht doppelt angelegt.
### Einstellungen
Bereich unten in der Seitenleiste, auch ohne Verbindung erreichbar; Änderungen gelten mit „Speichern": Sprache, Erscheinungsbild (Wie System / Hell / Dunkel), Update-Prüfung beim Verbinden, **„Professioneller Netzwerker"** (blendet den Bereich „Experte" und den Experten-Modus im Assistenten ein, Standard: aus), „Testumgebung anbieten", Ordner für Backups auf diesem PC, Backup-Passwort und „Einführung beim nächsten Start erneut zeigen". Beim ersten Start erscheint eine kurze **Einführung** mit denselben Grundeinstellungen, bis „Fertig" oder „Überspringen" gedrückt wurde. Die App startet maximiert mit einem kurzen Startbildschirm.
### Portbasierte VLANs
Im Experten-Modus lässt sich jedem VLAN im VLAN-Schritt zusätzlich eine Checkliste von Anschlüssen zuordnen (z. B. „Ether4"). Geräte, die dort eingesteckt werden, gehören dann automatisch zu diesem Netzwerk – auch ohne eigene VLAN-Unterstützung (802.1Q-Tagging), z. B. ein einzelner Access Point, eine smarte Steckdose oder eine Netzwerkdose im Gästezimmer. Ohne diese Auswahl bleibt ein VLAN wie bisher rein „getaggt" und ist nur über einen VLAN-fähigen Switch/Access Point erreichbar.
Ein Anschluss kann immer nur einem Netzwerk zugeordnet sein; eine Doppelzuordnung wird erkannt und blockiert „Weiter" mit einer Fehlermeldung. Technisch aktiviert das erst beim Anwenden die Bridge-VLAN-Filterung (`vlan-filtering`) auf der Heimnetz-Bridge – und zwar bewusst als allerletzten Schritt, nachdem alle PVID- und Bridge-VLAN-Tabellen-Einträge stehen, damit dabei keine Verbindung (auch nicht die eigene, laufende Einrichtung) unterbrochen wird. Die zugeordneten Anschlüsse erhalten zusätzlich `frame-types=admit-only-untagged-and-priority-tagged` und `ingress-filtering=yes`, damit ein daran angeschlossenes Gerät sich nicht per manuell getaggten Frames in ein anderes VLAN (z. B. das Heimnetz) einschleichen kann. Anschlüsse ohne eigene VLAN-Zuordnung bleiben unverändert Teil des normalen Heimnetzes.
### Eingabevalidierung
Adress-, Bereichs- und Portfelder in WAN, Heimnetzwerk, VLANs und Firewall werden direkt beim Tippen geprüft (IP-/„Adresse/Präfix"-Format, Start ≤ Ende bei Adress- und Portbereichen, Portzahlen 1–65535) und zeigen bei ungültiger Eingabe sofort eine rote, laientaugliche Fehlermeldung statt erst beim Anwenden mit einer RouterOS-Fehlermeldung zu scheitern. „Weiter" bleibt so lange gesperrt, bis alle Felder des aktuellen Schritts gültig sind. Behebt nebenbei einen echten Absturzpfad: Ohne diese Prüfung riefen `LanConfigurator`/`VlanConfigurator` bei jedem Tastendruck ungeprüft `IPAddress.Parse` auf einer möglicherweise unvollständigen Eingabe auf.
### Bekannte Router
Nach jeder erfolgreichen Verbindung merkt sich der Assistent Host, Benutzername und (sobald die Geräte-Erkennung gelaufen ist) die Hardware-Bezeichnung als Namensvorschlag — rein lokal, ohne weiteren Geräte-Zugriff. Das Passwort liegt dabei **nicht** in der lokalen Datei, sondern im Windows-Anmeldeinformationsspeicher (Systemsteuerung → Anmeldeinformationsverwaltung).
Bekannte Router leben ausschließlich als kompakte Kurzauswahl **dauerhaft in der Seitenleiste**, solange keine Verbindung besteht — die Verbinden-Seite selbst zeigt nur noch das Formular für ein neues, noch unbekanntes Gerät, ohne Dopplung. Ein Klick auf einen Eintrag verbindet direkt (ohne Umweg über das Formular) und öffnet bei Erfolg die Startseite; ein kleines Papierkorb-Symbol entfernt einen Eintrag wieder. Der Name kommt automatisch vom Gerätemodell — eine eigene Umbenennung gibt es bewusst nicht mehr, um die Seitenleiste kompakt zu halten. Sobald eine Verbindung besteht, zeigt dieselbe Stelle stattdessen Gerätename/-modell mit einem „Wechseln"-Button, der die Verbindung trennt und zur Verbinden-Seite zurückführt.
Bei „Verschlüsselte Verbindung" werden von Windows anerkannte Zertifikate direkt akzeptiert; ein selbstsigniertes Gerätezertifikat (der Normalfall) wird beim ersten Mal mit Fingerabdruck bestätigt und gemerkt, ein später geändertes Zertifikat nie still übernommen (Präfix `tls:` in `known-hosts.json`).
Da viele MikroTik-Geräte werksseitig dieselbe Standardadresse verwenden (`192.168.88.1`/`admin`), würde eine reine Host+Benutzername-Zuordnung mehrere unterschiedliche physische Geräte fälschlich als dasselbe behandeln. Der Assistent liest deshalb zusätzlich die Seriennummer aus (`/system/routerboard`) und legt bei einem Host+Benutzername-Treffer mit **unterschiedlicher** Seriennummer einen eigenen Eintrag an, statt den bestehenden zu überschreiben — kennt eine Seite (noch) keine Seriennummer (z. B. ein virtuelles CHR-Testgerät), wird im Zweifel derselbe Eintrag aktualisiert.
## Für Nutzer
- Windows 10/11, keine Installation nötig
- Einfach `MikroTik-Installer.exe` herunterladen und starten – eine einzelne, eigenständige Datei (~67 MB), kein separates .NET muss installiert werden
- Portable: läuft von jedem Ordner/USB-Stick, schreibt nichts in die Registry; Einstellungen und „Bekannte Router" liegen unter `%AppData%\MikroTik-Installer\` (`settings.json`, `saved-routers.json`), Passwörter im Windows-Anmeldeinformationsspeicher
- Portable: läuft von jedem Ordner/USB-Stick, schreibt nichts in Registry oder AppData
### Ist die Datei vertrauenswürdig?
@@ -163,7 +66,7 @@ Die exe ist nicht mit einem kostenpflichtigen Code-Signing-Zertifikat signiert
### Voraussetzungen
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0), aktueller Patchstand: Der Release-Build muss mindestens Runtime 8.0.33 mitbringen (CVE-2026-111497, siehe [SECURITY.md](SECURITY.md))
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
### Bauen & Testen
@@ -181,37 +84,19 @@ dotnet run --project src/MikrotikInstaller.App
Erzeugt `src/MikrotikInstaller.App/bin/Release/net8.0-windows/win-x64/publish/MikroTik-Installer.exe` – self-contained, single-file, ohne .NET-Runtime-Installation beim Nutzer.
Es gibt außerdem einen Gitea-Actions-Workflow (`.gitea/workflows/build.yml`), der das bei jedem Push automatisch übernimmt.
Es gibt außerdem einen Gitea-Actions-Workflow (`.gitea/workflows/build.yml`), der das bei jedem Push automatisch übernimmt – vorausgesetzt, ein passender Runner ist auf der Gitea-Instanz registriert.
### CI/CD (Gitea Actions)
### Interner Testzugang (nur Debug-Builds)
Ein selbstgehosteter Runner (`gitea-runner`, Name „danielpc-windows") läuft im **User-Modus ohne Docker** direkt auf dem Windows-Entwicklungsrechner und nutzt dessen bereits installiertes .NET 8 SDK – daher **kein** `actions/setup-dotnet`-Schritt im Workflow (der würde versuchen, nach `C:\Program Files\dotnet` zu schreiben, wofür dem User-Modus-Runner die Rechte fehlen). `runs-on: windows-latest` im Workflow muss daher exakt dem Label entsprechen, mit dem der Runner registriert wurde (`windows-latest:host`).
Läuft der Runner nicht dauerhaft im Hintergrund (z. B. nach einem Neustart), bleiben Pushes einfach in der Warteschlange, bis er wieder gestartet wird – nichts geht verloren, es gibt nur keine sofortige Rückmeldung.
### Testumgebung (Demo-Client)
`DemoRouterOsClient` simuliert drei Router (`127.127.127.127`–`.129`, `admin`/`admin`) komplett im Arbeitsspeicher, inkl. Backups, Firmware-Update, Ping/Traceroute/DNS. Seit dem Abgleich mit der Mac-Version (v1.8.0) auch in Release-Builds verfügbar (Abschnitt „Testumgebung" im Verbindungs-Schritt, abschaltbar in den Einstellungen) — die Adressen liegen im Loopback-Bereich und können nie ein echter Router sein. Ein Gerät je Adresse für die Laufzeit der App, damit Änderungen ein simuliertes Wiederverbinden überstehen.
Im Verbindungs-Schritt aktiviert die Kombination `127.127.127.127` / `admin` / `admin` einen komplett simulierten Router (kein echtes Gerät nötig, keine Netzwerkverbindung) – damit lässt sich der gesamte Assistent gefahrlos durchklicken. Dieser Zugang ist per `#if DEBUG` ausgeschlossen und existiert in veröffentlichten Release-Builds nicht.
### Projektstruktur
```
src/
MikrotikInstaller.App/ WPF-UI (Views, ViewModels, Wizard-Shell, Einstellungen, Hilfe)
MikrotikInstaller.App/ WPF-UI (Views, ViewModels, Wizard-Shell)
MikrotikInstaller.Core/ RouterOS-Clients, Konfigurationslogik, PDF-Export
Analysis/ IST-Zustand-Analyse
Backup/ Sichern, Zurückspielen, SFTP-Download, Known-Hosts
Configuration/ Assistenten-Konfiguratoren, Isolation, Upsert, Port-Konflikte
Connectivity/ REST-, Binär- und Demo-Client, Zeitlimits, Wiederverbinden
Devices/ Geräte-Erkennung, LAN-Scanner, MNDP, Netzwerk-Werkzeuge
Expert/ Menü-Katalog, Werkzeug-Pläne, Pakete
Help/ Begriffe und Handbuch
Maintenance/ Neustart, Firmware, Werksreset
Settings/ App-Einstellungen
MikrotikInstaller.Core.Tests/ Unit-Tests für Core (xUnit)
docs/
Handbuch.md Handbuch (auch in der App)
mac-paritaet-plan.md Plan zum Abgleich mit der Mac-Version
```
**Core** kapselt die gesamte RouterOS-Kommunikation hinter `IRouterOsClient` – wahlweise über die REST-API (RouterOS 7+) oder die binäre API (Port 8728/8729, ältere Geräte), mit automatischem Fallback. Jede Konfigurations-Funktion (WAN, LAN, VLAN, WLAN, Firewall) erzeugt sowohl die auszuführenden RouterOS-Befehle als auch eine laienverständliche Klartext-Zusammenfassung.
@@ -235,7 +120,7 @@ flowchart TB
IClient(["IRouterOsClient"])
Rest["RestRouterOsClient<br/>RouterOS 7+, Port 443/80"]
Binary["BinaryRouterOsClient<br/>Port 8728/8729"]
Demo["DemoRouterOsClient<br/>Testumgebung, kein echtes Geraet"]
Demo["DemoRouterOsClient<br/>nur #if DEBUG, kein echtes Geraet"]
Discovery["DeviceDiscoveryService"]
Backup["RouterBackupService"]
Updates["RouterOsUpdateService"]
@@ -259,7 +144,7 @@ flowchart TB
Binary --> Device
```
Verbindungsaufbau läuft immer über `RouterOsClientFactory`: REST-API zuerst, bei Fehlschlag Fallback auf die binäre API; nur bei den Testumgebungs-Adressen `127.127.127.127`–`.129` wird stattdessen der `DemoRouterOsClient` verwendet. Zeitlimits gelten je Zugriffsart (`RouterOsTimeouts`: Lesen 15 s, Schreiben 30 s, Befehle wie Backup/Update 120 s, Verbindungstest 8 s), weil schwache Hardware für Backups deutlich länger braucht. Die binäre API arbeitet Zugriffe strikt nacheinander ab (sie ordnet Antworten nur über die Reihenfolge zu) und liest nach einer Fehlerantwort auch das folgende `!done` mit. `IRouterOsClient.RunAsync` liefert die Ausgabe eines Befehls (z. B. `/ping`), `RouterOsException.IsConnectionLost` unterscheidet einen Verbindungsabbruch von einer Ablehnung durch das Gerät. Alle Configurators, `DeviceDiscoveryService`, `RouterBackupService` und `RouterOsUpdateService` sprechen ausschließlich gegen die Abstraktion `IRouterOsClient`, nie gegen eine konkrete Implementierung.
Verbindungsaufbau läuft immer über `RouterOsClientFactory`: REST-API zuerst, bei Fehlschlag Fallback auf die binäre API; nur bei den Test-Zugangsdaten `127.127.127.127` (und nur in Debug-Builds) wird stattdessen der `DemoRouterOsClient` verwendet. Alle Configurators, `DeviceDiscoveryService`, `RouterBackupService` und `RouterOsUpdateService` sprechen ausschließlich gegen die Abstraktion `IRouterOsClient`, nie gegen eine konkrete Implementierung.
Der `ModeSelectionStepViewModel`-Schritt setzt lediglich `WizardSession.IsExpertMode` (bool). Jeder folgende Step-ViewModel liest dieses Flag als Passthrough-Property; die zugehörige View blendet damit zusätzliche Eingabefelder ein oder aus (`Visibility`-Binding auf denselben `BoolToVisibilityConverter`, der auch sonst im Wizard verwendet wird). Es gibt keine zweite Steps-Liste und keinen separaten Codepfad — bei nicht gesetzten Experten-Feldern (Default) erzeugen die Configurators exakt dieselben RouterOS-Befehle wie im reinen Laien-Modus.
@@ -324,7 +209,6 @@ classDiagram
+string Architecture
+bool HasWireless
+InterfaceInfo[] Interfaces
+string? SerialNumber
}
class InterfaceInfo {
@@ -476,7 +360,6 @@ classDiagram
- [Nodify](https://github.com/miroiu/nodify) – anklickbare Netzwerk-Grafik im Schritt „Vorhandene Netzwerke"
- [PDFsharp](https://github.com/empira/PDFsharp) – PDF-Erzeugung
- [QRCoder](https://github.com/codebude/QRCoder) – WLAN-QR-Code
- [SSH.NET](https://github.com/sshnet/SSH.NET) – SFTP für „Backup auf PC speichern"
Die MikroTik-Markenfarbe (`#009245`) wurde aus der offiziellen Website übernommen; das App-Icon ist ein eigenes, neutrales Netzwerk-Symbol (kein MikroTik-Logo).
@@ -490,35 +373,32 @@ Neun der Milestones (`M-*`-Präfix) entstanden aus einem Feature-Vergleich mit e
|---|---|---|
| **v1.0 – Realgerät-Validierung** | Offen | Kompletter Wizard-Durchlauf inkl. „Jetzt einrichten" (RouterOS-Befehle, Backup, PDF-Export, Update-Prüfung) gegen ein echtes physisches Gerät verifizieren – größter offener Validierungs-Gap. Dabei insbesondere die portbasierte VLAN-Zuordnung (`vlan-filtering`) gegen ein Echtgerät prüfen, da bisher nur gegen den simulierten Demo-Client getestet |
| **v1.1 – Release-Housekeeping** | Offen | `v1.0.0`-Tag-Strategie klären, Copyright-Name in `LICENSE` bestätigen, Trennung Gitea (laufendes Dev) / GitHub (nur Releases) wiederherstellen |
| **v1.2 – Eingabevalidierung** | ✅ Abgeschlossen | Live-Fehlermeldungen bei ungültigen Eingaben (IP-/Subnetz-Format, Adressbereiche, Portbereiche) direkt in WAN/LAN/VLAN/Firewall, vom Nutzer bestätigt. Dabei nebenbei einen eigenständigen Bug behoben: Firewall-/NAT-Regeln duplizierten sich bei wiederholtem Wizard-Lauf gegen dasselbe Gerät (Issue #19) |
| **v1.3 – SFTP-Backup-Download** | ✅ Abgeschlossen | „Auf PC speichern" im Bereich Backup & Wartung, SSH-Hostkey per TOFU. Offen nur die Echtgerät-Prüfung (Pfad der Datei per SFTP) |
| **v1.2 – Eingabevalidierung** | Offen | Live-Fehlermeldungen bei ungültigen Eingaben (z. B. IP-/Subnetz-Format) direkt im jeweiligen Wizard-Schritt. Behebt nebenbei einen echten Absturzpfad: `LanConfigurator`/`VlanConfigurator` rufen `IPAddress.Parse` ungeprüft bei jedem Tastendruck auf |
| **v1.3 – SFTP-Backup-Download** | Offen | Backup-Download aufs PC per SFTP nachrüsten (bisher liegt das Backup nur auf dem Gerät selbst) |
| **IST-Zustand-Analyse** | ✅ Abgeschlossen | Kernfunktion umgesetzt und vom Nutzer bestätigt (Kategorie-Grafik, Firewall-/NAT-Regeln, Teilbaum-Hervorhebung). Offen: **optisches Feintuning** sowie Route-zu-Interface-Zuordnung gegen ein echtes Bestandsgerät verifizieren (derzeit best-effort, RouterOS-versionsabhängig) |
| **CI/CD – Gitea-Actions-Runner** | ✅ Abgeschlossen | Selbstgehosteter Runner registriert, `build.yml` erfolgreich scharf getestet (Run #26: Tests + Publish + Artefakt-Upload grün). Läuft aktuell nur, solange der Runner-Prozess manuell gestartet ist — kein Autostart/Dienst eingerichtet |
| **M-Bekannte-Router – Gespeicherte Verbindungen** | ✅ Abgeschlossen | Zuletzt genutzte Router mit Name/Standort merken, vom Nutzer bestätigt (siehe „Bekannte Router" oben) |
| **M-Geräte – LAN-Scanner** | ✅ Abgeschlossen | DHCP-Leases + ARP + Bridge-Hosts je Anschluss zusammengeführt, feste IP-Zuweisung (siehe „Geräte im Netzwerk" oben). Offen: Verhalten von „Make Static" gegen ein echtes Gerät verifizieren (Issue #30) |
| **M-Backup-Wartung – Wiederherstellung, Werksreset, Firmware-Update** | ✅ Abgeschlossen | Backup zurückspielen mit vierstufigem Seriennummer-/Architektur-/Modell-Abgleich, eigenständiger Bereich „Backup & Wartung", Neustart, RouterBOARD-Firmware, Werksreset-Notfallschalter, automatisches Wiederverbinden (siehe oben). Offen nur noch die Echtgerät-Prüfung von `/system/backup/load` inkl. Passwort (Issue #32) |
| **M-Übersicht-Edit – Direktes Bearbeiten im Diagramm** | ✅ Abgeschlossen | „Bearbeiten" für Interfaces, IP-Adressen, DHCP-Server und Firewall-/NAT-Regeln über denselben Editor wie im Experte-Bereich |
| **M-SSH – SSH-Fallback-Transport** | Umgewidmet | Die binäre API deckt ältere Geräte bereits ab; SSH (SSH.NET) wird nur noch für den SFTP-Backup-Download (v1.3) inkl. Hostkey-TOFU gebraucht |
| **M-Netzwerk-Tools – Ping/Traceroute/DNS/Port-Scan** | ✅ Abgeschlossen | Ping/Traceroute/DNS vom Router über die bestehende API, Port-Scan von diesem PC (siehe LAN-Scanner oben) |
| **M-Live-Traffic – Live-Durchsatzanzeige** | Verworfen (vorgeschlagen) | Die Mac-Version hat die Live-Traffic-Anzeige wegen Router-Last wieder entfernt; Issue #26 zur Schließung vorgeschlagen |
| **M-Mac-Abgleich-v1.11** | ✅ Abgeschlossen | Nachziehen der Mac-Version v1.11.4 ohne App-Store-Themen, Arbeitspakete P1–P42 in [`docs/mac-paritaet-plan.md`](docs/mac-paritaet-plan.md) (Issues #37–#70). Echtgerät-Prüfung als Checkliste in Issue #71 (v1.0) |
| **M-Experte – Generischer RouterOS-Zugriff** | ✅ Abgeschlossen | Eigener Bereich mit ~50 kuratierten Menüs samt Feldhilfen, eigenem Menüpfad, Befehlsvorschau und den Werkzeugen Isolieren / Neues LAN / Port in Bridge / Pakete |
| **M-i18n – Sprachen DE/EN/PT** | ✅ Abgeschlossen | Deutsch, Englisch, Portugiesisch (Brasilien) für die gesamte App inkl. Menükatalog, Begriffe und Handbuch (EN); Umschaltung in Einstellungen/Einführung (siehe „Sprachen" oben) |
| **CI/CD – Gitea-Actions-Runner** | Offen | Runner registrieren und `.gitea/workflows/build.yml` erstmals scharf testen |
| **M-Bekannte-Router – Gespeicherte Verbindungen** | Offen | Zuletzt genutzte Router mit Name/Standort merken |
| **M-Geräte – LAN-Scanner** | Offen | DHCP-Leases + ARP + Bridge-Hosts je Anschluss zusammengeführt, feste IP-Zuweisung |
| **M-Backup-Wartung – Wiederherstellung, Werksreset, Firmware-Update** | Offen | Backup zurückspielen (mit Modell-Abgleich als Brick-Schutz), Werksreset-Notfallschalter, Routerboard-Firmware-Update |
| **M-Übersicht-Edit – Direktes Bearbeiten im Diagramm** | Offen | IST-Zustand-Diagramm von rein lesend auf bearbeitbar (Rückschreiben ans Gerät) erweitern |
| **M-SSH – SSH-Fallback-Transport** | Offen | SSH-CLI-Fallback-Verbindung für Geräte/Firmware ohne REST-API, inkl. Hostkey-TOFU |
| **M-Netzwerk-Tools – Ping/Traceroute/DNS/Port-Scan** | Offen | Netzwerk-Werkzeuge vom Router aus sowie lokaler TCP-Port-Scan |
| **M-Live-Traffic – Live-Durchsatzanzeige** | Offen | Live-Traffic-Anzeige an Interfaces und im LAN-Scanner |
| **M-Experte – Generischer RouterOS-Zugriff** | Offen | Eigener Bereich mit generischer Menü-Engine + kuratierten Formularen für die wichtigsten RouterOS-Bereiche, über die bisherigen Experten-Zusatzfelder hinaus |
| **M-i18n – Zweisprachigkeit DE/EN** | Offen | Umschaltbare Sprache für die gesamte App |
### Ablaufplan (Priorisierung)
Reihenfolge oben in der Tabelle bereits danach sortiert; Begründung nach Phasen:
1. **Fundament/Sicherheit zuerst** ✅ — `v1.2` (behebt einen echten Absturzpfad) zusammen mit dem eigenständig gefundenen Firewall-/NAT-Idempotenz-Bug (Issue #19, kein Milestone) und `CI/CD` sind abgeschlossen. `v1.0` (Realgerät-Validierung) bleibt offen, bis Testhardware verfügbar ist — bevor auf der bestehenden Basis neue Flächen entstehen, muss sie gegen echte Hardware stehen.
2. **Unabhängige Mac-Paritätsfeatures ohne SSH-Abhängigkeit** — `M-Bekannte-Router` ✅ und `M-Geräte`/LAN-Scanner ✅ abgeschlossen, `M-Backup-Wartung` teilweise (Backup-Wiederherstellung ✅, Werksreset/Firmware-Update offen). Noch offen: Rest von `M-Backup-Wartung`, `v1.3` (SFTP-Backup-Download, gleicher Themenbereich, sinnvoll im selben Zug), `M-Übersicht-Edit` (baut auf der bereits verifizierten Nodify-Integration auf).
1. **Fundament/Sicherheit zuerst** — `v1.2` (behebt einen echten Absturzpfad, siehe Fokus-Spalte) zusammen mit dem eigenständig gefundenen Firewall-/NAT-Idempotenz-Bug (Issue #19 im Gitea-Repo, kein Milestone). Danach `v1.0` (Realgerät-Validierung) — bevor auf der bestehenden Basis neue Flächen entstehen, muss sie gegen echte Hardware stehen. `CI/CD` direkt im Anschluss, damit ab hier jede weitere Änderung automatisch abgesichert ist.
2. **Unabhängige Mac-Paritätsfeatures ohne SSH-Abhängigkeit** — `M-Bekannte-Router` (klein, rein lokal, sofortiger Nutzen), `M-Geräte`/LAN-Scanner und `M-Backup-Wartung` (beide bauen direkt auf dem bestehenden REST-/Binary-Client auf, kein neuer Transport nötig), `v1.3` (SFTP-Backup-Download, gleicher Themenbereich wie Backup-Wartung, sinnvoll im selben Zug), `M-Übersicht-Edit` (baut auf der bereits verifizierten Nodify-Integration auf).
3. **Transport-Fundament** — `M-SSH`: einzige Mac-Funktion, die einen komplett neuen Transport-Layer braucht (Ping/Traceroute/DNS-Auflösung und `/interface monitor-traffic` sind reine CLI-Befehle ohne REST-Äquivalent). Bewusst erst nach Phase 2, damit die risikoarmen Features nicht auf die größere Transport-Arbeit warten müssen.
4. **Darauf aufbauend** — `M-Netzwerk-Tools` (Port-Scan-Teil ist unabhängig und könnte vorgezogen werden, Ping/Traceroute/DNS brauchen `M-SSH`) und `M-Live-Traffic` (falls kein REST-Äquivalent für `/interface monitor-traffic` existiert, ebenfalls `M-SSH`-abhängig — das wird als Erstes bei der Umsetzung geprüft).
5. **Größtes Einzelstück zuletzt unter den Mac-Milestones** — `M-Experte` (generischer Zugriff auf >300 RouterOS-Menüs) ist der mit Abstand größte Umfang und blockiert nichts anderes; am besten inkrementell über einen längeren Zeitraum, Bereich für Bereich wie beim Mac-Vorbild.
6. **Querschnitt zuletzt** — `M-i18n` erst, wenn die UI-Fläche der vorherigen Phasen weitgehend steht, sonst laufend neue unübersetzte Strings. ✅ Mit dem Mac-Abgleich umgesetzt (DE/EN/PT); neue Texte brauchen ab jetzt einen Eintrag in `translations.json`.
6. **Querschnitt zuletzt** — `M-i18n` erst, wenn die UI-Fläche der vorherigen Phasen weitgehend steht, sonst laufend neue unübersetzte Strings.
7. **Release-Abschluss** — `v1.1` (Release-Housekeeping) kurz vor dem tatsächlichen `v1.0.0`-Tag, nicht vorher.
**Abgleich mit Mac v1.11.4 (2026-10-16):** Der vollständige Plan mit allen Arbeitspaketen steht in [`docs/mac-paritaet-plan.md`](docs/mac-paritaet-plan.md). Umgesetzt sind alle Pakete: Isolation und Verbindungs-Robustheit, Backup & Wartung inkl. SFTP-Download, Einstellungen/Einführung/Testumgebung, MNDP-Router-Suche, Netzwerk-Werkzeuge, Assistent (Vorladen, Port-Konflikte, Lease-Zeit, Fortschritt), Experte-Bereich mit Werkzeugen und Paketen, Bearbeiten in der Übersicht, Hilfe & Begriffe, Zertifikats-TOFU, Sprachen DE/EN/PT. Alles ist gegen die Testumgebung und Unit-Tests geprüft; die Echtgerät-Prüfung steht als Checkliste in Issue #71.
**Abgleichs-Rhythmus mit der Mac-Version:** Kein einmaliger Abgleich, sondern wiederkehrend — nach Abschluss eines `M-*`-Milestones hier oder einer entsprechenden Funktion auf Mac-Seite jeweils kurz prüfen, ob sich die READMEs/Milestones beider Projekte noch decken. RouterOS-API-Eigenheiten, die keine sprach-/framework-spezifische Ursache haben (wie der Firewall-Idempotenz-Bug, Issue #19), werden in beide Projekte übernommen, sobald sie auf einer Seite auffallen.
## Lizenz
-22
View File
@@ -1,22 +0,0 @@
# Sicherheit
## Behobene Schwachstellen
Für Security-Scans: Diese Funde sind geprüft und behoben. Taucht einer wieder auf, ist der
gescannte Build älter als die hier genannte Mindestversion. Neu bauen, dann ist der Fund weg.
| CVE / Advisory | Betroffen | Behoben ab | Behoben am | Maßnahme |
|---|---|---|---|---|
| [CVE-2026-111497](https://github.com/dotnet/announcements/issues/494) (Brotli-Decoder, Out-of-Bounds-Write) | `System.IO.Compression.Brotli.dll` der self-contained .NET-8-Runtime | .NET Runtime **8.0.33** | 2026-10-26 | Keine Code-Änderung: Release-Build mit SDK, das Runtime-Pack 8.0.33+ mitbringt, neu erzeugt. |
| [GHSA-mggc-4xg6-vcxf](https://github.com/advisories/GHSA-mggc-4xg6-vcxf), [GHSA-q939-rpr3-3284](https://github.com/advisories/GHSA-q939-rpr3-3284) | SSH.NET | SSH.NET **2026.0.0** | – | Paket aktualisiert (Commit `c834864`). |
## Hinweise zum .NET-Runtime-Patchstand
Die App wird self-contained veröffentlicht, die .NET-Runtime steckt also in der `.exe`. Welche
Runtime-Version mitkommt, bestimmt das **SDK auf dem Build-Rechner**, nicht die Projektdatei:
- Prüfen: `(Get-Item src\MikrotikInstaller.App\bin\Release\net8.0-windows\win-x64\System.IO.Compression.Brotli.dll).VersionInfo.ProductVersion`
muss `8.0.33` oder höher zeigen.
- Nach jedem .NET-Sicherheitsupdate: SDK aktualisieren, Release neu bauen, die neue `.exe` verteilen.
Bereits verteilte `.exe`-Dateien bleiben verwundbar, bis sie ersetzt werden.
- `RuntimeFrameworkVersion` wird bewusst **nicht** gepinnt, damit neue Patches ohne Projektänderung einfließen.
-93
View File
@@ -1,93 +0,0 @@
# MikroTik Installer manual
This manual explains every area of the app. Technical terms are explained in the "Terms" tab next to this one and behind every ⓘ symbol.
## 1. Connect
In the first step, enter your router's address, user name and password and click "Test connection". A new MikroTik router is usually reachable at 192.168.88.1 with the user "admin"; the password is on the sticker or empty.
- "Remember password" stores the password in the Windows Credential Manager, not in a file. If unchecked, a previously stored password is deleted.
- Only enable "Use encrypted connection" if HTTPS or API-SSL is set up on the router. The first time, the app asks you to confirm the router's certificate and remembers it.
### Find routers on the network
"Find routers on the network" finds MikroTik routers on the same network by their MAC address (like the "Neighbors" list in WinBox), even routers without a matching IP address. "Use IP" fills in the address. If a router has no IP, only WinBox via its MAC address helps; "Copy MAC" puts it on the clipboard.
### Known routers
After every successful connection the app remembers the router in the sidebar. One click connects directly; the trash icon removes the entry together with its stored password. While connected, the same place shows the device and "Switch".
### Test environment
Three simulated routers let you try the app without your own hardware. Nothing goes onto the network; changes are kept until you close the app. You can hide the test environment in the settings.
### Automatic reconnect
Every 30 seconds, while the window is active, the app checks whether the router still answers. If the connection drops — or the router restarts after an update, firmware update, restore or "Restart" — the app retries every 5 seconds. The attempt counter is shown in the sidebar. After about 5 minutes without an answer it gives up; then reconnect via "Switch".
### Router not reachable
Typical case: the router boots (LEDs blink) but can no longer be reached, for example after an interrupted setup. Usually it no longer has a matching IP address.
1. "Find routers on the network": if the router shows up with "no IP address", it is running but not reachable by IP. "Copy MAC".
2. Open WinBox, select the router under "Neighbors" and connect via its MAC address — this works without an IP.
3. In the WinBox terminal, first run `/log print` and save the error message before resetting anything. The log usually names the exact cause.
4. Set an IP for access, e.g. `/ip address add address=192.168.88.1/24 interface=ether2`, then reconnect with the app.
5. Only if that does not help: factory reset using the reset button on the device.
## 2. Areas
Once connected, all areas in the sidebar on the left can be clicked freely:
- Home: model, RouterOS version, serial number and ports (green dot = cable plugged in / link up), plus the RouterOS update (optional, with a warning; afterwards the app reconnects on its own).
- Set up: the setup wizard, see chapter 3.
- Overview: a diagram of the current configuration. Clicking a box highlights everything connected to it. With "Professional network admin", a box can be changed directly via "Edit".
- Devices: see chapter 4.
- Backup & maintenance: see chapter 5.
- Expert: only with "Professional network admin" enabled in the settings, see chapter 6.
Help & terms and Settings sit at the bottom of the sidebar and work without a connection too. Clicking an area again reloads it.
## 3. Setup wizard
The wizard in the "Set up" area asks what should be set up. The steps at the top can be clicked directly; "Back" and "Next" are at the bottom. The summary only contains what was validly set in visited steps. Nothing is changed on the router before you click "Set up now" in the summary.
- Internet access, home network, VLANs, Wi-Fi, firewall: the actual setup. The home network is pre-filled with the router's existing network; when applying, existing entries are updated instead of created twice.
- If a port that should join the home network is still in use otherwise (its own IP address, DHCP), the wizard offers "Free up port".
- "Isolate from home network" separates a VLAN completely: no access to the home network and no router management from the isolated network; only DNS and DHCP stay allowed.
- Summary: all changes in plain language, PDF export with credentials and Wi-Fi QR code. "Set up now" first creates a backup, then shows a progress bar and, at the end, what was done.
## 4. Devices on the network (LAN scanner)
The list shows all devices the router currently sees, grouped by the port they are connected to. "Assign fixed IP address" makes sure a device always gets the same address; "Undo" removes the fixed assignment (the device only notices after disconnecting and reconnecting).
"Tools" per device:
- Ping: Can the router reach the device?
- Traceroute: Which stations does the path go through?
- DNS lookup: Does the router know the device's name?
- Port scan (from this PC): Which common services answer? Red = open, green = closed, grey = no answer.
## 5. Backup & maintenance
- Create new backup: a backup on the router. A manual backup replaces the automatic backup before the next change for 10 minutes.
- Backup password (settings): new backups are encrypted and show a lock in the list. Without the password they cannot be restored.
- Save to PC: downloads a backup via SFTP into the backup folder. The router's SSH service must be enabled. The first time, the app asks whether to trust the router's SSH key.
- Restore now: restores a backup. The app first checks model, architecture and serial number: green = matches, yellow = identical model but a different device (only at your own risk), red = different model (blocked).
- Restart, update firmware, restore factory settings: see maintenance. After a factory reset the router is usually reachable at 192.168.88.1.
## 6. Expert
Only with "Professional network admin". On the left there are tools and about 50 RouterOS menus by area; "Custom menu path" reaches any other menu.
- Edit, add and delete entries. Every field has an explanation (ⓘ). Before saving, the app shows exactly the command that will run; a backup is created before the first change.
- Entries managed by RouterOS itself (dynamic), default entries and fixed lists cannot be deleted; disabled entries are greyed out.
- Isolate networks: separates two networks from each other, including blocking router management from the isolated network.
- Create new LAN: gives a single port its own network with DHCP, isolated if you like.
- Add port to bridge: the way back — returns a port (Wi-Fi too) to the home network and cleans up address, DHCP and isolation rules.
- Packages: install or remove RouterOS extra packages such as "hotspot". The router downloads them from MikroTik and restarts.
- Hotspot: a login portal for guests. Do not put it on the normal home network, but on a separate, isolated guest network.
## 7. Settings
At the bottom of the sidebar, changes apply with "Save": language, appearance (system, light, dark), update check when connecting, "Professional network admin", test environment, folder and password for backups, show introduction again.
-93
View File
@@ -1,93 +0,0 @@
# Handbuch MikroTik Installer
Dieses Handbuch erklärt alle Bereiche der App. Fachbegriffe stehen im Kapitel „Begriffe“ (Reiter nebenan) und hinter jedem ⓘ-Symbol.
## 1. Verbinden
Im ersten Schritt gibst du die Adresse deines Routers, Benutzername und Passwort ein und klickst „Verbindung testen“. Ein werksneuer MikroTik-Router ist meist unter 192.168.88.1 mit dem Benutzer „admin“ erreichbar; das Passwort steht auf dem Aufkleber oder ist leer.
- „Passwort merken“ legt das Passwort im Windows-Anmeldeinformationsspeicher ab, nicht in einer Datei. Ist der Haken aus, wird ein früher gespeichertes Passwort gelöscht.
- „Verschlüsselte Verbindung verwenden“ nur einschalten, wenn HTTPS bzw. API-SSL auf dem Router eingerichtet ist.
### Router im Netzwerk suchen
„Router im Netzwerk suchen“ findet MikroTik-Router im selben Netzwerk über ihre MAC-Adresse (wie die Liste „Neighbors“ in WinBox), auch solche ohne passende IP-Adresse. „IP übernehmen“ trägt die Adresse ein. Hat ein Router keine IP, hilft nur WinBox über die MAC-Adresse; „MAC kopieren“ legt sie in die Zwischenablage.
### Bekannte Router
Nach jeder erfolgreichen Verbindung merkt sich die App den Router in der Seitenleiste. Ein Klick verbindet direkt, das Papierkorb-Symbol entfernt den Eintrag samt gespeichertem Passwort. Ist eine Verbindung aktiv, zeigt dieselbe Stelle das Gerät und „Wechseln“.
### Testumgebung
Drei simulierte Router lassen sich ohne eigene Hardware ausprobieren. Nichts davon geht ins Netz; Änderungen bleiben bis zum Beenden der App erhalten. Ausblenden lässt sich die Testumgebung in den Einstellungen.
### Automatisches Wiederverbinden
Alle 30 Sekunden prüft die App bei aktivem Fenster, ob der Router noch antwortet. Fällt die Verbindung weg — oder startet der Router nach Update, Firmware-Update, Wiederherstellen oder „Neu starten“ neu —, versucht die App es alle 5 Sekunden erneut. Der Versuchszähler steht in der Seitenleiste. Nach etwa 5 Minuten ohne Antwort gibt sie auf; dann über „Wechseln“ neu verbinden.
### Router nicht erreichbar
Typischer Fall: Der Router startet (LEDs blinken), ist aber nicht mehr erreichbar, zum Beispiel nach einer unterbrochenen Einrichtung. Meist hat er dann keine passende IP-Adresse mehr.
1. „Router im Netzwerk suchen“: Erscheint der Router mit „keine IP-Adresse“, läuft er, ist nur nicht per IP erreichbar. „MAC kopieren“.
2. WinBox öffnen, unter „Neighbors“ den Router wählen und über die MAC-Adresse verbinden — das funktioniert ohne IP.
3. Im WinBox-Terminal zuerst `/log print` ausführen und die Fehlermeldung sichern, bevor irgendetwas zurückgesetzt wird. Das Protokoll nennt meist genau die Ursache.
4. Für den Zugriff eine IP setzen, z. B. `/ip address add address=192.168.88.1/24 interface=ether2`, dann mit der App neu verbinden.
5. Erst wenn das nicht hilft: Werkseinstellungen per Reset-Knopf am Gerät.
## 2. Bereiche
Nach dem Verbinden sind alle Bereiche links in der Seitenleiste frei anklickbar:
- Start: Modell, RouterOS-Version, Seriennummer und Anschlüsse (grüner Punkt = Kabel steckt / Verbindung besteht), dazu das RouterOS-Update (optional, mit Warnung; danach verbindet sich die App von selbst wieder).
- Einrichten: der Einrichtungs-Assistent, siehe Kapitel 3.
- Übersicht: eine Grafik der bestehenden Konfiguration. Ein Klick auf einen Kasten hebt alles hervor, was damit zusammenhängt. Mit „Professioneller Netzwerker“ lässt sich ein Kasten über „Bearbeiten“ direkt ändern.
- Geräte: siehe Kapitel 4.
- Backup & Wartung: siehe Kapitel 5.
- Experte: nur mit „Professioneller Netzwerker“ in den Einstellungen, siehe Kapitel 6.
Hilfe & Begriffe und Einstellungen stehen unten in der Seitenleiste und gehen auch ohne Verbindung. Ein erneuter Klick auf einen Bereich lädt ihn neu.
## 3. Einrichtungs-Assistent
Der Assistent im Bereich „Einrichten“ fragt ab, was eingerichtet werden soll. Die Schritte oben lassen sich direkt anklicken; „Zurück“ und „Weiter“ stehen unten. Die Zusammenfassung enthält nur, was in besuchten Schritten gültig eingestellt wurde. Nichts wird am Router geändert, bevor du in der Zusammenfassung „Jetzt einrichten“ klickst.
- Internet-Zugang, Heimnetzwerk, VLANs, WLAN, Firewall: die eigentliche Einrichtung. Das Heimnetzwerk wird mit dem schon vorhandenen Netz des Routers vorbelegt; beim Anwenden werden vorhandene Einträge aktualisiert statt doppelt angelegt.
- Ist ein Anschluss, der ins Heimnetz soll, noch anderweitig belegt (eigene IP-Adresse, DHCP), bietet der Assistent „Port freimachen“ an.
- „Von Heimnetz isolieren“ trennt ein VLAN vollständig: kein Zugriff ins Heimnetz und keine Router-Verwaltung aus dem isolierten Netz, nur DNS und DHCP bleiben erlaubt.
- Zusammenfassung: alle Änderungen im Klartext, PDF-Export mit Zugangsdaten und WLAN-QR-Code. „Jetzt einrichten“ erstellt zuerst eine Sicherung und zeigt dann einen Fortschrittsbalken und am Ende, was ausgeführt wurde.
## 4. Geräte im Netzwerk (LAN-Scanner)
Die Liste zeigt alle Geräte, die der Router gerade sieht, gruppiert nach dem Anschluss, an dem sie hängen. „Feste IP-Adresse zuweisen“ sorgt dafür, dass ein Gerät immer dieselbe Adresse bekommt; „Rückgängig machen“ entfernt die feste Zuweisung (das Gerät merkt das erst nach Trennen und Neuverbinden).
„Werkzeuge“ je Gerät:
- Ping: Ist das Gerät vom Router aus erreichbar?
- Traceroute: Über welche Stationen läuft der Weg?
- DNS-Auflösung: Kennt der Router den Namen des Geräts?
- Port-Scan (von diesem PC): Welche gängigen Dienste antworten? Rot = offen, grün = geschlossen, grau = keine Antwort.
## 5. Backup & Wartung
- Neues Backup erstellen: Sicherung auf dem Router. Eine Handsicherung ersetzt 10 Minuten lang die automatische Sicherung vor der nächsten Änderung.
- Backup-Passwort (Einstellungen): neue Backups werden verschlüsselt und tragen ein Schloss in der Liste. Ohne das Passwort lassen sie sich nicht zurückspielen.
- Auf PC speichern: lädt ein Backup per SFTP in den Backup-Ordner. Dafür muss der SSH-Dienst des Routers aktiv sein. Beim ersten Mal fragt die App, ob sie dem SSH-Schlüssel des Routers vertrauen soll.
- Jetzt wiederherstellen: spielt ein Backup zurück. Die App prüft vorher Modell, Architektur und Seriennummer: grün = passt, gelb = baugleiches anderes Gerät (nur auf eigene Gefahr), rot = anderes Modell (gesperrt).
- Neu starten, Firmware aktualisieren, Werkseinstellungen wiederherstellen: siehe Wartung. Nach einem Werksreset ist der Router meist unter 192.168.88.1 erreichbar.
## 6. Experte
Nur mit „Professioneller Netzwerker“. Links stehen Werkzeuge und rund 50 RouterOS-Menüs nach Bereichen; über „Eigener Menüpfad“ ist jedes weitere Menü erreichbar.
- Einträge bearbeiten, anlegen und löschen. Jedes Feld hat eine Erklärung (ⓘ). Vor dem Speichern zeigt die App genau den Befehl, der ausgeführt wird; vor der ersten Änderung entsteht eine Sicherung.
- Von RouterOS selbst verwaltete Einträge (dynamisch), Standard-Einträge und feste Listen lassen sich nicht löschen; deaktivierte Einträge sind ausgegraut.
- Netzwerke isolieren: trennt zwei Netze voneinander, inklusive Sperre der Router-Verwaltung aus dem isolierten Netz.
- Neues LAN anlegen: gibt einem einzelnen Anschluss ein eigenes Netz mit DHCP, auf Wunsch isoliert.
- Port in Bridge einfügen: der Rückweg — holt einen Anschluss (auch WLAN) zurück ins Heimnetz und räumt Adresse, DHCP und Isolationsregeln auf.
- Pakete: RouterOS-Zusatzpakete wie „hotspot“ installieren oder entfernen. Der Router lädt sie von MikroTik herunter und startet neu.
- Hotspot: ein Anmeldeportal für Gäste. Nicht auf das normale Heimnetz legen, sondern auf ein eigenes, isoliertes Gästenetz.
## 7. Einstellungen
Unten in der Seitenleiste, Änderungen gelten mit „Speichern“: Sprache, Erscheinungsbild (Wie System, Hell, Dunkel), Update-Prüfung beim Verbinden, „Professioneller Netzwerker“, Testumgebung, Ordner und Passwort für Backups, Einführung erneut zeigen.
-107
View File
@@ -1,107 +0,0 @@
# Plan: Windows-Version auf Stand der Mac-Version bringen
**Status:** ✅ alle Pakete P1–P42 umgesetzt (2026-10-16), Echtgerät-Prüfung offen → Issue #71 · **Stand:** 2026-10-16 · **Vorlage:** `kay/RouterOS` auf `git.focus72.de`, Release **v1.11.4** (2026-10-15)
**Ausgenommen (bewusst):** alles rund um App Store und Verkaufsversion — In-App-Kauf/„Vollversion", Paywall, Käufe wiederherstellen, App Sandbox, App-Store-Review, Apple-Signierung/Notarisierung. Ebenfalls nicht übertragbar: rein macOS-spezifische Fehler (Schlüsselbund-Rückfragen, `Settings`-Scene, iCloud-Schlüsselbund).
Gitea-Milestone: **M-Mac-Abgleich-v1.11** (neu) — die bereits bestehenden Paritäts-Issues (#8, #9, #20, #21, #23, #24, #26, #28, #29, #31) bleiben in ihren Milestones und sind unten jeweils referenziert.
## Grundsatzentscheidungen
| Thema | Mac | Windows (Entscheidung) | Begründung |
|---|---|---|---|
| Transport | REST + SSH-CLI-Fallback | REST + **binäre API** (bestehend) | Die binäre API deckt ältere Geräte (RouterOS 6) bereits ab. SSH wird nur noch für **SFTP** (Backup auf den PC laden) gebraucht, nicht als dritter Befehls-Transport. #20 wird entsprechend umgewidmet. |
| Ping/Traceroute/DNS | über SSH-CLI | über **REST/API-Befehle** (`/ping`, `/tool/traceroute`, `/execute` mit `:resolve`) | Kein SSH nötig, funktioniert mit beiden bestehenden Transporten. |
| Sicherungsformat | Text-Export (`.rsc`) + `run-after-reset`-Skript | **Binäres Backup** (`/system/backup/save`/`load`, bestehend) | Kays Restore-Fallen (#44 Abbruch beim ersten fehlerhaften Befehl, #34.4 Klartext-Login im Skript, fehlender WLAN-Schlüssel im Export) betreffen den Export-Weg und treten beim binären Backup nicht auf. |
| Backup-Verschlüsselung | lokal AES-GCM | **RouterOS-eigene Verschlüsselung** (`password=` bei `save`/`load`) | Schützt die Datei auf dem Gerät *und* jede heruntergeladene Kopie, ohne eigene Krypto. |
| Live-Traffic | eingeführt, dann **wieder entfernt** (Router-Last, Mac-Gitea #31) | **verworfen** | #26 / M-Live-Traffic wird geschlossen, damit wir den Fehler nicht nachbauen. |
| Testumgebung | simulierter hAP lite in der Release-Version, abschaltbar | Demo-Client (3 Profile) **auch im Release**, abschaltbar in den Einstellungen | Bisher nur `#if DEBUG`. |
| Handbuch | Markdown + PDF, in der App | Begriffe-Glossar zentral + Handbuch als Markdown im Repo, in der App per Hilfe-Fenster | |
## Arbeitspakete (Reihenfolge = Umsetzungsreihenfolge)
### Phase 1 — Sicherheit & Robustheit (Lehren aus Kays Funden)
| # | Paket | Mac-Quelle | Umfang |
|---|---|---|---|
| P1 (#37) | **Isolierte Netze erreichen die Router-Verwaltung nicht mehr** — bei „Von Heimnetz isolieren" zusätzlich Input-Regeln: DNS/DHCP/ICMP zum Router erlauben, sonst alles aus dem VLAN zum Router verwerfen | Mac #41 (dort noch offen) | Core + Tests |
| P2 (#38) | **Isolation trennt auch bestehende Verbindungen** (Connection-Tracking-Einträge zwischen den Netzen entfernen) | Mac v1.1.0 / bugs #7 | Core + Tests |
| P3 (#39) | **Isolations-Regeln mit Kommentar** („Isolation vlan10 -> bridge"), ältere Regeln ohne Kommentar werden weiter erkannt | Mac v1.11.3 | Core + Tests |
| P4 (#40) | **Langsame Router:** REST-Zeitlimit pro Aufruf statt global 8 s; Backup/Restore/Update bekommen 120 s | Mac v1.3.0, v1.6.0 | Core |
| P5 (#41) | **Zugriffe auf die Verbindung strikt nacheinander** (binäre API ist nicht nebenläufig nutzbar) | Mac v1.5.0 (#30) | Core |
| P6 (#42) | **„Passwort merken"** beim Verbinden; aus → gespeichertes Passwort wird gelöscht. Fehler beim Speichern im Anmeldeinformationsspeicher wird angezeigt | Mac v1.11.4 | App |
| P42 (#70) | **Zertifikat bei verschlüsselter Verbindung prüfen (TOFU)** — selbstsigniertes Zertifikat bestätigen und merken, geändertes nie still akzeptieren (beim Abgleich nachträglich gefunden) | Mac bugs.md #9 | Core + App |
### Phase 2 — Wartung: Neustart, Wiederverbinden, Werksreset, Firmware (#28)
| # | Paket | Mac-Quelle |
|---|---|---|
| P7 (#43) | **Automatisches Wiederverbinden** nach Neustart/Verbindungsabbruch (Retry alle 5 s mit Zähler, auch wenn der Router schon annimmt, aber noch nicht antwortet) | Mac v1.6.0, v1.11.1 |
| P8 (#44) | **Neustart-Button** | Mac README |
| P9 (#28) | **Werksreset-Notfallschalter** mit vorheriger Sicherung und deutlicher Warnung | #28 |
| P10 (#28) | **Routerboard-Firmware-Update** (aktuelle/verfügbare Firmware anzeigen, `upgrade` + Neustart) | #28 |
### Phase 3 — Sicherungen (v1.3, #8, #9)
| # | Paket | Mac-Quelle |
|---|---|---|
| P11 (#45) | **Backup-Passwort** (Einstellungen) → verschlüsselte Backups; Hinweis, wenn unverschlüsselt; Schloss-Symbol in der Liste | Mac v1.6.0 |
| P12 (#8 **Backup auf den PC laden** per SFTP (SSH.NET) mit Hostkey-Bestätigung (TOFU), Speicherort wählbar | #8, #9, Mac v1.8.0 |
| P13 (#46) | **Manuelle Sicherung ersetzt 10 Minuten lang die automatische** vor der nächsten Änderung; Erfolgsmeldung | Mac v1.4.0 |
| P14 (#47) | **Sicherungen nach Tag gruppiert** | Mac v1.7.0 |
### Phase 4 — Einstellungen, Erscheinungsbild, Start
| # | Paket | Mac-Quelle |
|---|---|---|
| P15 (#48) | **Einstellungen-Fenster**: Erscheinungsbild System/Hell/Dunkel, Backup-Passwort, Backup-Ordner, Update-Prüfung beim Verbinden, „Professioneller Netzwerker", „Testumgebung", Einführung erneut starten | Mac v1.3.0, v1.4.0, v1.7.0, v1.8.0 |
| P16 (#49) | **Testumgebung im Release** (Demo-Client ohne `#if DEBUG`, Button „Testumgebung" im Verbindungs-Schritt, abschaltbar) | Mac v1.8.0 |
| P17 (#50) | **Start maximiert + Startbildschirm** | Mac v1.7.0, v1.8.0 |
| P18 (#51) | **Einführung beim ersten Start** (Erscheinungsbild, Experten-Funktionen, Update-Prüfung) | Mac v1.4.0 |
| P19 (#52) | **Design-Feinschliff**: Abschnitte als dezente Blöcke, Zebra-Listen, Port-Farben im LAN-Scanner | Mac v1.7.0 |
### Phase 5 — Verbinden & Geräte
| # | Paket | Mac-Quelle |
|---|---|---|
| P20 (#31) | **Benutzername/Passwort nebeneinander** | #31 |
| P21 (#53) | **Router im Netzwerk finden (MNDP)** — auch Router ohne passende IP; IP übernehmen, MAC kopieren, Hinweis auf WinBox per MAC | Mac v1.6.0 |
| P22 (#54) | **Interface-Liste mit Link-Status-Punkt** (grau/grün) | Mac v1.3.0 |
| P23 (#23) | **Netzwerk-Tools** im LAN-Scanner: Ping/Traceroute/DNS vom Router, lokaler TCP-Port-Scan (~19 Ports) | #23 |
| P24 (#55) | **LAN-Scanner: Gerätename aus dem Router-DNS** (`/ip/dns/static`, Lease-Hostname) | Mac v1.6.0 |
| P25 (#56) | **Easter Egg**: Seriennummer 5× anklicken | Mac #44 |
### Phase 6 — Einrichten-Assistent
| # | Paket | Mac-Quelle |
|---|---|---|
| P26 (#57) | **Lease-Zeit mit Jahre/Tage/Stunden** statt Freitext; keine Kürzung vorhandener Werte | Mac v1.11.0 |
| P27 (#58) | **Vorhandenes LAN vom Router vorladen** statt Standardwerten | Mac v1.3.0 |
| P28 (#59) | **Fortschrittsbalken beim Anwenden** mit aktuellem Schritt und Abschluss-Übersicht (auch Restore/Werksreset); Sicherung als eigener sichtbarer Schritt | Mac v1.4.0 |
| P29 (#60) | **Port-Konflikt-Prüfung** („Port freimachen?") wenn ein gewählter Port schon eine IP/DHCP hat | Mac v1.0–1.2 |
| P30 (#61) | **Review zeigt beteiligte Ports je Netz** | Mac v1.3.0 |
### Phase 7 — Experte-Bereich (#21) und Übersicht-Bearbeiten (#24)
| # | Paket | Mac-Quelle |
|---|---|---|
| P31 (#21) | **Experte-Bereich Grundgerüst**: generischer Menüpfad-Zugriff (auflisten, bearbeiten, anlegen, löschen), nur sichtbar mit „Professioneller Netzwerker"; dynamische/Standard-Einträge ohne Löschen, deaktivierte ausgegraut | #21, Mac v1.0, v1.6 |
| P32 (#62) | **Kuratierte Menüs** (Firewall/NAT/Mangle/Raw, Interfaces, IP, DHCP, DNS, WLAN, Queues, System, Werkzeuge) mit Feldhilfen | Mac `RouterOSSchemaCatalog` |
| P33 (#63) | **Werkzeug „Netzwerke isolieren"** | Mac v1.3.0 |
| P34 (#64) | **Werkzeug „Neues LAN anlegen"** (mit Bridge-/DHCP-Prüfung und Warnung bei der eigenen Verbindungsadresse) | Mac v1.3.0, #39 |
| P35 (#65) | **Werkzeug „Port in Bridge einfügen"** (auch WLAN) | Mac v1.4.0, v1.11.3 |
| P36 (#66) | **Pakete installieren/entfernen** (Speicherprüfung, WLAN-Treiber-Warnung, Neustart + Wiederverbinden) | Mac v1.11.0 |
| P37 (#67) | **Hotspot erklärt** + Hinweis „Paket installieren: hotspot…" bei fehlendem Bereich | Mac v1.11.0, v1.11.2 |
| P38 (#24) | **Übersicht: Knoten bearbeiten** über denselben Dialog | #24 |
### Phase 8 — Begriffe, Handbuch, Sprachen
| # | Paket | Mac-Quelle |
|---|---|---|
| P39 (#68) | **Begriffe-Glossar** zentral (ⓘ-Texte aus einer Quelle), Kapitel „Begriffe" | Mac v1.6.0 (#37) |
| P40 (#69) | **Handbuch** DE/EN inkl. „Router nicht erreichbar" (Rettung per WinBox/MAC) | Mac v1.6.0 |
| P41 (#29) | **Sprachen DE/EN/PT-BR** mit Umschalter | #29, Mac v1.9.0 |
## Hinweise zur Prüfung
- Alles läuft gegen den Demo-Client und Unit-Tests. Echtgerät-Prüfung bleibt in **v1.0** gebündelt (Testhardware).
- RouterOS-Eigenheiten, die Kay live gefunden hat und die für uns gelten, sind oben als eigene Pakete aufgenommen (P1–P5, P26).
+1 -32
View File
@@ -1,14 +1,11 @@
<Application x:Class="MikrotikInstaller.App.App"
<Application x:Class="MikrotikInstaller.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MikrotikInstaller.App"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:steps="clr-namespace:MikrotikInstaller.App.ViewModels.Steps"
xmlns:stepViews="clr-namespace:MikrotikInstaller.App.Views.Steps"
xmlns:vm="clr-namespace:MikrotikInstaller.App.ViewModels"
xmlns:views="clr-namespace:MikrotikInstaller.App.Views"
xmlns:converters="clr-namespace:MikrotikInstaller.App.Converters"
xmlns:viewControls="clr-namespace:MikrotikInstaller.App.Views.Controls"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
@@ -21,49 +18,21 @@
<converters:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<converters:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<converters:InverseBooleanConverter x:Key="InverseBooleanConverter" />
<converters:EnumToBooleanConverter x:Key="EnumToBooleanConverter" />
<converters:NullToBoolConverter x:Key="NullToBoolConverter" />
<converters:CountToVisibilityConverter x:Key="CountToVisibilityConverter" />
<!-- MikroTik-Markenfarbe (aus mikrotik.com), theme-unabhängig konstant. -->
<SolidColorBrush x:Key="MikrotikAccentBrush" Color="#009245" />
<SolidColorBrush x:Key="MikrotikAccentSoftBrush" Color="#1A009245" />
<SolidColorBrush x:Key="MikrotikRedBrush" Color="#E00000" />
<SolidColorBrush x:Key="MikrotikWarningBrush" Color="#C77700" />
<DataTemplate DataType="{x:Type steps:ConnectionStepViewModel}">
<stepViews:ConnectionStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:StartViewModel}">
<views:StartView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:SetupWizardViewModel}">
<views:SetupWizardView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:HelpViewModel}">
<views:HelpView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:SettingsViewModel}">
<views:SettingsView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:BackupManagementStepViewModel}">
<stepViews:BackupManagementStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:ExpertEditorViewModel}">
<viewControls:ExpertEditorView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:ExpertStepViewModel}">
<stepViews:ExpertStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:DeviceOverviewStepViewModel}">
<stepViews:DeviceOverviewStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:NetworkAnalysisStepViewModel}">
<stepViews:NetworkAnalysisStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:LanScanStepViewModel}">
<stepViews:LanScanStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:ModeSelectionStepViewModel}">
<stepViews:ModeSelectionStepView />
</DataTemplate>
+7 -26
View File
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
@@ -13,31 +12,13 @@ public partial class App : Application
protected override void OnStartup(StartupEventArgs e)
{
// Sprache vor dem Laden der ersten Oberfläche setzen; Datums-/Zahlenformate folgen ihr (Mac v1.6.0).
Core.Localization.L.Language = UserSettings.Current.Language;
System.Globalization.CultureInfo.DefaultThreadCurrentCulture = Core.Localization.L.Culture;
System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = Core.Localization.L.Culture;
System.Threading.Thread.CurrentThread.CurrentCulture = Core.Localization.L.Culture;
System.Threading.Thread.CurrentThread.CurrentUICulture = Core.Localization.L.Culture;
FrameworkElement.LanguageProperty.OverrideMetadata(
typeof(FrameworkElement),
new FrameworkPropertyMetadata(System.Windows.Markup.XmlLanguage.GetLanguage(Core.Localization.L.Culture.IetfLanguageTag)));
// Startbildschirm (Mac v1.8.0), mindestens kurz sichtbar statt nur ein Aufblitzen.
var splash = new SplashScreen(typeof(App).Assembly, "Assets/splash.png");
splash.Show(autoClose: false, topMost: true);
var splashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.5) };
splashTimer.Tick += (_, _) =>
{
splashTimer.Stop();
splash.Close(TimeSpan.FromMilliseconds(300));
};
splashTimer.Start();
base.OnStartup(e);
// Hell/Dunkel laut Einstellungen (Standard: wie Windows); die Akzentfarbe ist immer MikroTik-Grün.
UserSettings.ApplyTheme();
// Übernimmt Hell/Dunkel vom Windows-Systemthema; die Akzentfarbe setzen wir
// anschließend selbst auf MikroTik-Grün statt auf die Windows-Akzentfarbe.
ApplicationThemeManager.ApplySystemTheme(updateAccent: false);
var appliedTheme = ApplicationThemeManager.GetAppTheme();
ApplicationAccentColorManager.Apply(BrandAccent, appliedTheme, systemGlassColor: false, systemAccentColor: false);
DispatcherUnhandledException += OnDispatcherUnhandledException;
}
@@ -50,8 +31,8 @@ public partial class App : Application
private static void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
MessageBox.Show(
L.F("Es ist ein unerwarteter Fehler aufgetreten:\n\n{0}\n\nDu kannst versuchen, im Assistenten weiterzumachen. Tritt der Fehler erneut auf, starte den Assistenten bitte neu.", e.Exception.Message),
L.T("MikroTik Installer — Unerwarteter Fehler"),
$"Es ist ein unerwarteter Fehler aufgetreten:\n\n{e.Exception.Message}\n\nDu kannst versuchen, im Assistenten weiterzumachen. Tritt der Fehler erneut auf, starte den Assistenten bitte neu.",
"MikroTik Installer — Unerwarteter Fehler",
MessageBoxButton.OK,
MessageBoxImage.Warning);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -38,45 +38,12 @@ public sealed class StringToVisibilityConverter : IValueConverter
throw new NotSupportedException();
}
/// <summary>Sichtbar, wenn der gebundene Wert nicht null ist. Mit ConverterParameter="Invert" umgekehrt.</summary>
/// <summary>Sichtbar, wenn der gebundene Wert nicht null ist.</summary>
public sealed class NullToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
(value is null) == string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase) ? Visibility.Visible : Visibility.Collapsed;
value is null ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>true, wenn der gebundene Wert nicht null ist — z. B. um einen Button nur mit vorhandener Adresse zu aktivieren.</summary>
public sealed class NullToBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value is not null;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>Sichtbar, wenn die gebundene Anzahl größer als 0 ist.</summary>
public sealed class CountToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is int count && count > 0 ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>
/// Bindet ein <see cref="RadioButton.IsChecked"/> an einen bestimmten Wert eines Enums (ConverterParameter
/// als String, z. B. "ExpertMode"), damit sich mehr als zwei sich gegenseitig ausschließende Optionen über
/// eine einzige Enum-Property statt mehrerer verschränkter bool-Properties abbilden lassen.
/// </summary>
public sealed class EnumToBooleanConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is not null && parameter is string parameterString && string.Equals(value.ToString(), parameterString, StringComparison.Ordinal);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is true && parameter is string parameterString ? Enum.Parse(targetType, parameterString) : Binding.DoNothing;
}
@@ -1,43 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Windows;
using MikrotikInstaller.Core.Backup;
namespace MikrotikInstaller.App.Credentials;
/// <summary>
/// Trust On First Use für TLS-Zertifikate bei „Verschlüsselte Verbindung“ (Mac: Zertifikats-TOFU, bugs.md #9):
/// ein unbekanntes Zertifikat wird mit Fingerabdruck bestätigt und gemerkt, ein geändertes nie still akzeptiert.
/// Dieselbe Ablage wie die SSH-Schlüssel (known-hosts.json), mit Präfix „tls:“.
/// </summary>
internal static class CertificateTrust
{
public static Func<X509Certificate, bool> For(string host) => certificate =>
{
var fingerprint = $"SHA256:{Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData()))}";
var store = new KnownHostsStore();
var key = $"tls:{host}";
var known = store.Get(key);
if (known == fingerprint)
{
return true;
}
var accepted = Application.Current.Dispatcher.Invoke(() =>
{
var text = known is null
? L.F("Der Router {0} weist sich mit einem eigenen (selbstsignierten) Zertifikat aus.\n\nFingerabdruck:\n{1}\n\nDiesem Router vertrauen und das Zertifikat merken?", host, fingerprint)
: L.F("ACHTUNG: Das Zertifikat von {0} hat sich geändert!\n\nBisher: {1}\nJetzt: {2}\n\nDas passiert nach einem Werksreset oder neuen Zertifikat — kann aber auch bedeuten, dass sich jemand dazwischenschaltet. Nur fortfahren, wenn du dir sicher bist.", host, known, fingerprint);
return MessageBox.Show(text, L.T("Zertifikat bestätigen"), MessageBoxButton.YesNo,
known is null ? MessageBoxImage.Question : MessageBoxImage.Warning) == MessageBoxResult.Yes;
});
if (accepted)
{
store.Remember(key, fingerprint);
}
return accepted;
};
}
@@ -1,115 +0,0 @@
using System.Runtime.InteropServices;
using System.Text;
namespace MikrotikInstaller.App.Credentials;
/// <summary>
/// Speichert Passwörter für "Bekannte Router" im Windows-Anmeldeinformationsspeicher
/// (Systemsteuerung → Anmeldeinformationsverwaltung) statt im Klartext in
/// <c>SavedRoutersStore</c>s JSON-Datei — bewusst hier in App statt Core, da Core plattformneutral
/// bleiben soll (TargetFramework net8.0) und dies reine Windows-API (advapi32.dll) ist.
///
/// Rein lesende/aufräumende Vorgänge (siehe <see cref="TryRead"/>) geben bei Fehlern nur einen
/// leeren Zustand zurück statt zu werfen — "Bekannte Router" ist eine Komfortfunktion, ein
/// Anmeldeinformationsspeicher-Problem soll nie den Assistenten selbst zum Absturz bringen.
/// </summary>
internal static class WindowsCredentialStore
{
private const uint CredTypeGeneric = 1;
private const uint CredPersistLocalMachine = 2;
private const int ErrorNotFound = 1168;
/// <summary>Gibt false zurück, wenn Windows das Passwort nicht speichern konnte — der Aufrufer zeigt das an.</summary>
public static bool Save(string savedRouterId, string username, string password)
{
var passwordBytes = Encoding.Unicode.GetBytes(password);
var blob = Marshal.AllocHGlobal(Math.Max(passwordBytes.Length, 1));
try
{
if (passwordBytes.Length > 0)
{
Marshal.Copy(passwordBytes, 0, blob, passwordBytes.Length);
}
var credential = new Credential
{
Type = CredTypeGeneric,
TargetName = TargetName(savedRouterId),
CredentialBlobSize = (uint)passwordBytes.Length,
CredentialBlob = blob,
Persist = CredPersistLocalMachine,
UserName = username,
};
return CredWrite(ref credential, 0);
}
finally
{
Marshal.FreeHGlobal(blob);
}
}
public static string? TryRead(string savedRouterId)
{
if (!CredRead(TargetName(savedRouterId), CredTypeGeneric, 0, out var credentialPtr))
{
return null;
}
try
{
var credential = Marshal.PtrToStructure<Credential>(credentialPtr);
if (credential.CredentialBlobSize == 0 || credential.CredentialBlob == IntPtr.Zero)
{
return string.Empty;
}
var passwordBytes = new byte[credential.CredentialBlobSize];
Marshal.Copy(credential.CredentialBlob, passwordBytes, 0, passwordBytes.Length);
return Encoding.Unicode.GetString(passwordBytes);
}
finally
{
CredFree(credentialPtr);
}
}
public static void Delete(string savedRouterId)
{
if (!CredDelete(TargetName(savedRouterId), CredTypeGeneric, 0) && Marshal.GetLastWin32Error() != ErrorNotFound)
{
// Best effort — siehe Klassenkommentar.
}
}
private static string TargetName(string savedRouterId) => $"MikroTik-Installer:{savedRouterId}";
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct Credential
{
public uint Flags;
public uint Type;
public string TargetName;
public string? Comment;
public long LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public string? TargetAlias;
public string? UserName;
}
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredWrite(ref Credential credential, uint flags);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credentialPtr);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool CredFree(IntPtr credential);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredDelete(string target, uint type, uint flags);
}
@@ -1,16 +0,0 @@
using System.Windows.Markup;
using MikrotikInstaller.Core.Localization;
namespace MikrotikInstaller.App.Localization;
/// <summary>
/// Übersetzter Text in XAML: <c>Text="{l:T 'Verbindung testen'}"</c>. Wird beim Laden der Oberfläche
/// aufgelöst — ein Sprachwechsel wirkt deshalb nach einem Neustart der App.
/// </summary>
[MarkupExtensionReturnType(typeof(string))]
public sealed class TExtension(string german) : MarkupExtension
{
public string German { get; } = german;
public override object ProvideValue(IServiceProvider serviceProvider) => L.T(German);
}
+104 -149
View File
@@ -1,5 +1,4 @@
<ui:FluentWindow x:Class="MikrotikInstaller.App.MainWindow"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<ui:FluentWindow x:Class="MikrotikInstaller.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@@ -8,10 +7,9 @@
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:vm="clr-namespace:MikrotikInstaller.App.ViewModels"
mc:Ignorable="d"
Title="{l:T 'MikroTik Einrichtungs-Assistent'}" Height="680" Width="980"
Title="MikroTik Einrichtungs-Assistent" Height="680" Width="980"
MinHeight="560" MinWidth="820"
WindowStartupLocation="CenterScreen"
WindowState="Maximized"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
Icon="pack://application:,,,/Assets/app.ico">
@@ -20,49 +18,13 @@
<vm:MainViewModel />
</Window.DataContext>
<Window.Resources>
<!-- Ein Bereich der Seitenleiste: RadioButton an MainViewModel.Area gebunden, Tag = Symbol. -->
<Style x:Key="NavItem" TargetType="RadioButton">
<Setter Property="GroupName" Value="Area" />
<Setter Property="Margin" Value="0,0,0,2" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="Bg" Background="Transparent" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon x:Name="Icon" Symbol="{Binding Tag, RelativeSource={RelativeSource TemplatedParent}}"
FontSize="18" Margin="0,0,12,0" VerticalAlignment="Center" />
<ContentPresenter VerticalAlignment="Center" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bg" Property="Background" Value="{StaticResource MikrotikAccentSoftBrush}" />
<Setter TargetName="Icon" Property="Foreground" Value="{StaticResource MikrotikAccentBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="{l:T 'MikroTik Einrichtungs-Assistent'}">
<ui:TitleBar Grid.Row="0" Title="MikroTik Einrichtungs-Assistent">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="pack://application:,,,/Assets/app.ico" />
</ui:TitleBar.Icon>
@@ -74,130 +36,123 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Seitenleiste: Marke, Verbindung, Bereiche -->
<!-- Sidebar: Marke + Schritt-Übersicht -->
<Border Grid.Column="0" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,0,1,0">
<Grid Margin="20,24,16,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4,0,0,20">
<StackPanel Margin="20,24,16,24">
<StackPanel Orientation="Horizontal" Margin="4,0,0,28">
<Image Source="pack://application:,,,/Assets/app.ico" Width="22" Height="22"
Margin="0,0,10,0" VerticalAlignment="Center" />
<TextBlock Text="{l:T 'MikroTik Installer'}" FontWeight="SemiBold" FontSize="15" VerticalAlignment="Center" />
<TextBlock Text="MikroTik Installer" FontWeight="SemiBold" FontSize="15" VerticalAlignment="Center" />
</StackPanel>
<!-- Verbindungs-Karte: dauerhaft sichtbar, unabhängig vom aktuellen Bereich (Issue #36). -->
<Border Grid.Row="1" Background="{DynamicResource ControlFillColorSecondaryBrush}" CornerRadius="8" Padding="12" Margin="0,0,0,16">
<Grid>
<StackPanel Visibility="{Binding IsConnected, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<Border Width="8" Height="8" CornerRadius="4" Background="{StaticResource MikrotikAccentBrush}"
Margin="0,0,8,0" VerticalAlignment="Center" />
<TextBlock Text="{l:T 'Verbunden'}" FontSize="12" Opacity="0.7" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="{Binding ConnectedDeviceLabel}" FontWeight="SemiBold" TextWrapping="Wrap" Margin="0,0,0,10" />
<TextBlock Text="{Binding ConnectionState.StatusText}" FontSize="12" TextWrapping="Wrap" Margin="0,-4,0,10"
Foreground="{StaticResource MikrotikWarningBrush}"
Visibility="{Binding ConnectionState.StatusText, Converter={StaticResource StringToVisibilityConverter}}" />
<ui:Button Content="{l:T 'Wechseln'}" Appearance="Secondary" HorizontalAlignment="Stretch"
Command="{Binding SwitchConnectionCommand}" />
</StackPanel>
<StackPanel Visibility="{Binding IsConnected, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}">
<TextBlock Text="{l:T 'Nicht verbunden'}" FontSize="12" Opacity="0.7" Margin="0,0,0,8" />
<StackPanel Visibility="{Binding Connection.HasSavedRouters, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer MaxHeight="150" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Connection.SavedRouters}"
IsEnabled="{Binding Connection.IsTesting, Converter={StaticResource InverseBooleanConverter}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:Button Grid.Column="0" Appearance="Secondary" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Command="{Binding DataContext.QuickConnectCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}">
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="12" />
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.7" />
</StackPanel>
</ui:Button>
<ui:Button Grid.Column="1" Appearance="Secondary" Margin="4,0,0,0" Padding="6,0" ToolTip="{l:T 'Entfernen'}"
Command="{Binding DataContext.Connection.RemoveSavedRouterCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}">
<ui:SymbolIcon Symbol="Delete20" />
</ui:Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
<TextBlock Text="{l:T 'Rechts eine neue Verbindung einrichten.'}" FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Visibility="{Binding Connection.HasSavedRouters, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
</StackPanel>
</Grid>
</Border>
<!-- Bereiche: frei anklickbar, ohne Verbindung gesperrt -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
<StackPanel IsEnabled="{Binding IsConnected}">
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.Home24}" Content="{l:T 'Start'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Start}" />
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.Wand24}" Content="{l:T 'Einrichten'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Setup}" />
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.Organization24}" Content="{l:T 'Übersicht'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Overview}" />
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.Desktop24}" Content="{l:T 'Geräte'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Devices}" />
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.ArrowSync24}" Content="{l:T 'Backup &amp; Wartung'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Backup}" />
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.WrenchScrewdriver24}" Content="{l:T 'Experte'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Expert}"
Visibility="{Binding ShowExpert, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="3" Margin="0,12,0,0">
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.BookQuestionMark24}" Content="{l:T 'Hilfe &amp; Begriffe'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Help}" />
<RadioButton Style="{StaticResource NavItem}" Tag="{x:Static ui:SymbolRegular.Settings24}" Content="{l:T 'Einstellungen'}"
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Settings}" />
</StackPanel>
</Grid>
<ItemsControl ItemsSource="{Binding StepNavItems}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Padding="10,8" Margin="0,0,0,2" CornerRadius="6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter Property="Background" Value="{StaticResource MikrotikAccentSoftBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<Border Width="22" Height="22" CornerRadius="11" Margin="0,0,10,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<TextBlock Text="{Binding Number}" FontSize="11" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ui:SymbolIcon Symbol="Checkmark24" FontSize="12" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed">
<ui:SymbolIcon.Style>
<Style TargetType="ui:SymbolIcon">
<Style.Triggers>
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</ui:SymbolIcon.Style>
</ui:SymbolIcon>
</Grid>
</Border>
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="150">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
<Setter Property="FontWeight" Value="Normal" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Inhalt: aktueller Bereich -->
<!-- Inhalt: aktueller Schritt -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding PageTitle}" FontSize="26" FontWeight="SemiBold" Margin="36,28,36,0" />
<TextBlock Grid.Row="0" Text="{Binding CurrentStep.Title}" FontSize="26" FontWeight="SemiBold" Margin="36,28,36,0" />
<ScrollViewer Grid.Row="1" Padding="36,0,28,16">
<ScrollViewer.Style>
<Style TargetType="ScrollViewer" BasedOn="{StaticResource {x:Type ScrollViewer}}">
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Style.Triggers>
<DataTrigger Binding="{Binding PageScrolls}" Value="False">
<Setter Property="VerticalScrollBarVisibility" Value="Disabled" />
</DataTrigger>
</Style.Triggers>
</Style>
</ScrollViewer.Style>
<ContentControl Content="{Binding CurrentPage}" Margin="0,4,0,0" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="36,0,28,16">
<ContentControl Content="{Binding CurrentStep}" Margin="0,4,0,0" />
</ScrollViewer>
<Border Grid.Row="2" BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,1,0,0">
<Grid Margin="36,16,36,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:Button Grid.Column="1" Content="Zurück" Command="{Binding GoBackCommand}" Margin="0,0,8,0" />
<ui:Button Grid.Column="2" Content="Weiter" Appearance="Primary" Command="{Binding GoNextCommand}"
Visibility="{Binding IsLastStep, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
<ui:Button Grid.Column="2" Content="Beenden" Appearance="Primary" Command="{Binding FinishCommand}"
Visibility="{Binding IsLastStep, Converter={StaticResource BoolToVisibilityConverter}}" />
</Grid>
</Border>
</Grid>
</Grid>
</Grid>
+7 -36
View File
@@ -11,43 +11,14 @@ public partial class MainWindow : FluentWindow
InitializeComponent();
Closing += OnClosing;
// Folgt Hell/Dunkel-Wechseln des Windows-Systemthemes zur Laufzeit — nur bei "Wie System";
if (DataContext is MainViewModel viewModel)
{
viewModel.CloseRequested += (_, _) => Close();
}
// Folgt Hell/Dunkel-Wechseln des Windows-Systemthemes zur Laufzeit;
// updateAccents:false, damit unser MikroTik-Grün-Akzent dabei erhalten bleibt.
WatchSystemThemeIfWanted();
UserSettings.Changed += (_, _) => WatchSystemThemeIfWanted();
// Einführung beim ersten Start, bis "Fertig" oder "Überspringen" gedrückt wurde (Mac v1.4.0).
// ContentRendered statt Loaded: erst dann ist das Hauptfenster wirklich sichtbar, sonst bliebe es hinter dem modalen Dialog verborgen.
ContentRendered += (_, _) =>
{
if (!UserSettings.Current.IntroCompleted)
{
new Views.IntroWindow { Owner = this }.ShowDialog();
}
};
}
private bool _isWatchingSystemTheme;
private void WatchSystemThemeIfWanted()
{
var wanted = UserSettings.Current.Theme == Core.Settings.AppTheme.System;
if (wanted == _isWatchingSystemTheme)
{
return;
}
// UnWatch wirft, solange das Fenster nicht geladen ist — daher nur, wenn vorher wirklich beobachtet wurde.
if (wanted)
{
SystemThemeWatcher.Watch(this, WindowBackdropType.Mica, updateAccents: false);
}
else
{
SystemThemeWatcher.UnWatch(this);
}
_isWatchingSystemTheme = wanted;
SystemThemeWatcher.Watch(this, WindowBackdropType.Mica, updateAccents: false);
}
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MikrotikInstaller.Core\MikrotikInstaller.Core.csproj" />
@@ -8,7 +8,6 @@
die exe eingebettet wird und über pack://application:,,,/Assets/app.ico ansprechbar bleibt. -->
<ItemGroup>
<Resource Include="Assets\app.ico" />
<Resource Include="Assets\splash.png" />
</ItemGroup>
<ItemGroup>
-57
View File
@@ -1,57 +0,0 @@
using System.Windows;
using MikrotikInstaller.App.Credentials;
using MikrotikInstaller.Core.Settings;
using Wpf.Ui.Appearance;
namespace MikrotikInstaller.App;
/// <summary>Die geladenen Einstellungen für die ganze App, plus das Backup-Passwort aus dem Anmeldeinformationsspeicher.</summary>
public static class UserSettings
{
private const string BackupPasswordId = "backup-password";
private static readonly AppSettingsStore Store = new();
public static AppSettings Current { get; private set; } = Store.Load();
public static event EventHandler? Changed;
public static void Save(AppSettings settings)
{
Store.Save(settings);
Current = settings;
ApplyTheme();
Changed?.Invoke(null, EventArgs.Empty);
}
/// <summary>Leer/null = Backups unverschlüsselt.</summary>
public static string? BackupPassword
{
get => WindowsCredentialStore.TryRead(BackupPasswordId) is { Length: > 0 } password ? password : null;
}
/// <summary>Gibt false zurück, wenn Windows das Passwort nicht speichern konnte (Mac v1.11.4: dann nicht als gesetzt anzeigen).</summary>
public static bool SetBackupPassword(string? password)
{
if (string.IsNullOrEmpty(password))
{
WindowsCredentialStore.Delete(BackupPasswordId);
return true;
}
return WindowsCredentialStore.Save(BackupPasswordId, "backup", password);
}
public static void ApplyTheme()
{
if (Current.Theme == AppTheme.System)
{
ApplicationThemeManager.ApplySystemTheme(updateAccent: false);
}
else
{
ApplicationThemeManager.Apply(Current.Theme == AppTheme.Dark ? ApplicationTheme.Dark : ApplicationTheme.Light, updateAccent: false);
}
ApplicationAccentColorManager.Apply(App.BrandAccent, ApplicationThemeManager.GetAppTheme(), systemGlassColor: false, systemAccentColor: false);
}
}
@@ -1,18 +0,0 @@
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Frei anklickbare Bereiche der Seitenleiste (Bereichs-Navigation wie in der Mac-Version).</summary>
public enum AppArea
{
Start,
Setup,
Overview,
Devices,
Backup,
/// <summary>Nur mit „Professioneller Netzwerker“ sichtbar (Mac v1.3.0).</summary>
Expert,
/// <summary>Hilfe und Einstellungen gehen auch ohne Verbindung.</summary>
Help,
Settings,
}
@@ -1,14 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Beobachtbarer Zustand der Geräteverbindung für die Sidebar — v. a. ob gerade neu verbunden wird.</summary>
public partial class ConnectionState : ObservableObject
{
[ObservableProperty]
private bool _isReconnecting;
/// <summary>Hinweis unter „Verbunden", z. B. „Verbindung wird wiederhergestellt … (Versuch 3)".</summary>
[ObservableProperty]
private string? _statusText;
}
@@ -1,46 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Help;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Ein Begriff in der gewählten Sprache.</summary>
public sealed record GlossaryEntry(string Title, string Text);
/// <summary>Hilfe-Fenster (Mac v1.6.0): Handbuch und durchsuchbare Begriffe.</summary>
public partial class HelpViewModel : ObservableObject
{
private readonly IReadOnlyList<GlossaryEntry> _allTerms;
public HelpViewModel()
{
Manual = HelpContent.Manual(Language);
_allTerms = HelpContent.Glossary
.Select(t => new GlossaryEntry(t.Title(Language), t.Text(Language)))
.OrderBy(t => t.Title, StringComparer.CurrentCultureIgnoreCase)
.ToList();
Filter();
}
public static string Language => MikrotikInstaller.Core.Localization.L.Language;
public IReadOnlyList<HelpBlock> Manual { get; }
public ObservableCollection<GlossaryEntry> Terms { get; } = [];
[ObservableProperty]
private string _searchText = string.Empty;
partial void OnSearchTextChanged(string value) => Filter();
private void Filter()
{
Terms.Clear();
foreach (var term in _allTerms.Where(t => SearchText.Length == 0
|| t.Title.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase)
|| t.Text.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase)))
{
Terms.Add(term);
}
}
}
@@ -1,4 +0,0 @@
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Eine wählbare Sprache für Einstellungen und Einführung.</summary>
public sealed record LanguageOption(string Code, string Name);
@@ -1,214 +1,99 @@
using MikrotikInstaller.Core.Localization;
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.App.ViewModels.Steps;
using MikrotikInstaller.Core.Connectivity;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>
/// Bereichs-Navigation: Seitenleiste mit frei anklickbaren Bereichen. Ohne Verbindung zeigt der Inhalt
/// die Verbinden-Seite; nur Hilfe und Einstellungen gehen dann trotzdem.
/// Steuert die Schritt-für-Schritt-Navigation des Einrichtungs-Assistenten.
/// </summary>
public partial class MainViewModel : ObservableObject
{
private readonly WizardSession _session = new();
private readonly DispatcherTimer _heartbeat = new() { Interval = TimeSpan.FromSeconds(30) };
private readonly HelpViewModel _help = new();
private SettingsViewModel? _settings;
private Pages? _pages;
/// <summary>Je Verbindung neu angelegt, damit nichts vom vorherigen Gerät hängen bleibt.</summary>
private sealed record Pages(
StartViewModel Start,
SetupWizardViewModel Setup,
NetworkAnalysisStepViewModel Overview,
LanScanStepViewModel Devices,
BackupManagementStepViewModel Backup,
ExpertStepViewModel Expert);
public MainViewModel()
{
Connection = new ConnectionStepViewModel(_session);
Connection.PropertyChanged += OnConnectionChanged;
_ = Connection.OnActivatedAsync();
Steps =
[
new ConnectionStepViewModel(_session),
new DeviceOverviewStepViewModel(_session),
new NetworkAnalysisStepViewModel(_session),
new ModeSelectionStepViewModel(_session),
new WanStepViewModel(_session),
new LanStepViewModel(_session),
new VlanStepViewModel(_session),
new WlanStepViewModel(_session),
new FirewallStepViewModel(_session),
new SummaryStepViewModel(_session),
new UpdateStepViewModel(_session),
];
_session.ConnectionEnded += (_, _) =>
foreach (var step in Steps)
{
Connection.IsConnected = false;
OnPropertyChanged(nameof(ConnectedDeviceLabel));
};
UserSettings.Changed += (_, _) =>
{
OnPropertyChanged(nameof(ShowExpert));
if (Area == AppArea.Expert && !ShowExpert)
{
Area = AppArea.Start;
}
};
_heartbeat.Tick += async (_, _) => await CheckConnectionAsync();
_heartbeat.Start();
step.PropertyChanged += (_, _) => GoNextCommand.NotifyCanExecuteChanged();
}
CurrentStepIndex = 0;
}
public ConnectionState ConnectionState => _session.State;
public ConnectionStepViewModel Connection { get; }
public bool IsConnected => Connection.IsConnected && _pages is not null;
public bool ShowExpert => UserSettings.Current.ProfessionalMode;
/// <summary>Geräte-Modell, sobald bekannt, sonst nur der Host.</summary>
public string? ConnectedDeviceLabel => _session.Device is { } device ? $"{device.IdentityName} ({device.BoardModel})" : Connection.Host;
public ObservableCollection<WizardStepViewModelBase> Steps { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CurrentPage))]
[NotifyPropertyChangedFor(nameof(PageTitle))]
[NotifyPropertyChangedFor(nameof(PageScrolls))]
private AppArea _area = AppArea.Start;
[NotifyPropertyChangedFor(nameof(CurrentStep))]
[NotifyPropertyChangedFor(nameof(ProgressText))]
[NotifyPropertyChangedFor(nameof(IsFirstStep))]
[NotifyPropertyChangedFor(nameof(IsLastStep))]
[NotifyPropertyChangedFor(nameof(StepNavItems))]
[NotifyCanExecuteChangedFor(nameof(GoBackCommand))]
[NotifyCanExecuteChangedFor(nameof(GoNextCommand))]
private int _currentStepIndex;
public object CurrentPage => Area switch
public WizardStepViewModelBase CurrentStep => Steps[CurrentStepIndex];
public string ProgressText => $"Schritt {CurrentStepIndex + 1} von {Steps.Count}";
public IReadOnlyList<StepNavItem> StepNavItems => Steps
.Select((step, index) => new StepNavItem(index + 1, step.Title, index == CurrentStepIndex, index < CurrentStepIndex))
.ToList();
public bool IsFirstStep => CurrentStepIndex == 0;
public bool IsLastStep => CurrentStepIndex == Steps.Count - 1;
[RelayCommand(CanExecute = nameof(CanGoBack))]
private async Task GoBackAsync()
{
AppArea.Help => _help,
AppArea.Settings => _settings ??= new SettingsViewModel(),
_ when _pages is null => Connection,
AppArea.Setup => _pages.Setup,
AppArea.Overview => _pages.Overview,
AppArea.Devices => _pages.Devices,
AppArea.Backup => _pages.Backup,
AppArea.Expert => _pages.Expert,
_ => _pages.Start,
};
/// <summary>„Einrichten“ hat eine eigene Scroll-Fläche, damit Schrittleiste und Zurück/Weiter stehen bleiben.</summary>
public bool PageScrolls => CurrentPage is not SetupWizardViewModel;
public string PageTitle => Area switch
{
AppArea.Help => L.T("Hilfe & Begriffe"),
AppArea.Settings => L.T("Einstellungen"),
_ when _pages is null => L.T("Verbinden"),
AppArea.Setup => L.T("Einrichten"),
AppArea.Overview => L.T("Übersicht"),
AppArea.Backup => L.T("Backup & Wartung"),
AppArea.Expert => L.T("Experte"),
_ => ((WizardStepViewModelBase)CurrentPage).Title,
};
partial void OnAreaChanged(AppArea oldValue, AppArea newValue)
{
if (oldValue == AppArea.Settings)
do
{
_settings = null; // beim nächsten Öffnen frisch aus den gespeicherten Werten
CurrentStepIndex--;
}
while (CurrentStepIndex > 0 && CurrentStep.ShouldSkip);
if (CurrentPage is WizardStepViewModelBase page)
{
_ = page.OnActivatedAsync();
}
await CurrentStep.OnActivatedAsync();
}
private bool CanGoBack() => !IsFirstStep;
[RelayCommand(CanExecute = nameof(CanGoNext))]
private async Task GoNextAsync()
{
do
{
CurrentStepIndex++;
}
while (CurrentStepIndex < Steps.Count - 1 && CurrentStep.ShouldSkip);
await CurrentStep.OnActivatedAsync();
}
private bool CanGoNext() => !IsLastStep && CurrentStep.CanGoNext;
/// <summary>Wird ausgelöst, wenn der Nutzer auf dem letzten Schritt „Beenden" klickt.</summary>
public event EventHandler? CloseRequested;
[RelayCommand]
private void Navigate(AppArea area)
{
if (area == Area)
{
OnAreaChanged(area, area); // erneuter Klick lädt den Bereich neu
}
Area = area;
}
private void OnConnectionChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ConnectionStepViewModel.IsConnected))
{
return;
}
if (Connection.IsConnected)
{
_ = OnConnectedAsync();
}
else
{
_pages = null;
RefreshShell();
}
}
private async Task OnConnectedAsync()
{
_session.ResetPlan();
var device = new DeviceOverviewStepViewModel(_session);
device.PropertyChanged += (_, _) => OnPropertyChanged(nameof(ConnectedDeviceLabel));
// Liest Modell und Schnittstellen aus und trägt den Router in „Bekannte Router“ ein.
await device.OnActivatedAsync();
_pages = new Pages(
new StartViewModel(device, new UpdateStepViewModel(_session)),
new SetupWizardViewModel(_session),
new NetworkAnalysisStepViewModel(_session),
new LanScanStepViewModel(_session),
new BackupManagementStepViewModel(_session),
new ExpertStepViewModel(_session));
RefreshShell();
Navigate(AppArea.Start);
}
private void RefreshShell()
{
OnPropertyChanged(nameof(IsConnected));
OnPropertyChanged(nameof(ConnectedDeviceLabel));
OnPropertyChanged(nameof(CurrentPage));
OnPropertyChanged(nameof(PageTitle));
OnPropertyChanged(nameof(PageScrolls));
}
/// <summary>
/// Herzschlag alle 30 s, nur bei aktivem Fenster und ohne laufende längere Aktion (geringe Router-Last,
/// Mac v1.3.0): fällt die Verbindung weg, wird automatisch neu verbunden.
/// </summary>
private async Task CheckConnectionAsync()
{
if (!Connection.IsConnected || _session.IsDeviceBusy || _session.State.IsReconnecting
|| _session.Client is not { } client || Application.Current?.MainWindow?.IsActive != true)
{
return;
}
try
{
await client.GetAsync("/system/identity");
}
catch (RouterOsException ex) when (ex.IsConnectionLost)
{
await _session.ReconnectAsync(TimeSpan.Zero);
}
catch (RouterOsException)
{
// Das Gerät antwortet, lehnt nur ab — Verbindung besteht.
}
}
/// <summary>Ein-Klick-Verbinden aus der Seitenleiste; die Navigation zur Startseite übernimmt <see cref="OnConnectionChanged"/>.</summary>
[RelayCommand]
private Task QuickConnectAsync(SavedRouterEntry entry) => Connection.ConnectToSavedRouterCommand.ExecuteAsync(entry);
/// <summary>Trennt die aktuelle Verbindung und zeigt wieder die Verbinden-Seite.</summary>
[RelayCommand]
private async Task SwitchConnectionAsync()
{
await _session.DisposeClientAsync();
_session.Device = null;
Connection.IsConnected = false;
await Connection.OnActivatedAsync();
Area = AppArea.Start;
}
private void Finish() => CloseRequested?.Invoke(this, EventArgs.Empty);
/// <summary>Wird beim Schließen des Hauptfensters aufgerufen, um die Geräteverbindung sauber zu beenden.</summary>
public Task DisposeSessionAsync() => _session.DisposeClientAsync();
@@ -1,116 +0,0 @@
using MikrotikInstaller.Core.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using MikrotikInstaller.Core.Settings;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Bereich „Einstellungen“ (Mac: Einstellungen → Allgemein/Darstellung). Änderungen gelten erst mit „Speichern".</summary>
public partial class SettingsViewModel : ObservableObject
{
public SettingsViewModel()
{
var settings = UserSettings.Current;
_theme = settings.Theme;
_language = settings.Language;
_checkUpdatesOnConnect = settings.CheckUpdatesOnConnect;
_professionalMode = settings.ProfessionalMode;
_testEnvironment = settings.TestEnvironment;
_backupFolder = settings.EffectiveBackupFolder;
_hasBackupPassword = UserSettings.BackupPassword is not null;
}
[ObservableProperty]
private AppTheme _theme;
/// <summary>Wählbare Sprachen (Code, Anzeigename in der jeweiligen Sprache).</summary>
public IReadOnlyList<LanguageOption> Languages { get; } = L.Languages.Select(l => new LanguageOption(l.Code, l.Name)).ToList();
[ObservableProperty]
private string _language;
[ObservableProperty]
private bool _checkUpdatesOnConnect;
[ObservableProperty]
private bool _professionalMode;
[ObservableProperty]
private bool _testEnvironment;
[ObservableProperty]
private string _backupFolder;
/// <summary>Neues Backup-Passwort; leer lassen = unverändert.</summary>
[ObservableProperty]
private string _newBackupPassword = string.Empty;
[ObservableProperty]
private bool _hasBackupPassword;
[ObservableProperty]
private bool _removeBackupPassword;
[ObservableProperty]
private bool _restartIntro;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private string? _statusMessage;
[RelayCommand]
private void ChooseBackupFolder()
{
var dialog = new OpenFolderDialog { Title = L.T("Ordner für Backups wählen"), InitialDirectory = BackupFolder };
if (dialog.ShowDialog() == true)
{
BackupFolder = dialog.FolderName;
}
}
[RelayCommand]
private void Save()
{
ErrorMessage = null;
StatusMessage = null;
if (RemoveBackupPassword)
{
UserSettings.SetBackupPassword(null);
}
else if (NewBackupPassword.Length > 0 && !UserSettings.SetBackupPassword(NewBackupPassword))
{
ErrorMessage = L.T("Das Backup-Passwort konnte nicht gespeichert werden. Neue Backups bleiben deshalb unverändert.");
return;
}
var current = UserSettings.Current;
var languageChanged = Language != current.Language;
UserSettings.Save(current with
{
Language = Language,
Theme = Theme,
CheckUpdatesOnConnect = CheckUpdatesOnConnect,
ProfessionalMode = ProfessionalMode,
TestEnvironment = TestEnvironment,
BackupFolder = BackupFolder == new AppSettings().EffectiveBackupFolder ? null : BackupFolder,
IntroCompleted = current.IntroCompleted && !RestartIntro,
});
StatusMessage = L.T("Gespeichert.");
HasBackupPassword = UserSettings.BackupPassword is not null;
NewBackupPassword = string.Empty;
RemoveBackupPassword = false;
// Die Oberfläche wird beim Laden übersetzt — eine neue Sprache gilt nach einem Neustart vollständig.
if (languageChanged && System.Windows.MessageBox.Show(
L.T("Die Sprache wird nach einem Neustart der App übernommen. Jetzt neu starten?"),
L.T("Sprache"), System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Question) == System.Windows.MessageBoxResult.Yes
&& Environment.ProcessPath is { } exe)
{
System.Diagnostics.Process.Start(exe);
System.Windows.Application.Current.Shutdown();
}
}
}
@@ -1,83 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.App.ViewModels.Steps;
using MikrotikInstaller.Core.Localization;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>
/// Bereich „Einrichten“: der Assistent mit frei anklickbaren Schritten und eigener Zurück/Weiter-Leiste.
/// Die Zusammenfassung wendet nur an, was in besuchten Schritten gültig eingestellt wurde.
/// </summary>
public partial class SetupWizardViewModel : WizardStepViewModelBase
{
private readonly IReadOnlyList<WizardStepViewModelBase> _allSteps;
private readonly HashSet<WizardStepViewModelBase> _visited = [];
public SetupWizardViewModel(WizardSession session)
: base(L.T("Einrichten"), string.Empty)
{
_allSteps =
[
new ModeSelectionStepViewModel(session),
new WanStepViewModel(session),
new LanStepViewModel(session),
new VlanStepViewModel(session),
new WlanStepViewModel(session),
new FirewallStepViewModel(session),
new SummaryStepViewModel(session),
];
_currentStep = _allSteps[0];
foreach (var step in _allSteps)
{
step.PropertyChanged += (_, _) => GoNextCommand.NotifyCanExecuteChanged();
}
}
/// <summary>Ohne „Professioneller Netzwerker“ entfällt die Moduswahl, ohne WLAN-Schnittstelle der WLAN-Schritt.</summary>
public IReadOnlyList<WizardStepViewModelBase> Steps => _allSteps.Where(s => !s.ShouldSkip).ToList();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StepNavItems))]
[NotifyPropertyChangedFor(nameof(IsLastStep))]
[NotifyCanExecuteChangedFor(nameof(GoBackCommand))]
[NotifyCanExecuteChangedFor(nameof(GoNextCommand))]
private WizardStepViewModelBase _currentStep;
private int CurrentIndex => Steps.ToList().IndexOf(CurrentStep);
public IReadOnlyList<StepNavItem> StepNavItems => Steps
.Select((step, index) => new StepNavItem(index + 1, step.Title, step == CurrentStep, step != CurrentStep && _visited.Contains(step)))
.ToList();
public bool IsLastStep => CurrentIndex == Steps.Count - 1;
public override Task OnActivatedAsync()
{
// Wird ein Schritt zwischendurch unsichtbar (z. B. Moduswahl nach Abwahl von „Professioneller Netzwerker“), zurück zum ersten.
return GoToAsync(CurrentIndex < 0 ? Steps[0] : CurrentStep);
}
private async Task GoToAsync(WizardStepViewModelBase step)
{
CurrentStep = step;
_visited.Add(step);
OnPropertyChanged(nameof(StepNavItems));
await step.OnActivatedAsync();
}
/// <summary>Klick auf einen Schritt in der Leiste (Nummer ab 1).</summary>
[RelayCommand]
private Task GoToStepAsync(int number) => GoToAsync(Steps[number - 1]);
[RelayCommand(CanExecute = nameof(CanGoBack))]
private Task GoBackAsync() => GoToAsync(Steps[CurrentIndex - 1]);
private bool CanGoBack() => CurrentIndex > 0;
[RelayCommand(CanExecute = nameof(CanGoNextStep))]
private Task GoNextAsync() => GoToAsync(Steps[CurrentIndex + 1]);
private bool CanGoNextStep() => !IsLastStep && CurrentStep.CanGoNext;
}
@@ -1,16 +0,0 @@
using MikrotikInstaller.App.ViewModels.Steps;
using MikrotikInstaller.Core.Localization;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Startseite nach dem Verbinden: Schnellzugriffe, Gerätedaten und RouterOS-Update.</summary>
public sealed class StartViewModel(DeviceOverviewStepViewModel device, UpdateStepViewModel update)
: WizardStepViewModelBase(L.T("Start"), L.T("Dein Gerät auf einen Blick. Wähle links oder hier unten, was du tun möchtest."))
{
public DeviceOverviewStepViewModel Device { get; } = device;
public UpdateStepViewModel Update { get; } = update;
/// <summary>Das Gerät wird schon beim Verbinden ausgelesen, hier nur der Update-Stand aufgefrischt.</summary>
public override Task OnActivatedAsync() => Update.OnActivatedAsync();
}
@@ -1,455 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.IO;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Backup;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Connectivity.Demo;
using MikrotikInstaller.Core.Devices;
using MikrotikInstaller.Core.Maintenance;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Eigenständiger Bereich „Backup &amp; Wartung“ (auflisten, erstellen, wiederherstellen, Neustart, Firmware,
/// Werksreset). Liest das Gerät (Modell, Architektur, Seriennummer) beim Öffnen selbst neu aus, damit
/// die Angaben nach Neustart oder Restore stimmen. Rein informativ bis auf die Backup-Aktionen selbst — verändert sonst nichts am
/// Gerät und ist nicht Teil der vom Assistenten geplanten Soll-Konfiguration.
/// </summary>
public partial class BackupManagementStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
public BackupManagementStepViewModel(WizardSession session)
: base(
title: L.T("Backup & Wartung"),
description: L.T("Backups auflisten, erstellen oder zurückspielen — und das Gerät neu starten, die Firmware aktualisieren oder auf Werkseinstellungen zurücksetzen."))
{
_session = session;
}
public ObservableCollection<BackupSelectionItemViewModel> AvailableBackups { get; } = [];
[ObservableProperty]
private bool _isLoadingDevice;
[ObservableProperty]
private bool _isLoadingBackups;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private RouterDevice? _device;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApplyRestoreCommand))]
[NotifyCanExecuteChangedFor(nameof(DownloadBackupCommand))]
private BackupSelectionItemViewModel? _selectedBackup;
/// <summary>Pfad der zuletzt auf den PC geladenen Sicherung (für „Ordner öffnen“).</summary>
[ObservableProperty]
private string? _downloadedPath;
private bool CanDownloadBackup => !IsRestoring && SelectedBackup is not null && _session.Credentials is not null;
/// <summary>
/// Lädt das ausgewählte Backup per SFTP in den Backup-Ordner aus den Einstellungen (v1.3, #8/#9).
/// Ein unbekannter oder geänderter SSH-Schlüssel muss bestätigt werden (TOFU).
/// </summary>
[RelayCommand(CanExecute = nameof(CanDownloadBackup))]
private async Task DownloadBackupAsync()
{
if (SelectedBackup is not { } backup || _session.Credentials is not { } credentials)
{
return;
}
SetBusy(true);
ErrorMessage = null;
DownloadedPath = null;
try
{
RestoreProgressText = L.F("„{0}“ wird auf diesen PC geladen …", backup.FileName);
var folder = UserSettings.Current.EffectiveBackupFolder;
DownloadedPath = _session.Client is DemoRouterOsClient
? await WriteDemoBackupAsync(folder, backup.FileName)
: await BackupDownloadService.DownloadAsync(credentials, backup.FileName, folder, new KnownHostsStore(), ConfirmHostKey);
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
catch (IOException ex)
{
ErrorMessage = L.F("Die Datei konnte nicht gespeichert werden: {0}", ex.Message);
}
finally
{
RestoreProgressText = null;
SetBusy(false);
}
}
[RelayCommand]
private void OpenDownloadFolder()
{
if (DownloadedPath is { } path)
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
}
}
/// <summary>Testumgebung: kein SSH — eine Platzhalter-Datei zeigt den Ablauf.</summary>
private static async Task<string> WriteDemoBackupAsync(string folder, string fileName)
{
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, fileName);
await File.WriteAllTextAsync(path, L.T("Testumgebung: simuliertes Backup, kein echter Router-Inhalt."));
return path;
}
/// <summary>SSH.NET fragt auf einem Hintergrund-Thread — die Rückfrage muss auf den UI-Thread.</summary>
private static bool ConfirmHostKey(HostKeyCheck check) =>
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var text = check.PreviousFingerprint is null
? L.F("Erste SSH-Verbindung zu {0}.\n\nSchlüssel-Fingerabdruck:\n{1}\n\nDiesem Router vertrauen und den Schlüssel merken?", check.Host, check.Fingerprint)
: L.F("ACHTUNG: Der SSH-Schlüssel von {0} hat sich geändert!\n\nBisher: {1}\nJetzt: {2}\n\nDas passiert nach einem Werksreset oder Gerätetausch — kann aber auch bedeuten, dass sich jemand dazwischenschaltet. Nur fortfahren, wenn du dir sicher bist.", check.Host, check.PreviousFingerprint, check.Fingerprint);
return System.Windows.MessageBox.Show(text, L.T("SSH-Schlüssel bestätigen"), System.Windows.MessageBoxButton.YesNo,
check.PreviousFingerprint is null ? System.Windows.MessageBoxImage.Question : System.Windows.MessageBoxImage.Warning)
== System.Windows.MessageBoxResult.Yes;
});
/// <summary>Nur relevant, wenn <see cref="BackupSelectionItemViewModel.RequiresConfirmation"/> beim ausgewählten Backup gesetzt ist.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApplyRestoreCommand))]
private bool _confirmRiskyBackup;
[ObservableProperty]
private string? _createBackupMessage;
[ObservableProperty]
private bool _isRestoring;
[ObservableProperty]
private string? _restoreProgressText;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMainContent))]
private bool _restoreFinished;
public bool ShowMainContent => !RestoreFinished && !FactoryResetDone;
/// <summary>RouterBOARD-Firmware (null bei Geräten ohne RouterBOARD oder wenn nicht lesbar).</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(UpgradeFirmwareCommand))]
private FirmwareInfo? _firmware;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(FactoryResetCommand))]
private bool _confirmFactoryReset;
[ObservableProperty]
private string? _maintenanceMessage;
/// <summary>Nach dem Werksreset ist die Verbindung bewusst beendet — der Bereich zeigt nur noch den Abschluss-Hinweis.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMainContent))]
private bool _factoryResetDone;
public override bool CanGoNext => true;
/// <summary>Hinweis wie in der Mac-Version, solange Backups unverschlüsselt entstehen.</summary>
public bool IsBackupUnencrypted => UserSettings.BackupPassword is null;
public override async Task OnActivatedAsync()
{
if (_session.Client is null)
{
ErrorMessage = L.T("Keine Verbindung zum Gerät.");
return;
}
ErrorMessage = null;
CreateBackupMessage = null;
RestoreFinished = false;
FactoryResetDone = false;
OnPropertyChanged(nameof(IsBackupUnencrypted));
IsLoadingDevice = true;
try
{
// Bewusst unabhängig von DeviceOverviewStepViewModel (der in diesem Bereich übersprungen
// wird) — dieser Schritt muss auch direkt nach der Verbindung, ohne Umweg, funktionieren.
Device = await DeviceDiscoveryService.DiscoverAsync(_session.Client);
_session.Device = Device;
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
IsLoadingDevice = false;
return;
}
IsLoadingDevice = false;
Firmware = await TryReadFirmwareAsync(_session.Client);
await LoadBackupsAsync();
}
private static async Task<FirmwareInfo?> TryReadFirmwareAsync(IRouterOsClient client)
{
try
{
var firmware = await RouterMaintenanceService.ReadFirmwareAsync(client);
return firmware.IsRouterBoard ? firmware : null;
}
catch (RouterOsException)
{
return null;
}
}
private bool CanRunMaintenance => !IsRestoring && _session.Client is not null;
[RelayCommand(CanExecute = nameof(CanRunMaintenance))]
private Task RebootAsync() => RunWithReconnectAsync(
L.T("Das Gerät startet neu …"),
client => RouterMaintenanceService.RebootAsync(client),
L.T("Das Gerät wurde neu gestartet und ist wieder verbunden."));
private bool CanUpgradeFirmware => CanRunMaintenance && Firmware?.IsUpgradeAvailable == true;
/// <summary>Firmware einspielen und neu starten — sie wird erst nach dem Neustart aktiv.</summary>
[RelayCommand(CanExecute = nameof(CanUpgradeFirmware))]
private Task UpgradeFirmwareAsync() => RunWithReconnectAsync(
L.T("Firmware wird eingespielt, danach startet das Gerät neu …"),
async client =>
{
await RouterMaintenanceService.UpgradeFirmwareAsync(client);
await RouterMaintenanceService.RebootAsync(client);
},
L.T("Die Firmware wurde aktualisiert, das Gerät ist wieder verbunden."));
private bool CanFactoryReset => CanRunMaintenance && ConfirmFactoryReset;
/// <summary>
/// Sichert zuerst den aktuellen Zustand (zusätzlich legt RouterOS selbst eins an), setzt dann zurück
/// und beendet die Verbindung: danach gelten Standardadresse und Standard-Zugangsdaten, ein
/// automatisches Wiederverbinden mit den alten Daten würde ins Leere laufen.
/// </summary>
[RelayCommand(CanExecute = nameof(CanFactoryReset))]
private async Task FactoryResetAsync()
{
if (_session.Client is not { } client)
{
return;
}
SetBusy(true);
try
{
RestoreProgressText = L.T("Sicherung des aktuellen Zustands wird erstellt …");
await _session.CreateSafetyBackupAsync(client);
RestoreProgressText = L.T("Gerät wird auf Werkseinstellungen zurückgesetzt …");
await RouterMaintenanceService.FactoryResetAsync(client);
await _session.EndConnectionAsync();
FactoryResetDone = true;
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
finally
{
RestoreProgressText = null;
ConfirmFactoryReset = false;
SetBusy(false);
}
}
/// <summary>Führt eine Aktion aus, nach der das Gerät neu startet, verbindet danach selbst wieder und liest alles neu ein.</summary>
private async Task RunWithReconnectAsync(string progressText, Func<IRouterOsClient, Task> action, string successMessage)
{
if (_session.Client is not { } client)
{
return;
}
SetBusy(true);
ErrorMessage = null;
MaintenanceMessage = null;
try
{
RestoreProgressText = progressText;
await action(client);
RestoreProgressText = L.T("Warte, bis das Gerät wieder erreichbar ist …");
if (await _session.ReconnectAsync(TimeSpan.FromSeconds(15)))
{
SetBusy(false);
await OnActivatedAsync();
MaintenanceMessage = successMessage;
}
else
{
ErrorMessage = _session.State.StatusText;
}
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
finally
{
RestoreProgressText = null;
SetBusy(false);
}
}
private void SetBusy(bool busy)
{
IsRestoring = busy;
_session.IsDeviceBusy = busy;
CreateBackupNowCommand.NotifyCanExecuteChanged();
RebootCommand.NotifyCanExecuteChanged();
UpgradeFirmwareCommand.NotifyCanExecuteChanged();
FactoryResetCommand.NotifyCanExecuteChanged();
ApplyRestoreCommand.NotifyCanExecuteChanged();
DownloadBackupCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private Task Refresh() => LoadBackupsAsync();
private async Task LoadBackupsAsync()
{
if (_session.Client is null)
{
return;
}
AvailableBackups.Clear();
SelectedBackup = null;
ConfirmRiskyBackup = false;
IsLoadingBackups = true;
try
{
var backups = await BackupRestoreService.ListBackupsAsync(_session.Client);
foreach (var backup in backups)
{
AvailableBackups.Add(new BackupSelectionItemViewModel(backup, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber));
}
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsLoadingBackups = false;
CreateBackupNowCommand.NotifyCanExecuteChanged();
}
}
private bool CanCreateBackupNow => !IsLoadingBackups && !IsRestoring && _session.Client is not null;
[RelayCommand(CanExecute = nameof(CanCreateBackupNow))]
private async Task CreateBackupNowAsync()
{
if (_session.Client is null)
{
return;
}
SetBusy(true);
ErrorMessage = null;
CreateBackupMessage = null;
try
{
RestoreProgressText = L.T("Backup wird erstellt …");
var name = await RouterBackupService.CreateBackupAsync(
_session.Client, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber, UserSettings.BackupPassword);
_session.LastManualBackup = (name, DateTimeOffset.Now);
RestoreProgressText = null;
CreateBackupMessage = L.F("Backup „{0}“ wurde erstellt. Es ersetzt in den nächsten 10 Minuten die automatische Sicherung vor einer Änderung.", name);
// Frisch erstelltes Backup soll sofort in der Liste auftauchen.
await LoadBackupsAsync();
}
catch (RouterOsException ex)
{
RestoreProgressText = null;
ErrorMessage = ex.Message;
}
finally
{
SetBusy(false);
}
}
private bool CanApplyRestore =>
!IsRestoring && SelectedBackup is not null
&& (SelectedBackup.Matches || (SelectedBackup.RequiresConfirmation && ConfirmRiskyBackup));
/// <summary>
/// Erstellt zuerst eine Sicherung des aktuellen Zustands (falls das Zurückspielen unerwünscht war,
/// lässt sich so auch das noch rückgängig machen), spielt danach das ausgewählte Backup zurück.
/// Das Gerät startet dabei automatisch neu; die laufende Verbindung geht dabei ggf. verloren, ohne
/// dass das ein Fehler wäre.
/// </summary>
[RelayCommand(CanExecute = nameof(CanApplyRestore))]
private async Task ApplyRestoreAsync()
{
if (_session.Client is null || SelectedBackup is null)
{
return;
}
SetBusy(true);
ErrorMessage = null;
MaintenanceMessage = null;
try
{
RestoreProgressText = L.T("Sicherung des aktuellen Zustands wird erstellt …");
await _session.CreateSafetyBackupAsync(_session.Client);
RestoreProgressText = L.F("„{0}\" wird zurückgespielt — das Gerät startet danach neu …", SelectedBackup.FileName);
await BackupRestoreService.RestoreAsync(
_session.Client, SelectedBackup.Backup, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber, ConfirmRiskyBackup,
SelectedBackup.Backup.IsEncrypted ? UserSettings.BackupPassword : null);
RestoreFinished = true;
// Gerät startet nach dem Zurückspielen neu — danach selbst wieder verbinden (Mac v1.6.0).
RestoreProgressText = L.T("Warte, bis das Gerät nach dem Neustart wieder erreichbar ist …");
if (await _session.ReconnectAsync(TimeSpan.FromSeconds(20)))
{
SetBusy(false);
await OnActivatedAsync();
MaintenanceMessage = L.T("Das Backup wurde zurückgespielt, das Gerät ist wieder verbunden.");
}
RestoreProgressText = null;
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
catch (InvalidOperationException ex)
{
ErrorMessage = ex.Message;
}
finally
{
RestoreProgressText = null;
SetBusy(false);
}
}
}
@@ -1,48 +0,0 @@
using MikrotikInstaller.Core.Localization;
using MikrotikInstaller.Core.Backup;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Ein auswählbares Backup in der Backup-Verwaltung, mit Kompatibilitäts-Anzeige.</summary>
public sealed class BackupSelectionItemViewModel
{
public BackupSelectionItemViewModel(BackupFileInfo backup, string? currentBoardModel, string? currentArchitecture, string? currentSerialNumber)
{
Backup = backup;
Compatibility = BackupRestoreService.CheckCompatibility(backup, currentBoardModel, currentArchitecture, currentSerialNumber);
}
public BackupFileInfo Backup { get; }
public string FileName => Backup.FileName;
/// <summary>Lokale Zeit, "?" falls das Gerät kein auswertbares Erstellungsdatum geliefert hat.</summary>
public string CreationTimeLabel => Backup.CreationTime?.LocalDateTime.ToString("g", L.Culture) ?? "?";
/// <summary>Gruppierung der Liste nach Tag (Mac v1.7.0).</summary>
public string DayLabel => Backup.CreationTime?.LocalDateTime.ToString("D", L.Culture) ?? L.T("Ohne Datum");
public bool IsEncrypted => Backup.IsEncrypted;
public BackupCompatibility Compatibility { get; }
public bool Matches => Compatibility == BackupCompatibility.Match;
/// <summary>Gleiches Modell + Architektur, aber bekannt andere Seriennummer — auf eigene Gefahr wiederherstellbar.</summary>
public bool IsSameModelDifferentDevice => Compatibility == BackupCompatibility.SameModelDifferentDevice;
public bool IsUnknown => Compatibility == BackupCompatibility.Unknown;
public bool IsBlocked => Compatibility == BackupCompatibility.Mismatch;
/// <summary>Sowohl "baugleich, aber anderes Gerät" als auch "gar nicht prüfbar" brauchen die Bestätigungs-Checkbox.</summary>
public bool RequiresConfirmation => IsSameModelDifferentDevice || IsUnknown;
public string StatusLabel => Compatibility switch
{
BackupCompatibility.Match => L.T("Passt zu diesem Gerät"),
BackupCompatibility.SameModelDifferentDevice => L.T("Gleiches Modell, anderes physisches Gerät — auf eigene Gefahr wiederherstellbar"),
BackupCompatibility.Mismatch => L.F("Anderes Gerät (Modell „{0}“) — kann nicht wiederhergestellt werden", Backup.Model),
_ => L.T("Gerät nicht prüfbar — muss manuell bestätigt werden"),
};
}
@@ -1,103 +1,23 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.App.Credentials;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Connectivity.Demo;
using MikrotikInstaller.Core.Devices;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Erster Schritt: Verbindungsdaten zum MikroTik-Gerät erfassen und testen — nur noch für neue,
/// unbekannte Geräte gedacht. "Bekannte Router" leben seit dem Sidebar-Umbau (MainWindow.xaml)
/// dauerhaft dort als Kurzauswahl statt hier im Hauptfenster verdoppelt zu werden; dieses ViewModel
/// hält <see cref="SavedRouters"/> aber weiterhin (die Sidebar bindet direkt über
/// <c>MainViewModel.Connection</c> darauf) und stellt <see cref="ConnectToSavedRouterCommand"/>/
/// <see cref="RemoveSavedRouterCommand"/> dafür bereit. Ein Eintrag wird erst im nächsten Schritt
/// (Geräte-Erkennung) tatsächlich gespeichert/aktualisiert — dort sind Modell und Seriennummer für
/// den Namensvorschlag bzw. die Unterscheidung zweier Geräte mit gleichem Host+Benutzername bekannt.
/// Erster Schritt: Verbindungsdaten zum MikroTik-Gerät erfassen und testen.
/// Erst nach einer erfolgreich getesteten Verbindung darf im Assistenten weitergegangen werden.
/// </summary>
public partial class ConnectionStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
private readonly SavedRoutersStore _savedRoutersStore = new();
public ConnectionStepViewModel(WizardSession session)
: base(
title: L.T("Verbindung"),
description: L.T("Gib die Adresse deines MikroTik-Geräts sowie Benutzername und Passwort ein."))
title: "Verbindung",
description: "Gib die Adresse deines MikroTik-Geräts sowie Benutzername und Passwort ein.")
{
_session = session;
RefreshSavedRouters();
}
public ObservableCollection<SavedRouterEntry> SavedRouters { get; } = [];
public bool HasSavedRouters => SavedRouters.Count > 0;
/// <summary>Simulierte Testgeräte (Mac v1.8.0 „Testumgebung"), abschaltbar in den Einstellungen.</summary>
public IReadOnlyList<DemoDeviceProfile> TestDevices => DemoRouterOsClient.Profiles;
public bool ShowTestEnvironment => UserSettings.Current.TestEnvironment;
/// <summary>Per MNDP gefundene Router (wie „Neighbors" in WinBox), Mac v1.6.0.</summary>
public ObservableCollection<MndpNeighbor> Neighbors { get; } = [];
[ObservableProperty]
private bool _isSearchingNeighbors;
[ObservableProperty]
private string? _neighborSearchMessage;
[RelayCommand]
private async Task SearchNeighborsAsync()
{
IsSearchingNeighbors = true;
NeighborSearchMessage = null;
Neighbors.Clear();
try
{
foreach (var neighbor in await MndpDiscovery.DiscoverAsync(TimeSpan.FromSeconds(4)))
{
Neighbors.Add(neighbor);
}
NeighborSearchMessage = Neighbors.Count == 0
? L.T("Kein MikroTik-Router gefunden. Ist der PC per Kabel oder WLAN mit dem Router verbunden?")
: null;
}
catch (System.Net.Sockets.SocketException)
{
NeighborSearchMessage = L.T("Die Router-Suche ist gerade nicht möglich. Läuft auf diesem PC ein anderes Programm, das Port 5678 belegt?");
}
finally
{
IsSearchingNeighbors = false;
}
}
[RelayCommand]
private void UseNeighbor(MndpNeighbor neighbor)
{
if (neighbor.IPv4Address is { } address)
{
Host = address;
}
}
[RelayCommand]
private void CopyMac(MndpNeighbor neighbor) => System.Windows.Clipboard.SetText(neighbor.MacAddress);
[RelayCommand]
private async Task ConnectToTestDeviceAsync(DemoDeviceProfile profile)
{
Host = profile.Host;
Username = DemoRouterOsClient.DemoUsername;
Password = DemoRouterOsClient.DemoPassword;
UseEncryption = false;
await TestConnectionAsync();
}
[ObservableProperty]
@@ -112,9 +32,6 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private bool _useEncryption;
[ObservableProperty]
private bool _rememberPassword = true;
[ObservableProperty]
private bool _isTesting;
@@ -132,58 +49,11 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
private bool CanTestConnection => !IsTesting && !string.IsNullOrWhiteSpace(Host) && !string.IsNullOrWhiteSpace(Username);
public override Task OnActivatedAsync()
{
RefreshSavedRouters();
OnPropertyChanged(nameof(ShowTestEnvironment));
return Task.CompletedTask;
}
private void RefreshSavedRouters()
{
SavedRouters.Clear();
var routers = _savedRoutersStore.Load().OrderByDescending(r => r.LastConnectedAt);
foreach (var router in routers)
{
SavedRouters.Add(new SavedRouterEntry(router));
}
OnPropertyChanged(nameof(HasSavedRouters));
}
/// <summary>Nur noch intern von <see cref="ConnectToSavedRouterAsync"/> genutzt — Name/Standort werden nirgends mehr manuell bearbeitet, daher kein eigenes Kommando mehr dafür.</summary>
private void UseSavedRouter(SavedRouterEntry entry)
{
Host = entry.Host;
Username = entry.Username;
var storedPassword = WindowsCredentialStore.TryRead(entry.Id);
Password = storedPassword ?? string.Empty;
RememberPassword = storedPassword is not null;
}
/// <summary>Ein-Klick-Verbinden für die Sidebar-Kurzauswahl — übernimmt die Zugangsdaten und verbindet direkt, ohne den Umweg über die Formularfelder.</summary>
[RelayCommand]
private async Task ConnectToSavedRouterAsync(SavedRouterEntry entry)
{
UseSavedRouter(entry);
await TestConnectionAsync();
}
[RelayCommand]
private void RemoveSavedRouter(SavedRouterEntry entry)
{
_savedRoutersStore.Remove(entry.Id);
WindowsCredentialStore.Delete(entry.Id);
SavedRouters.Remove(entry);
OnPropertyChanged(nameof(HasSavedRouters));
}
[RelayCommand(CanExecute = nameof(CanTestConnection))]
private async Task TestConnectionAsync()
{
IsTesting = true;
StatusMessage = L.T("Verbindung wird getestet …");
StatusMessage = "Verbindung wird getestet …";
StatusIsError = false;
IsConnected = false;
@@ -195,16 +65,13 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
var credentials = new RouterOsCredentials(Host.Trim(), Username.Trim(), Password)
{
UseEncryption = UseEncryption,
TrustCertificate = CertificateTrust.For(Host.Trim()),
};
_session.Client = await RouterOsClientFactory.ConnectAsync(credentials);
_session.Credentials = credentials;
_session.RememberPassword = RememberPassword;
IsConnected = true;
StatusIsError = false;
StatusMessage = L.T("Verbindung erfolgreich hergestellt.");
StatusMessage = "Verbindung erfolgreich hergestellt.";
}
catch (RouterOsException ex)
{
@@ -1,7 +1,5 @@
using MikrotikInstaller.Core.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.App.Credentials;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Devices;
using MikrotikInstaller.Core.Updates;
@@ -15,12 +13,11 @@ namespace MikrotikInstaller.App.ViewModels.Steps;
public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
private readonly SavedRoutersStore _savedRoutersStore = new();
public DeviceOverviewStepViewModel(WizardSession session)
: base(
title: L.T("Geräte-Erkennung"),
description: L.T("Wir lesen Modell, RouterOS-Version und Netzwerkschnittstellen deines Geräts aus."))
title: "Geräte-Erkennung",
description: "Wir lesen Modell, RouterOS-Version und Netzwerkschnittstellen deines Geräts aus.")
{
_session = session;
}
@@ -31,10 +28,6 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private string? _errorMessage;
/// <summary>Nicht blockierend: Windows konnte das Passwort nicht im Anmeldeinformationsspeicher ablegen.</summary>
[ObservableProperty]
private string? _credentialWarning;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGoNext))]
[NotifyPropertyChangedFor(nameof(Interfaces))]
@@ -42,31 +35,6 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
public override bool CanGoNext => Device is not null;
private readonly List<DateTimeOffset> _serialClicks = [];
/// <summary>Easter Egg wie in der Mac-Version (found.md #36): fünf schnelle Klicks auf die Seriennummer.</summary>
[ObservableProperty]
private bool _showCake;
/// <summary>Zählt Klicks; eine Pause über 1,5 s startet die Zählung neu.</summary>
[RelayCommand]
private void SerialNumberClicked()
{
var now = DateTimeOffset.Now;
if (_serialClicks.Count > 0 && now - _serialClicks[^1] > TimeSpan.FromSeconds(1.5))
{
_serialClicks.Clear();
}
_serialClicks.Add(now);
if (_serialClicks.Count >= 5)
{
_serialClicks.Clear();
ShowCake = true;
}
}
public IReadOnlyList<InterfaceDisplayItem> Interfaces =>
Device?.Interfaces.Select(InterfaceDisplayItem.From).ToList() ?? [];
@@ -79,7 +47,7 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
{
if (_session.Client is null)
{
ErrorMessage = L.T("Es besteht keine Verbindung zum Gerät. Bitte gehe einen Schritt zurück.");
ErrorMessage = "Es besteht keine Verbindung zum Gerät. Bitte gehe einen Schritt zurück.";
Device = null;
return;
}
@@ -92,15 +60,11 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
var device = await DeviceDiscoveryService.DiscoverAsync(_session.Client);
Device = device;
_session.Device = device;
RecordSavedRouter(device);
// Läuft im Hintergrund weiter, ohne die Navigation zu blockieren — ein frisches Gerät
// hat an dieser Stelle im Assistenten meist noch keinen Internetzugang, die Prüfung
// schlägt dann einfach fehl (kein Fehlerzustand) und wird nach dem Anwenden wiederholt.
if (UserSettings.Current.CheckUpdatesOnConnect)
{
_ = CheckForUpdatesInBackgroundAsync(_session.Client);
}
_ = CheckForUpdatesInBackgroundAsync(_session.Client);
}
catch (RouterOsException ex)
{
@@ -117,30 +81,4 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
{
_session.UpdateInfo = await RouterOsUpdateService.CheckForUpdatesAsync(client);
}
/// <summary>
/// Trägt die soeben erfolgreich verbundene Verbindung in "Bekannte Router" ein bzw. aktualisiert
/// den passenden bestehenden Eintrag (siehe SavedRoutersStore.RecordConnection). Rein additive
/// Komfortfunktion — ein Fehler hier darf die Geräte-Erkennung selbst nicht stören.
/// </summary>
private void RecordSavedRouter(RouterDevice device)
{
if (_session.Credentials is not { } credentials)
{
return;
}
var saved = _savedRoutersStore.RecordConnection(
credentials.Host, credentials.Username, device.BoardModel, device.SerialNumber, DateTimeOffset.Now);
CredentialWarning = null;
if (!_session.RememberPassword)
{
WindowsCredentialStore.Delete(saved.Id);
}
else if (!WindowsCredentialStore.Save(saved.Id, credentials.Username, credentials.Password))
{
CredentialWarning = L.T("Das Passwort konnte nicht in der Windows-Anmeldeinformationsverwaltung gespeichert werden. Du musst es beim nächsten Verbinden erneut eingeben.");
}
}
}
@@ -1,176 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Expert;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Bearbeiten-Bereich für einen RouterOS-Eintrag — im Experte-Bereich und in der Übersicht (Issue #24)
/// derselbe: Feldhilfen aus dem Katalog, „Weitere Parameter“, Befehlsvorschau, Speichern erst bei Änderung,
/// Sicherung vor der ersten Änderung einer Sitzung.
/// </summary>
public partial class ExpertEditorViewModel : ObservableObject
{
private readonly WizardSession _session;
private readonly MenuSchema? _schema;
private readonly IReadOnlyDictionary<string, string>? _row;
private readonly Func<Task> _onSaved;
public ExpertEditorViewModel(
WizardSession session, string path, MenuSchema? schema, IReadOnlyDictionary<string, string>? row,
IReadOnlyList<string> interfaceNames, Func<Task> onSaved)
{
_session = session;
Path = path;
_schema = schema;
_row = row;
_onSaved = onSaved;
Title = row is null ? L.T("Neuer Eintrag")
: ExpertMenus.IsTrue(row, "dynamic") ? L.T("Eintrag ansehen (dynamisch, von RouterOS verwaltet)")
: L.T("Eintrag bearbeiten");
BuildFields(interfaceNames);
}
public string Path { get; }
public string Title { get; }
public ObservableCollection<ExpertFieldViewModel> Fields { get; } = [];
/// <summary>Zusätzliche Parameter als „name=wert“-Zeilen, z. B. für Felder ohne Katalog-Eintrag.</summary>
[ObservableProperty]
private string _extraParameters = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? _previewText;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool _isSaving;
[ObservableProperty]
private string? _errorMessage;
/// <summary>Wird ausgelöst, wenn der Editor geschlossen werden soll (nach dem Speichern oder per „Schließen“).</summary>
public event EventHandler? Closed;
private void BuildFields(IReadOnlyList<string> interfaceNames)
{
foreach (var field in _schema?.Fields ?? [])
{
var options = field.Kind switch
{
"enum" => field.Options ?? [],
"interfacePick" => interfaceNames,
_ => [],
};
var item = new ExpertFieldViewModel(field.Key, field.Label, field.Help, field.Kind, options, _row?.GetValueOrDefault(field.Key) ?? string.Empty);
if (_row is null && field.Default is { } defaultValue)
{
// Neuer Eintrag: Katalog-Standardwert vorschlagen (zählt als Änderung, wird also mitgeschickt).
item.Value = defaultValue;
}
Add(item);
}
// Weitere Felder des Eintrags, die der Katalog nicht kennt (oder alle, bei einem eigenen Menüpfad).
foreach (var (key, value) in _row ?? new Dictionary<string, string>())
{
if (key.StartsWith('.') || Fields.Any(f => f.Key == key) || key is "dynamic" or "invalid" or "running" or "default")
{
continue;
}
Add(new ExpertFieldViewModel(key, key, L.T("Weiterer Parameter dieses Eintrags."), value is "true" or "false" ? "bool" : "text", [], value));
}
UpdatePreview();
}
private void Add(ExpertFieldViewModel field)
{
field.PropertyChanged += (_, _) => UpdatePreview();
Fields.Add(field);
}
partial void OnExtraParametersChanged(string value) => UpdatePreview();
private Dictionary<string, string> CollectChanges()
{
var original = _row is null ? null : Fields.ToDictionary(f => f.Key, f => f.Original);
var edited = Fields.ToDictionary(f => f.Key, f => f.Value);
foreach (var line in ExtraParameters.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separator = line.IndexOf('=');
if (separator > 0)
{
edited[line[..separator].Trim()] = line[(separator + 1)..].Trim();
}
}
return ExpertMenus.Changes(original, edited);
}
private void UpdatePreview()
{
var changes = CollectChanges();
PreviewText = changes.Count == 0
? null
: _schema is { Singleton: true }
? ExpertMenus.Preview(Path, "set", null, changes)
: ExpertMenus.Preview(Path, _row is null ? "add" : "set", _row?.GetValueOrDefault(".id"), changes);
}
private bool CanSave => PreviewText is not null && !IsSaving;
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
if (_session.Client is not { } client)
{
return;
}
if (_row is not null && _schema is not { Singleton: true } && !_row.ContainsKey(".id"))
{
// Ohne ID würde aus „ändern“ ein neuer Eintrag.
ErrorMessage = L.T("Dieser Eintrag hat keine ID und kann hier nicht geändert werden.");
return;
}
IsSaving = true;
ErrorMessage = null;
try
{
if (!_session.ExpertBackupDone)
{
await _session.CreateSafetyBackupAsync(client);
_session.ExpertBackupDone = true;
}
await ExpertMenus.SaveAsync(client, _schema, Path, _row?.GetValueOrDefault(".id"), CollectChanges());
LastSavedCommand = PreviewText;
Closed?.Invoke(this, EventArgs.Empty);
await _onSaved();
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsSaving = false;
}
}
/// <summary>Der zuletzt ausgeführte Befehl, für die Erfolgsmeldung des Aufrufers.</summary>
public string? LastSavedCommand { get; private set; }
[RelayCommand]
private void Close() => Closed?.Invoke(this, EventArgs.Empty);
}
@@ -1,295 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Expert;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Ein Eintrag in der Menüliste links: ein kuratiertes Menü oder ein Werkzeug.</summary>
public sealed record ExpertMenuItem(string Title, string Group, string? Path, MenuSchema? Schema, string? Tool = null);
/// <summary>Ein Listeneintrag eines Menüs.</summary>
public sealed class ExpertEntryViewModel(IReadOnlyDictionary<string, string> row, MenuSchema? schema)
{
public IReadOnlyDictionary<string, string> Row { get; } = row;
public string? Id => Row.GetValueOrDefault(".id");
/// <summary>Die wichtigsten Spalten des Menüs, sonst die ersten sinnvollen Felder.</summary>
public string Summary
{
get
{
var keys = schema?.Columns is { Count: > 0 } columns
? columns
: Row.Keys.Where(k => !k.StartsWith('.') && k is not ("disabled" or "dynamic" or "invalid")).Take(4).ToList();
var parts = keys.Select(k => Row.GetValueOrDefault(k)).Where(v => !string.IsNullOrEmpty(v));
var text = string.Join(" · ", parts);
return text.Length > 0 ? text : Id ?? "(Eintrag)";
}
}
public bool IsDisabled => ExpertMenus.IsDisabled(Row);
public bool IsDynamic => ExpertMenus.IsTrue(Row, "dynamic");
public bool CanDelete => ExpertMenus.CanDelete(schema, Row);
}
/// <summary>Ein Feld im Bearbeiten-Bereich, mit Hilfe aus dem Katalog.</summary>
public partial class ExpertFieldViewModel : ObservableObject
{
public ExpertFieldViewModel(string key, string label, string help, string kind, IReadOnlyList<string> options, string original)
{
Key = key;
Label = label;
Help = help;
Kind = kind;
Options = options;
Original = kind == "bool" ? NormalizeBool(original) : original;
_value = Original;
}
public string Key { get; }
public string Label { get; }
public string Help { get; }
public string Kind { get; }
public IReadOnlyList<string> Options { get; }
public string Original { get; }
public bool IsBool => Kind == "bool";
public bool HasOptions => !IsBool && Options.Count > 0;
public bool IsText => !IsBool && Options.Count == 0;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsChecked))]
private string _value;
public bool IsChecked
{
get => Value == "true";
set => Value = value ? "true" : "false";
}
/// <summary>RouterOS liefert "true"/"false", akzeptiert aber auch "yes"/"no" — einheitlich vergleichen.</summary>
private static string NormalizeBool(string value) => value switch
{
"yes" or "true" => "true",
"no" or "false" => "false",
_ => value,
};
}
/// <summary>
/// Experte-Bereich (Issue #21, Mac „Experte“-Tab): kuratierte RouterOS-Menüs mit Feldhilfen und ein
/// eigener Menüpfad für alles andere. Auflisten, bearbeiten, anlegen, löschen — jede Änderung zeigt vorher
/// den genauen Befehl, vor der ersten Änderung entsteht eine Sicherung. Nur mit „Professioneller Netzwerker“.
/// </summary>
public partial class ExpertStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
public ExpertStepViewModel(WizardSession session)
: base(
title: L.T("Experte"),
description: L.T("Direkter Zugriff auf RouterOS-Menüs. Jede Änderung zeigt vorher den genauen Befehl; vor der ersten Änderung wird eine Sicherung erstellt."))
{
_session = session;
Menus = [.. ExpertTools.Items, .. MenuCatalog.All.Select(m => new ExpertMenuItem(m.Title, m.Group, m.Path, m))];
Tools = new ExpertTools(session);
}
public IReadOnlyList<ExpertMenuItem> Menus { get; }
/// <summary>Werkzeuge (Netzwerke isolieren, Neues LAN, Port in Bridge, Pakete).</summary>
public ExpertTools Tools { get; }
public ObservableCollection<ExpertEntryViewModel> Entries { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsMenuSelected))]
[NotifyPropertyChangedFor(nameof(IsToolSelected))]
[NotifyPropertyChangedFor(nameof(CanAdd))]
private ExpertMenuItem? _selectedMenu;
public bool IsMenuSelected => SelectedMenu is { Tool: null };
public bool IsToolSelected => SelectedMenu is { Tool: not null };
public bool CanAdd => SelectedMenu?.Schema is not { Singleton: true } and not { FixedList: true } && IsMenuSelected;
[ObservableProperty]
private string _customPath = string.Empty;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private string? _statusMessage;
/// <summary>Der geöffnete Bearbeiten-Bereich, null wenn geschlossen (gemeinsam mit der Übersicht, Issue #24).</summary>
[ObservableProperty]
private ExpertEditorViewModel? _editor;
public override async Task OnActivatedAsync()
{
await Tools.ActivateAsync();
if (SelectedMenu is { Path: not null })
{
await LoadEntriesAsync();
}
}
partial void OnSelectedMenuChanged(ExpertMenuItem? value)
{
Tools.ActiveTool = value?.Tool;
Editor = null;
Entries.Clear();
ErrorMessage = null;
StatusMessage = null;
if (value is { Path: not null })
{
_ = LoadEntriesAsync();
}
}
[RelayCommand]
private void OpenCustomPath()
{
if (string.IsNullOrWhiteSpace(CustomPath))
{
return;
}
var path = MenuCatalog.NormalizePath(CustomPath);
SelectedMenu = Menus.FirstOrDefault(m => m.Path == path)
?? new ExpertMenuItem(path, L.T("Eigener Menüpfad"), path, MenuCatalog.Find(path));
}
[RelayCommand]
private Task Refresh() => LoadEntriesAsync();
private async Task LoadEntriesAsync()
{
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path)
{
return;
}
IsLoading = true;
ErrorMessage = null;
Entries.Clear();
try
{
foreach (var row in await client.GetAsync(path))
{
Entries.Add(new ExpertEntryViewModel(row, SelectedMenu.Schema));
}
if (SelectedMenu.Schema is { Singleton: true } && Entries.Count > 0)
{
EditEntry(Entries[0]);
}
}
catch (RouterOsException ex)
{
ErrorMessage = DescribeLoadError(path, ex);
}
finally
{
IsLoading = false;
}
}
/// <summary>Fehlt ein Bereich, weil er ein eigenes Paket ist (z. B. Hotspot auf dem hAP lite), verständlich erklären (Mac v1.11.0).</summary>
private static string DescribeLoadError(string path, RouterOsException ex) =>
path.StartsWith("/ip/hotspot", StringComparison.OrdinalIgnoreCase)
? L.T("Hotspot ist auf diesem Router ein eigenes Zusatzpaket und noch nicht installiert. Unter „Werkzeuge → Pakete“ lässt sich das Paket „hotspot“ installieren.")
: ex.Message == L.T("Diese Einstellung wird von deinem Gerät nicht unterstützt.")
? L.F("Den Menüpfad „{0}“ gibt es auf diesem Router nicht (RouterOS-Version oder fehlendes Paket).", path)
: ex.Message;
[RelayCommand]
private void EditEntry(ExpertEntryViewModel entry) => OpenEditor(entry.Row);
[RelayCommand]
private void NewEntry() => OpenEditor(null);
private void OpenEditor(IReadOnlyDictionary<string, string>? row)
{
if (SelectedMenu?.Path is not { } path)
{
return;
}
ExpertEditorViewModel? editor = null;
editor = new ExpertEditorViewModel(_session, path, SelectedMenu.Schema, row, Tools.InterfaceNames, async () =>
{
StatusMessage = L.F("Gespeichert: {0}", editor!.LastSavedCommand);
await LoadEntriesAsync();
});
editor.Closed += (_, _) =>
{
if (Editor == editor)
{
Editor = null;
}
};
Editor = editor;
}
[RelayCommand]
private async Task DeleteEntryAsync(ExpertEntryViewModel entry)
{
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path || entry.Id is not { } id || !entry.CanDelete)
{
return;
}
var preview = ExpertMenus.Preview(path, "remove", id, new Dictionary<string, string>());
if (System.Windows.MessageBox.Show(L.F("Diesen Eintrag löschen?\n\n{0}\n\nBefehl: {1}", entry.Summary, preview), L.T("Eintrag löschen"),
System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.Yes)
{
return;
}
IsLoading = true;
ErrorMessage = null;
try
{
if (!_session.ExpertBackupDone)
{
StatusMessage = L.T("Sicherung vor der ersten Änderung wird erstellt …");
await _session.CreateSafetyBackupAsync(client);
_session.ExpertBackupDone = true;
}
await client.RemoveAsync(path, id);
StatusMessage = L.F("Gelöscht: {0}", preview);
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsLoading = false;
}
await LoadEntriesAsync();
}
}
@@ -1,460 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Configuration;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Expert;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Ein Paket in der Paketliste mit Vormerkung.</summary>
public partial class PackageRowViewModel(RouterPackage package) : ObservableObject
{
public RouterPackage Package { get; } = package;
/// <summary>Installiert: Haken = behalten; verfügbar: Haken = installieren.</summary>
[ObservableProperty]
private bool _isWanted = package.IsInstalled;
public bool CanChange => !Package.IsSystemPackage && Package.Scheduled.Length == 0;
public string StatusLabel => Package switch
{
{ IsScheduledForInstall: true } => L.T("zur Installation vorgemerkt (nach Neustart)"),
{ IsScheduledForUninstall: true } => L.T("zum Entfernen vorgemerkt (nach Neustart)"),
{ IsInstalled: true } => L.F("installiert, {0}", Package.Version),
_ => L.F("verfügbar, {0} KiB", Package.SizeBytes / 1024),
};
}
/// <summary>
/// Werkzeuge im Experte-Bereich (Mac v1.3.0–v1.11.3): Netzwerke isolieren, Neues LAN anlegen,
/// Port in Bridge einfügen, Pakete. Jedes Werkzeug zeigt seinen Plan vorab als Liste und führt ihn
/// mit Fortschritt und Abschluss-Übersicht aus; vorher entsteht eine Sicherung.
/// </summary>
public partial class ExpertTools : ObservableObject
{
public static readonly IReadOnlyList<ExpertMenuItem> Items =
[
new(L.T("Netzwerke isolieren"), L.T("Werkzeuge"), null, null, "isolate"),
new(L.T("Neues LAN anlegen"), L.T("Werkzeuge"), null, null, "newlan"),
new(L.T("Port in Bridge einfügen"), L.T("Werkzeuge"), null, null, "rejoin"),
new(L.T("Pakete"), L.T("Werkzeuge"), null, null, "packages"),
];
private readonly WizardSession _session;
private List<IReadOnlyDictionary<string, string>> _interfaces = [];
private List<IReadOnlyDictionary<string, string>> _bridgePorts = [];
public ExpertTools(WizardSession session)
{
_session = session;
NewLanLease.PropertyChanged += (_, _) => RefreshPlan();
}
/// <summary>Alle Interface-Namen (auch für Auswahlfelder im Menü-Editor).</summary>
public List<string> InterfaceNames { get; private set; } = [];
public ObservableCollection<string> Bridges { get; } = [];
/// <summary>Anschlüsse (Ethernet/WLAN), die nicht in einer Bridge hängen — Kandidaten für „Port in Bridge einfügen“.</summary>
public ObservableCollection<string> FreePorts { get; } = [];
/// <summary>Anschlüsse (Ethernet/WLAN) für „Neues LAN anlegen“.</summary>
public ObservableCollection<string> PhysicalPorts { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsIsolate), nameof(IsNewLan), nameof(IsRejoin), nameof(IsPackages))]
private string? _activeTool;
public bool IsIsolate => ActiveTool == "isolate";
public bool IsNewLan => ActiveTool == "newlan";
public bool IsRejoin => ActiveTool == "rejoin";
public bool IsPackages => ActiveTool == "packages";
/// <summary>Was das Werkzeug ausführen wird — Klartext, vor dem Anwenden.</summary>
public ObservableCollection<string> PlanLines { get; } = [];
public ObservableCollection<string> CompletedSteps { get; } = [];
private IReadOnlyList<ConfigurationAction> _plan = [];
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApplyPlanCommand))]
private bool _isBusy;
[ObservableProperty]
private string? _toolError;
[ObservableProperty]
private string? _toolWarning;
[ObservableProperty]
private string? _progressText;
[ObservableProperty]
private int _progressValue;
[ObservableProperty]
private int _progressMaximum = 1;
// --- Netzwerke isolieren ---
[ObservableProperty]
private string? _isolateInterface;
[ObservableProperty]
private string? _isolateFrom;
// --- Neues LAN anlegen ---
[ObservableProperty]
private string? _newLanInterface;
[ObservableProperty]
private string _newLanAddress = "10.10.10.1/24";
[ObservableProperty]
private string _newLanPoolStart = "10.10.10.10";
[ObservableProperty]
private string _newLanPoolEnd = "10.10.10.200";
[ObservableProperty]
private bool _newLanIsolate = true;
public LeaseTimeEditor NewLanLease { get; } = new();
// --- Port in Bridge einfügen ---
[ObservableProperty]
private string? _rejoinPort;
[ObservableProperty]
private string? _rejoinBridge;
// --- Pakete ---
public ObservableCollection<PackageRowViewModel> Packages { get; } = [];
[ObservableProperty]
private string? _packageInfo;
public async Task ActivateAsync()
{
if (_session.Client is not { } client)
{
return;
}
try
{
_interfaces = (await client.GetAsync("/interface")).ToList();
_bridgePorts = (await client.GetAsync("/interface/bridge/port")).ToList();
}
catch (RouterOsException ex)
{
ToolError = ex.Message;
return;
}
InterfaceNames = _interfaces.Select(i => i.GetValueOrDefault("name")).OfType<string>().ToList();
Replace(Bridges, _interfaces.Where(i => i.GetValueOrDefault("type") == "bridge").Select(i => i["name"]));
var physical = _interfaces.Where(i => i.GetValueOrDefault("type") is "ether" or "wlan" or "wifi" or "wireless")
.Select(i => i.GetValueOrDefault("name")).OfType<string>().ToList();
Replace(PhysicalPorts, physical);
Replace(FreePorts, physical.Where(p => !_bridgePorts.Any(bp => bp.GetValueOrDefault("interface") == p)));
RejoinBridge ??= Bridges.FirstOrDefault();
IsolateFrom ??= Bridges.FirstOrDefault();
RefreshPlan();
}
private static void Replace(ObservableCollection<string> target, IEnumerable<string> items)
{
target.Clear();
foreach (var item in items)
{
target.Add(item);
}
}
partial void OnActiveToolChanged(string? value)
{
ToolError = null;
CompletedSteps.Clear();
if (value == "packages")
{
_ = LoadPackagesAsync(refresh: false);
}
RefreshPlan();
}
partial void OnIsolateInterfaceChanged(string? value) => RefreshPlan();
partial void OnIsolateFromChanged(string? value) => RefreshPlan();
partial void OnNewLanInterfaceChanged(string? value) => RefreshPlan();
partial void OnNewLanAddressChanged(string value) => RefreshPlan();
partial void OnNewLanPoolStartChanged(string value) => RefreshPlan();
partial void OnNewLanPoolEndChanged(string value) => RefreshPlan();
partial void OnNewLanIsolateChanged(bool value) => RefreshPlan();
partial void OnRejoinPortChanged(string? value) => RefreshPlan();
partial void OnRejoinBridgeChanged(string? value) => RefreshPlan();
private void RefreshPlan() => _ = BuildPlanAsync();
private async Task BuildPlanAsync()
{
ToolWarning = null;
_plan = [];
try
{
_plan = ActiveTool switch
{
"isolate" => await PlanIsolateAsync(),
"newlan" => await PlanNewLanAsync(),
"rejoin" when RejoinPort is { } port && RejoinBridge is { } bridge && _session.Client is { } client
=> await ExpertToolPlans.RejoinBridgeAsync(client, port, bridge),
_ => [],
};
}
catch (RouterOsException ex)
{
ToolError = ex.Message;
}
Replace(PlanLines, _plan.Select(a => a.Description));
ApplyPlanCommand.NotifyCanExecuteChanged();
}
private async Task<IReadOnlyList<ConfigurationAction>> PlanIsolateAsync()
{
if (IsolateInterface is not { } iface || IsolateFrom is not { } other || iface == other || _session.Client is not { } client)
{
return [];
}
var address = (await client.GetAsync("/ip/address")).FirstOrDefault(a => a.GetValueOrDefault("interface") == iface)?.GetValueOrDefault("address");
if (address is null)
{
ToolWarning = L.F("„{0}“ hat keine IP-Adresse — erst ein eigenes Netz anlegen (z. B. mit „Neues LAN anlegen“).", iface);
return [];
}
var network = IpNetworkHelper.GetNetworkCidr(address);
if (_session.Credentials?.Host is { } host && IpNetworkHelper.Contains(network, host))
{
ToolWarning = L.F("Du bist über {0} mit dem Router verbunden, also aus dem Netz, das isoliert werden soll. Die Isolation würde dich aussperren — bitte über ein anderes Netz verbinden.", host);
return [];
}
return ExpertToolPlans.Isolate(iface, other, network);
}
private async Task<IReadOnlyList<ConfigurationAction>> PlanNewLanAsync()
{
if (NewLanInterface is not { } iface || _session.Client is not { } client)
{
return [];
}
if (!NetworkValidation.IsValidCidr(NewLanAddress) || !NetworkValidation.IsValidIPv4(NewLanPoolStart) || !NetworkValidation.IsValidIPv4(NewLanPoolEnd)
|| !NetworkValidation.IsOrderedRange(NewLanPoolStart, NewLanPoolEnd))
{
ToolWarning = L.T("Bitte Adresse mit Präfix (z. B. 10.10.10.1/24) und einen gültigen Adressbereich angeben.");
return [];
}
var conflicts = await PortConflicts.FindAsync(client, [iface]);
var bridge = _bridgePorts.FirstOrDefault(p => p.GetValueOrDefault("interface") == iface)?.GetValueOrDefault("bridge");
var warnings = new List<string>();
if (bridge is not null)
{
warnings.Add(L.F("„{0}“ hängt in der Bridge „{1}“ und wird herausgenommen. Ist dein PC an diesem Anschluss angeschlossen, bricht die Verbindung ab.", iface, bridge));
}
if (conflicts.FirstOrDefault(c => c.Address is not null && c.Address == _session.Credentials?.Host) is { } own)
{
warnings.Add(L.F("Achtung: Über {0} auf „{1}“ bist du gerade verbunden — die Verbindung bricht ab.", own.Address, iface));
}
ToolWarning = warnings.Count > 0 ? string.Join(" ", warnings) : null;
return ExpertToolPlans.NewLan(
new NewLanSettings(iface, NewLanAddress, NewLanPoolStart, NewLanPoolEnd, NewLanLease.Value, NewLanIsolate ? Bridges.FirstOrDefault() : null),
conflicts,
bridge);
}
private bool CanApplyPlan => !IsBusy && _plan.Count > 0;
[RelayCommand(CanExecute = nameof(CanApplyPlan))]
private async Task ApplyPlanAsync()
{
if (_session.Client is not { } client)
{
return;
}
var plan = _plan;
IsBusy = true;
_session.IsDeviceBusy = true;
ToolError = null;
CompletedSteps.Clear();
ProgressMaximum = plan.Count + 1;
ProgressValue = 0;
try
{
ProgressText = L.T("Sicherung wird erstellt …");
CompletedSteps.Add(L.F("Sicherung „{0}“", await _session.CreateSafetyBackupAsync(client)));
ProgressValue = 1;
for (var i = 0; i < plan.Count; i++)
{
ProgressText = L.F("Schritt {0} von {1}: {2}", i + 2, ProgressMaximum, plan[i].Description);
await plan[i].ApplyAsync(client, CancellationToken.None);
CompletedSteps.Add(plan[i].Description);
ProgressValue = i + 2;
}
ProgressText = null;
}
catch (RouterOsException ex)
{
ToolError = L.F("{0} (abgebrochen bei: {1})", ex.Message, ProgressText);
ProgressText = null;
}
finally
{
IsBusy = false;
_session.IsDeviceBusy = false;
}
// Ein Isolations-Schalter darf nicht unbemerkt auf das nächste Netz wirken (Mac v1.3.0).
NewLanInterface = null;
IsolateInterface = null;
RejoinPort = null;
await ActivateAsync();
}
[RelayCommand]
private Task RefreshPackagesAsync() => LoadPackagesAsync(refresh: true);
private async Task LoadPackagesAsync(bool refresh)
{
if (_session.Client is not { } client)
{
return;
}
IsBusy = true;
PackageInfo = L.T("Paketliste wird vom MikroTik-Server geholt …");
try
{
var (packages, free) = await PackageService.ListAsync(client, refresh);
Packages.Clear();
foreach (var package in packages)
{
var row = new PackageRowViewModel(package);
row.PropertyChanged += (_, _) => UpdatePackageInfo(free);
Packages.Add(row);
}
UpdatePackageInfo(free);
}
catch (RouterOsException ex)
{
ToolError = ex.Message;
PackageInfo = null;
}
finally
{
IsBusy = false;
ApplyPackagesCommand.NotifyCanExecuteChanged();
}
}
private void UpdatePackageInfo(long? freeBytes)
{
var install = Packages.Where(p => p.IsWanted && p.Package.IsAvailable).ToList();
var remove = Packages.Where(p => !p.IsWanted && p.Package.IsInstalled).ToList();
var needed = install.Sum(p => p.Package.SizeBytes);
var lines = new List<string>();
if (install.Count + remove.Count == 0)
{
lines.Add(L.T("Haken setzen zum Installieren, Haken entfernen zum Entfernen."));
}
else
{
lines.Add(L.F("Vorgemerkt: {0} installieren, {1} entfernen. Der Router lädt die Pakete von MikroTik herunter und startet neu.", install.Count, remove.Count));
}
if (freeBytes is { } free && needed > free)
{
lines.Add(L.F("Zu wenig Speicher: benötigt {0} KiB, frei {1} KiB.", needed / 1024, free / 1024));
}
if (remove.Any(p => p.Package.IsWirelessDriver))
{
lines.Add(L.T("Achtung: Ein WLAN-Treiber wird entfernt — das WLAN des Routers ist danach aus."));
}
PackageInfo = string.Join(" ", lines);
ApplyPackagesCommand.NotifyCanExecuteChanged();
}
private bool CanApplyPackages =>
!IsBusy && Packages.Any(p => (p.IsWanted && p.Package.IsAvailable) || (!p.IsWanted && p.Package.IsInstalled && !p.Package.IsSystemPackage));
[RelayCommand(CanExecute = nameof(CanApplyPackages))]
private async Task ApplyPackagesAsync()
{
if (_session.Client is not { } client)
{
return;
}
var install = Packages.Where(p => p.IsWanted && p.Package.IsAvailable).Select(p => p.Package).ToList();
var remove = Packages.Where(p => !p.IsWanted && p.Package.IsInstalled && !p.Package.IsSystemPackage).Select(p => p.Package).ToList();
var commands = install.Select(p => $"/system package enable {p.Name}").Concat(remove.Select(p => $"/system package uninstall {p.Name}")).Append("/system package apply-changes");
if (System.Windows.MessageBox.Show(
L.F("Diese Befehle werden ausgeführt, danach startet der Router neu:\n\n{0}", string.Join("\n", commands)),
L.T("Pakete ändern"), System.Windows.MessageBoxButton.OKCancel, System.Windows.MessageBoxImage.Question) != System.Windows.MessageBoxResult.OK)
{
return;
}
IsBusy = true;
_session.IsDeviceBusy = true;
ToolError = null;
try
{
PackageInfo = L.T("Sicherung wird erstellt …");
await _session.CreateSafetyBackupAsync(client);
PackageInfo = L.T("Änderungen werden vorgemerkt und angewendet …");
await PackageService.ApplyAsync(client, install, remove);
PackageInfo = L.T("Der Router lädt die Pakete herunter und startet neu — die Verbindung wird danach automatisch wiederhergestellt …");
_session.IsDeviceBusy = false;
if (await _session.ReconnectAsync(TimeSpan.FromSeconds(30)))
{
await LoadPackagesAsync(refresh: false);
var missing = install.Where(p => Packages.FirstOrDefault(r => r.Package.Name == p.Name)?.Package.IsInstalled != true).Select(p => p.Name).ToList();
PackageInfo = missing.Count == 0 ? L.T("Fertig — alle Änderungen sind übernommen.") : L.F("Nicht installiert: {0}.", string.Join(", ", missing));
}
}
catch (Exception ex) when (ex is RouterOsException or InvalidOperationException)
{
ToolError = ex.Message;
}
finally
{
IsBusy = false;
_session.IsDeviceBusy = false;
}
}
}
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -16,8 +15,8 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
public FirewallStepViewModel(WizardSession session)
: base(
title: L.T("Firewall"),
description: L.T("Wir richten sichere Basis-Firewallregeln ein: Der Router ist aus dem Internet nicht erreichbar, dein Heimnetz bleibt geschützt. Optional kannst du einzelne Ports für Geräte in deinem Netzwerk freigeben."))
title: "Firewall",
description: "Wir richten sichere Basis-Firewallregeln ein: Der Router ist aus dem Internet nicht erreichbar, dein Heimnetz bleibt geschützt. Optional kannst du einzelne Ports für Geräte in deinem Netzwerk freigeben.")
{
_session = session;
}
@@ -32,11 +31,7 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
public bool IsExpertMode => _session.IsExpertMode;
[ObservableProperty]
private string? _blockedAddressesValidationMessage;
public override bool CanGoNext => PortForwards.All(IsValid) && BlockedAddressesValidationMessage is null;
public override bool CanGoNext => PortForwards.All(IsValid);
public override Task OnActivatedAsync()
{
@@ -49,15 +44,7 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
private void AddPortForward()
{
var entry = new PortForwardEntry();
// ValidationMessage wird von SaveToSession selbst geschrieben (siehe UpdatePortForwardValidation) —
// davon ausgenommen, sonst würde sich SaveToSession bei jeder Neuberechnung selbst erneut auslösen.
entry.PropertyChanged += (_, args) =>
{
if (args.PropertyName != nameof(PortForwardEntry.ValidationMessage))
{
SaveToSession();
}
};
entry.PropertyChanged += (_, _) => SaveToSession();
PortForwards.Add(entry);
SaveToSession();
}
@@ -71,87 +58,14 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
partial void OnBlockedAddressesTextChanged(string value) => SaveToSession();
private static string? ComputeValidationMessage(PortForwardEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Name))
{
return L.T("Bitte einen Namen für diese Portfreigabe vergeben.");
}
if (!NetworkValidation.IsValidIPv4(entry.TargetAddress))
{
return L.T("Ziel-Adresse: keine gültige IP-Adresse.");
}
if (!int.TryParse(entry.ExternalPort, out var externalPort) || externalPort is not (> 0 and <= 65535))
{
return L.T("Externer Port: bitte eine Zahl zwischen 1 und 65535 eingeben.");
}
if (!int.TryParse(entry.InternalPort, out var internalPort) || internalPort is not (> 0 and <= 65535))
{
return L.T("Interner Port: bitte eine Zahl zwischen 1 und 65535 eingeben.");
}
if (!string.IsNullOrWhiteSpace(entry.AllowedSourceAddress) && !NetworkValidation.IsValidIPv4OrCidr(entry.AllowedSourceAddress))
{
return L.T("Quell-IP-Einschränkung: keine gültige IP-Adresse oder „Adresse/Präfix\".");
}
if (!string.IsNullOrWhiteSpace(entry.ExternalPortRangeEnd))
{
if (!int.TryParse(entry.ExternalPortRangeEnd, out var externalEnd) || externalEnd is not (> 0 and <= 65535))
{
return L.T("Externer Portbereich bis: bitte eine Zahl zwischen 1 und 65535 eingeben.");
}
if (externalEnd <= externalPort)
{
return L.T("Externer Portbereich bis: muss größer als der externe Port sein.");
}
}
if (!string.IsNullOrWhiteSpace(entry.InternalPortRangeEnd))
{
if (!int.TryParse(entry.InternalPortRangeEnd, out var internalEnd) || internalEnd is not (> 0 and <= 65535))
{
return L.T("Interner Portbereich bis: bitte eine Zahl zwischen 1 und 65535 eingeben.");
}
if (internalEnd <= internalPort)
{
return L.T("Interner Portbereich bis: muss größer als der interne Port sein.");
}
}
return null;
}
private void UpdatePortForwardValidation()
{
foreach (var entry in PortForwards)
{
entry.ValidationMessage = ComputeValidationMessage(entry);
}
}
private static bool IsValid(PortForwardEntry entry) => entry.ValidationMessage is null;
private string? ComputeBlockedAddressesValidationMessage()
{
var invalid = BlockedAddressesText
.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(entry => !NetworkValidation.IsValidIPv4OrCidr(entry));
return invalid is null
? null
: L.F("IP-/Subnetz-Sperrliste: „{0}\" ist keine gültige IP-Adresse oder „Adresse/Präfix\".", invalid);
}
private static bool IsValid(PortForwardEntry entry) =>
!string.IsNullOrWhiteSpace(entry.Name)
&& !string.IsNullOrWhiteSpace(entry.TargetAddress)
&& int.TryParse(entry.ExternalPort, out var externalPort) && externalPort is > 0 and <= 65535
&& int.TryParse(entry.InternalPort, out var internalPort) && internalPort is > 0 and <= 65535;
private void SaveToSession()
{
UpdatePortForwardValidation();
BlockedAddressesValidationMessage = ComputeBlockedAddressesValidationMessage();
OnPropertyChanged(nameof(CanGoNext));
if (!CanGoNext || _session.WanInterfaceName is null || _session.LanBridgeName is null)
@@ -1,10 +1,9 @@
using MikrotikInstaller.Core.Localization;
using MikrotikInstaller.Core.Devices;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Aufbereitete Anzeige einer Schnittstelle für die Geräte-Übersicht (laientaugliche Texte statt Enum-Werte).</summary>
public sealed record InterfaceDisplayItem(string Name, string KindLabel, string StatusLabel, bool IsProblem, bool HasLink)
public sealed record InterfaceDisplayItem(string Name, string KindLabel, string StatusLabel, bool IsProblem)
{
public static InterfaceDisplayItem From(InterfaceInfo info)
{
@@ -14,11 +13,11 @@ public sealed record InterfaceDisplayItem(string Name, string KindLabel, string
InterfaceKind.Bridge => "Bridge",
InterfaceKind.Vlan => "VLAN",
InterfaceKind.Wireless => "WLAN",
_ => L.T("Sonstige"),
_ => "Sonstige",
};
var statusLabel = info.IsDisabled ? L.T("Deaktiviert") : info.IsRunning ? L.T("Aktiv") : L.T("Inaktiv");
var statusLabel = info.IsDisabled ? "Deaktiviert" : info.IsRunning ? "Aktiv" : "Inaktiv";
return new InterfaceDisplayItem(info.Name, kindLabel, statusLabel, IsProblem: info.IsDisabled, HasLink: info.IsRunning && !info.IsDisabled);
return new InterfaceDisplayItem(info.Name, kindLabel, statusLabel, IsProblem: info.IsDisabled);
}
}
@@ -1,79 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Devices;
using MikrotikInstaller.Core.Devices.LanScan;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Eine Zeile im LAN-Scanner: aufbereitete Anzeige eines <see cref="DiscoveredDevice"/> plus Zustand der "Feste IP"-Aktion.</summary>
public partial class LanScanDeviceRowViewModel : ObservableObject
{
public LanScanDeviceRowViewModel(DiscoveredDevice device)
{
Device = device;
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IpAddressDisplay))]
[NotifyPropertyChangedFor(nameof(HostNameDisplay))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(ActionLabel))]
[NotifyPropertyChangedFor(nameof(CanToggleStatic))]
[NotifyPropertyChangedFor(nameof(ActionHint))]
private DiscoveredDevice _device;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanToggleStatic))]
private bool _isBusy;
[ObservableProperty]
private string? _errorMessage;
/// <summary>
/// Gesetzt, direkt nachdem für dieses Gerät eine feste Lease entfernt wurde. RouterOS kennt danach
/// keinen Lease-Eintrag mehr für dieses Gerät — das Gerät selbst merkt das aber nicht sofort: Es hält
/// seine bisherige IP-Adresse client-seitig weiter, bis es von sich aus eine neue DHCP-Anfrage stellt
/// (Lease-Renewal-Timer) oder der Link neu aufgebaut wird (Kabel/WLAN trennen und neu verbinden,
/// Neustart). Rein für die Anzeige — wird bei jedem Neuladen des Schritts wieder zurückgesetzt.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(ActionHint))]
private bool _isAwaitingNewLease;
/// <summary>Netzwerk-Werkzeuge für dieses Gerät aufgeklappt (Ping, Traceroute, DNS, Port-Scan — Issue #23).</summary>
[ObservableProperty]
private bool _showTools;
[ObservableProperty]
private bool _isToolRunning;
public ObservableCollection<string> ToolOutput { get; } = [];
public ObservableCollection<PortScanResult> PortResults { get; } = [];
public string MacAddress => Device.MacAddress;
public string IpAddressDisplay => Device.IpAddress ?? L.T("Keine IP bekannt");
public string HostNameDisplay => string.IsNullOrWhiteSpace(Device.HostName) ? L.T("Unbekanntes Gerät") : Device.HostName;
public string StatusLabel => Device switch
{
{ IsStaticLease: true } => L.T("Feste IP-Adresse"),
{ HasDhcpLease: true } => L.T("Automatisch per DHCP"),
_ when IsAwaitingNewLease => L.T("Feste IP entfernt"),
_ => L.T("Kein DHCP-Lease"),
};
public string ActionLabel => Device.IsStaticLease ? L.T("Rückgängig machen") : L.T("Feste IP-Adresse zuweisen");
/// <summary>Ohne DHCP-Lease-Eintrag gibt es nichts, das umgestellt werden könnte.</summary>
public bool CanToggleStatic => !IsBusy && Device.LeaseId is not null;
/// <summary>Erklärt, warum der Button gerade deaktiviert ist, nachdem eine feste Zuweisung entfernt wurde.</summary>
public string? ActionHint => IsAwaitingNewLease && Device.LeaseId is null
? L.T("Erst wieder möglich, sobald sich das Gerät neu meldet — üblicherweise nach Trennen/Neuverbinden oder wenn seine bisherige Lease-Zeit abläuft, nicht sofort.")
: null;
}
@@ -1,16 +0,0 @@
using System.Collections.ObjectModel;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Alle im LAN-Scanner gefundenen Geräte an einem physischen Anschluss.</summary>
public sealed class LanScanPortGroupViewModel(string portLabel, IEnumerable<LanScanDeviceRowViewModel> devices, int index = 0)
{
/// <summary>Gedämpfte Farben, die in Hell und Dunkel lesbar bleiben — je Anschluss eine (Mac v1.7.0).</summary>
private static readonly string[] Palette = ["#5B8DEF", "#E8A33D", "#9B6BD3", "#3BAA8C", "#D9665F", "#8C9BAB"];
public string PortLabel { get; } = portLabel;
public string AccentColor { get; } = Palette[index % Palette.Length];
public ObservableCollection<LanScanDeviceRowViewModel> Devices { get; } = new(devices);
}
@@ -1,202 +0,0 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Devices;
using MikrotikInstaller.Core.Devices.LanScan;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Wizard-unabhängig nutzbarer, aber im Assistenten mitlaufender Geräte-Bereich: liest DHCP-Leases,
/// ARP-Tabelle und Bridge-Host-Tabelle aus, gruppiert die gefundenen Geräte nach physischem Anschluss
/// und erlaubt, eine DHCP-Lease auf Knopfdruck fest zuzuweisen (oder das wieder rückgängig zu machen).
/// Rein informativ, bis auf diese eine Aktion — verändert sonst nichts am Gerät und fließt nicht in
/// die geplante Soll-Konfiguration ein, siehe <see cref="Analysis.NetworkAnalysisService"/> für das
/// analoge Vorgehen auf Netzwerk-Struktur-Ebene.
/// </summary>
public partial class LanScanStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
public LanScanStepViewModel(WizardSession session)
: base(
title: L.T("Geräte im Netzwerk"),
description: L.T("Diese Geräte hat der Router aktuell an seinen Anschlüssen erkannt. Du kannst einem Gerät hier optional eine feste IP-Adresse zuweisen, damit sie sich nicht mehr ändert."))
{
_session = session;
}
public ObservableCollection<LanScanPortGroupViewModel> PortGroups { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEmptyHint))]
private bool _isLoading;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEmptyHint))]
private string? _errorMessage;
/// <summary>Steuert den Hinweistext für den Fall, dass gar keine Geräte gefunden wurden.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEmptyHint))]
private bool _hasDevices;
public override bool CanGoNext => !IsLoading;
public bool ShowEmptyHint => !IsLoading && ErrorMessage is null && !HasDevices;
public override Task OnActivatedAsync() => LoadAsync();
/// <summary>Auch als manuelles "Aktualisieren" genutzt — z. B. um nach einem Trennen/Neuverbinden zu prüfen, ob ein Gerät wieder eine DHCP-Lease bekommen hat.</summary>
[RelayCommand]
private Task Retry() => LoadAsync();
private async Task LoadAsync()
{
if (_session.Client is null)
{
ErrorMessage = L.T("Keine Verbindung zum Gerät.");
HasDevices = false;
return;
}
IsLoading = true;
ErrorMessage = null;
PortGroups.Clear();
try
{
var devices = await LanScanService.ScanAsync(_session.Client);
HasDevices = devices.Count > 0;
foreach (var group in devices.GroupBy(d => d.PortInterfaceName))
{
var rows = group.Select(d => new LanScanDeviceRowViewModel(d));
PortGroups.Add(new LanScanPortGroupViewModel(group.Key ?? L.T("Unbekannter Anschluss"), rows, PortGroups.Count));
}
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
HasDevices = false;
}
finally
{
IsLoading = false;
OnPropertyChanged(nameof(CanGoNext));
}
}
[RelayCommand]
private static void ToggleTools(LanScanDeviceRowViewModel row) => row.ShowTools = !row.ShowTools;
[RelayCommand]
private Task PingAsync(LanScanDeviceRowViewModel row) =>
RunToolAsync(row, L.F("Ping an {0} …", row.Device.IpAddress), client => NetworkTools.PingAsync(client, row.Device.IpAddress!));
[RelayCommand]
private Task TracerouteAsync(LanScanDeviceRowViewModel row) =>
RunToolAsync(row, L.F("Weg zu {0} …", row.Device.IpAddress), client => NetworkTools.TracerouteAsync(client, row.Device.IpAddress!));
[RelayCommand]
private Task ResolveAsync(LanScanDeviceRowViewModel row) =>
RunToolAsync(row, L.F("Namensauflösung für {0} …", row.HostNameDisplay), client => NetworkTools.ResolveAsync(client, row.Device.HostName!));
/// <summary>Läuft von diesem PC aus, nicht über den Router.</summary>
[RelayCommand]
private async Task ScanPortsAsync(LanScanDeviceRowViewModel row)
{
if (row.Device.IpAddress is not { } address || row.IsToolRunning)
{
return;
}
row.IsToolRunning = true;
row.ToolOutput.Clear();
row.PortResults.Clear();
row.ToolOutput.Add(L.F("Port-Scan von diesem PC aus auf {0} …", address));
try
{
foreach (var result in await NetworkTools.ScanPortsAsync(address, NetworkTools.DefaultPortScanTimeout))
{
row.PortResults.Add(result);
}
row.ToolOutput.Clear();
}
catch (ArgumentException ex)
{
row.ToolOutput.Add(ex.Message);
}
finally
{
row.IsToolRunning = false;
}
}
private async Task RunToolAsync(LanScanDeviceRowViewModel row, string startText, Func<IRouterOsClient, Task<IReadOnlyList<string>>> tool)
{
if (_session.Client is not { } client || row.IsToolRunning)
{
return;
}
row.IsToolRunning = true;
row.PortResults.Clear();
row.ToolOutput.Clear();
row.ToolOutput.Add(startText);
try
{
var lines = await tool(client);
row.ToolOutput.Clear();
foreach (var line in lines)
{
row.ToolOutput.Add(line);
}
}
catch (Exception ex) when (ex is RouterOsException or ArgumentException)
{
row.ToolOutput.Add(ex.Message);
}
finally
{
row.IsToolRunning = false;
}
}
[RelayCommand]
private async Task ToggleStaticAsync(LanScanDeviceRowViewModel row)
{
if (_session.Client is null || !row.CanToggleStatic)
{
return;
}
row.IsBusy = true;
row.ErrorMessage = null;
try
{
if (row.Device.IsStaticLease)
{
row.Device = await LanScanService.UndoStaticAsync(_session.Client, row.Device);
row.IsAwaitingNewLease = true;
}
else
{
row.Device = await LanScanService.MakeStaticAsync(_session.Client, row.Device);
}
}
catch (RouterOsException ex)
{
row.ErrorMessage = ex.Message;
}
finally
{
row.IsBusy = false;
}
}
}
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Configuration;
@@ -16,11 +15,10 @@ public partial class LanStepViewModel : WizardStepViewModelBase
public LanStepViewModel(WizardSession session)
: base(
title: L.T("Heimnetzwerk (DHCP)"),
description: L.T("Wähle, welche Anschlüsse zu deinem Heimnetzwerk gehören sollen, und lege den IP-Adressbereich fest. Die Vorgaben passen für die meisten Haushalte."))
title: "Heimnetzwerk (DHCP)",
description: "Wähle, welche Anschlüsse zu deinem Heimnetzwerk gehören sollen, und lege den IP-Adressbereich fest. Die Vorgaben passen für die meisten Haushalte.")
{
_session = session;
Lease.PropertyChanged += (_, _) => SaveToSession();
}
public ObservableCollection<SelectableInterface> AvailableInterfaces { get; } = [];
@@ -47,27 +45,8 @@ public partial class LanStepViewModel : WizardStepViewModelBase
private string _secondaryDns = "8.8.8.8";
// Experten-Felder — nur im Experten-Modus sichtbar, leer bedeutet: RouterOS-Standardverhalten.
public LeaseTimeEditor Lease { get; } = new();
/// <summary>Hinweis, dass die Felder mit dem vorhandenen Heimnetz des Routers vorbelegt wurden (Mac v1.3.0).</summary>
[ObservableProperty]
private string? _prefillMessage;
/// <summary>Für welche Verbindung schon vorgeladen wurde — nach einem Gerätewechsel erneut.</summary>
private object? _prefilledFor;
/// <summary>Alle Port-Konflikte der wählbaren Anschlüsse; angezeigt werden nur die der gewählten.</summary>
private IReadOnlyList<PortConflict> _allConflicts = [];
public ObservableCollection<string> PortConflictLines { get; } = [];
public bool HasPortConflicts => PortConflictLines.Count > 0;
/// <summary>„Port freimachen?“ (Mac v1.0–v1.2): ohne Zustimmung bleibt „Weiter“ gesperrt.</summary>
[ObservableProperty]
private bool _releaseConflictingPorts;
partial void OnReleaseConflictingPortsChanged(bool value) => SaveToSession();
private string _dhcpLeaseTime = string.Empty;
[ObservableProperty]
private string _domainName = string.Empty;
@@ -77,96 +56,31 @@ public partial class LanStepViewModel : WizardStepViewModelBase
public bool IsExpertMode => _session.IsExpertMode;
[ObservableProperty]
private string? _validationMessage;
public override bool CanGoNext =>
AvailableInterfaces.Any(i => i.IsSelected)
&& !string.IsNullOrWhiteSpace(BridgeName)
&& !string.IsNullOrWhiteSpace(RouterAddress)
&& !string.IsNullOrWhiteSpace(DhcpPoolStart)
&& !string.IsNullOrWhiteSpace(DhcpPoolEnd)
&& !string.IsNullOrWhiteSpace(PrimaryDns);
public override bool CanGoNext => ValidationMessage is null;
public override async Task OnActivatedAsync()
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
var existing = await LoadExistingLanOnceAsync();
var previouslySelected = AvailableInterfaces.Where(i => i.IsSelected).Select(i => i.Name).ToHashSet();
var keepSelection = existing is null && AvailableInterfaces.Count > 0;
AvailableInterfaces.Clear();
var candidates = (_session.Device?.Interfaces ?? []).Where(
i => i.Kind == InterfaceKind.Ethernet && i.Name != _session.WanInterfaceName);
foreach (var iface in candidates)
{
var isSelected = existing is { MemberInterfaces.Count: > 0 }
? existing.MemberInterfaces.Contains(iface.Name)
: !keepSelection || previouslySelected.Contains(iface.Name);
var item = new SelectableInterface(iface.Name, isSelected);
var item = new SelectableInterface(iface.Name, isSelected: true);
item.PropertyChanged += (_, _) => SaveToSession();
AvailableInterfaces.Add(item);
}
_allConflicts = _session.Client is { } client
? await PortConflicts.FindAsync(client, AvailableInterfaces.Select(i => i.Name))
: [];
SaveToSession();
}
private List<PortConflict> SelectedConflicts() =>
_allConflicts.Where(c => AvailableInterfaces.Any(i => i.IsSelected && i.Name == c.InterfaceName)).ToList();
private void UpdatePortConflictLines(List<PortConflict> conflicts)
{
PortConflictLines.Clear();
foreach (var conflict in conflicts)
{
var viaConnection = conflict.Address is not null && conflict.Address == _session.Credentials?.Host
? L.T(" — Achtung: Darüber bist du gerade mit dem Router verbunden! Die Verbindung bricht beim Anwenden ab.")
: string.Empty;
PortConflictLines.Add($"„{conflict.InterfaceName}“: {conflict.Description}{viaConnection}");
}
OnPropertyChanged(nameof(HasPortConflicts));
}
/// <summary>
/// Belegt die Felder einmal je Verbindung mit dem Heimnetz vor, das schon auf dem Router eingerichtet
/// ist, statt mit Standardwerten (Mac v1.3.0). Ein Anwenden aktualisiert dann die vorhandenen Einträge.
/// </summary>
private async Task<LanSettings?> LoadExistingLanOnceAsync()
{
if (_session.Client is not { } client || ReferenceEquals(_prefilledFor, client))
{
return null;
}
_prefilledFor = client;
var existing = await ExistingLanReader.ReadAsync(client);
if (existing is null)
{
PrefillMessage = null;
return null;
}
BridgeName = existing.BridgeName;
var parts = existing.RouterAddressCidr.Split('/');
RouterAddress = parts[0];
PrefixLength = int.TryParse(parts.ElementAtOrDefault(1), out var prefix) ? prefix : PrefixLength;
if (existing.DhcpPoolStart.Length > 0)
{
DhcpPoolStart = existing.DhcpPoolStart;
DhcpPoolEnd = existing.DhcpPoolEnd;
}
if (existing.PrimaryDnsServer.Length > 0)
{
PrimaryDns = existing.PrimaryDnsServer;
SecondaryDns = existing.SecondaryDnsServer ?? string.Empty;
}
Lease.Load(existing.DhcpLeaseTime);
DomainName = existing.DomainName ?? string.Empty;
PrefillMessage = L.F("Vorhandenes Heimnetz „{0}“ ({1}) vom Router übernommen. Beim Anwenden werden diese Einträge aktualisiert statt neu angelegt.", existing.BridgeName, existing.RouterAddressCidr);
return existing;
return Task.CompletedTask;
}
partial void OnBridgeNameChanged(string value) => SaveToSession();
@@ -183,66 +97,14 @@ public partial class LanStepViewModel : WizardStepViewModelBase
partial void OnSecondaryDnsChanged(string value) => SaveToSession();
partial void OnDhcpLeaseTimeChanged(string value) => SaveToSession();
partial void OnDomainNameChanged(string value) => SaveToSession();
partial void OnNtpServerChanged(string value) => SaveToSession();
private string? ComputeValidationMessage()
{
if (!AvailableInterfaces.Any(i => i.IsSelected))
{
return L.T("Bitte mindestens einen Anschluss für dein Heimnetzwerk auswählen.");
}
if (HasPortConflicts && !ReleaseConflictingPorts)
{
return L.T("Mindestens ein gewählter Anschluss ist noch anderweitig belegt — bitte freimachen oder abwählen.");
}
if (string.IsNullOrWhiteSpace(BridgeName))
{
return L.T("Bitte einen Namen für das Heimnetzwerk vergeben.");
}
if (!NetworkValidation.IsValidIPv4(RouterAddress))
{
return L.T("Router-Adresse: keine gültige IP-Adresse.");
}
if (!NetworkValidation.IsValidPrefixLength(PrefixLength))
{
return L.T("Präfix: bitte eine Zahl zwischen 0 und 32 eingeben.");
}
if (!NetworkValidation.IsValidIPv4(DhcpPoolStart) || !NetworkValidation.IsValidIPv4(DhcpPoolEnd))
{
return L.T("Adressbereich: bitte gültige IP-Adressen für Anfang und Ende eingeben.");
}
if (!NetworkValidation.IsOrderedRange(DhcpPoolStart, DhcpPoolEnd))
{
return L.T("Adressbereich: die erste Adresse muss vor (oder gleich) der letzten liegen.");
}
if (!NetworkValidation.IsValidIPv4(PrimaryDns))
{
return L.T("DNS-Server (bevorzugt): keine gültige IP-Adresse.");
}
if (!string.IsNullOrWhiteSpace(SecondaryDns) && !NetworkValidation.IsValidIPv4(SecondaryDns))
{
return L.T("DNS-Server (alternativ): keine gültige IP-Adresse.");
}
return null;
}
private void SaveToSession()
{
var conflicts = SelectedConflicts();
UpdatePortConflictLines(conflicts);
ValidationMessage = ComputeValidationMessage();
OnPropertyChanged(nameof(CanGoNext));
if (!CanGoNext)
@@ -263,12 +125,11 @@ public partial class LanStepViewModel : WizardStepViewModelBase
DhcpPoolEnd,
PrimaryDns,
string.IsNullOrWhiteSpace(SecondaryDns) ? null : SecondaryDns,
Lease.Value,
string.IsNullOrWhiteSpace(DhcpLeaseTime) ? null : DhcpLeaseTime,
string.IsNullOrWhiteSpace(DomainName) ? null : DomainName,
string.IsNullOrWhiteSpace(NtpServer) ? null : NtpServer);
_session.LanSettings = settings;
// Erst freimachen, dann einhängen — sonst landet eine Adresse auf einem Bridge-Port.
_session.LanActions = [.. PortConflicts.BuildReleaseActions(conflicts), .. LanConfigurator.BuildActions(settings)];
_session.LanActions = LanConfigurator.BuildActions(settings);
}
}
@@ -1,30 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Configuration;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Eingabe der DHCP-Lease-Zeit als Jahre/Tage/Stunden (Mac v1.11.0), 0/0/0 = RouterOS-Standard.</summary>
public partial class LeaseTimeEditor : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Value))]
private int _years;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Value))]
private int _days;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Value))]
private int _hours;
/// <summary>RouterOS-Schreibweise oder null.</summary>
public string? Value => new LeaseTime(Math.Max(Years, 0), Math.Max(Days, 0), Math.Max(Hours, 0)).ToRouterOs();
/// <summary>Übernimmt einen vom Router gelesenen Wert ohne Kürzung.</summary>
public void Load(string? routerOsDuration)
{
var parsed = LeaseTime.Parse(routerOsDuration);
(Years, Days, Hours) = (parsed.Years, parsed.Days, parsed.Hours);
}
}
@@ -1,13 +1,12 @@
using MikrotikInstaller.Core.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Wahl zwischen dem geführten Assistenten (Standard, für die meisten Nutzer passend) und dem
/// Experten-Modus, der in den folgenden Schritten zusätzliche, technischere RouterOS-Optionen einblendet.
/// Die Wahl blockiert nie das Weitergehen — der geführte Assistent ist vorausgewählt. Nur mit
/// „Professioneller Netzwerker“ sichtbar.
/// Dritter Schritt (direkt nach der Geräte-Erkennung): Wahl zwischen dem geführten Assistenten
/// (Standard, für die meisten Nutzer passend) und dem Experten-Modus, der in den folgenden
/// Schritten zusätzliche, technischere RouterOS-Optionen einblendet. Die Wahl blockiert nie das
/// Weitergehen — der geführte Assistent ist vorausgewählt.
/// </summary>
public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
{
@@ -15,8 +14,8 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
public ModeSelectionStepViewModel(WizardSession session)
: base(
title: L.T("Modus wählen"),
description: L.T("Der geführte Assistent passt für die meisten Haushalte. Kennst du dich mit RouterOS aus, kannst du den Experten-Modus wählen — die folgenden Schritte zeigen dann zusätzliche, technischere Einstellungen."))
title: "Modus wählen",
description: "Der geführte Assistent passt für die meisten Haushalte. Kennst du dich mit RouterOS aus, kannst du den Experten-Modus wählen — die folgenden Schritte zeigen dann zusätzliche, technischere Einstellungen.")
{
_session = session;
_isExpertMode = session.IsExpertMode;
@@ -25,9 +24,6 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private bool _isExpertMode;
/// <summary>Ohne „Professioneller Netzwerker" gibt es nichts zu wählen — dann immer der geführte Assistent.</summary>
public override bool ShouldSkip => !UserSettings.Current.ProfessionalMode;
public override Task OnActivatedAsync()
{
IsExpertMode = _session.IsExpertMode;
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using System.Net;
using System.Windows;
@@ -7,15 +6,17 @@ using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Analysis;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Devices;
using MikrotikInstaller.Core.Expert;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Bereich „Übersicht“: liest aus, welche Netzwerke auf dem Gerät bereits eingerichtet sind, und
/// stellt sie als anklickbare, nach Kategorien gruppierte Grafik dar. Verändert nichts am Gerät, außer
/// über „Bearbeiten“ (nur mit „Professioneller Netzwerker“). Bei leerem Ergebnis zeigt die View einen
/// kurzen Hinweistext statt einer leeren Grafik.
/// Dritter Schritt: liest aus, welche Netzwerke auf einem Bestandsgerät bereits eingerichtet sind, und
/// stellt sie als anklickbare, nach Kategorien gruppierte Grafik dar. Rein informativ — verändert
/// nichts am Gerät. Kann nicht per <see cref="ShouldSkip"/> übersprungen werden, obwohl das Ergebnis
/// erst nach dem Auslesen feststeht: die Wizard-Navigation prüft <c>ShouldSkip</c>, bevor
/// <see cref="OnActivatedAsync"/> für den Ziel-Schritt läuft (siehe <c>MainViewModel.GoNextAsync</c>) —
/// zum Zeitpunkt der Prüfung wäre das Ergebnis der Analyse also noch gar nicht bekannt. Stattdessen
/// zeigt die View bei leerem Ergebnis einen kurzen Hinweistext statt einer leeren Grafik.
/// </summary>
public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
{
@@ -23,8 +24,8 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
public NetworkAnalysisStepViewModel(WizardSession session)
: base(
title: L.T("Vorhandene Netzwerke"),
description: L.T("So sieht es auf diesem Gerät aktuell aus, nach Kategorien geordnet. Klicke auf einen Punkt in der Grafik, um alles hervorzuheben, was mit ihm verbunden ist."))
title: "Vorhandene Netzwerke",
description: "So sieht es auf diesem Gerät aktuell aus, nach Kategorien geordnet. Klicke auf einen Punkt in der Grafik, um alles hervorzuheben, was mit ihm verbunden ist.")
{
_session = session;
}
@@ -57,12 +58,11 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
public override bool CanGoNext => !IsLoading;
public override async Task OnActivatedAsync()
{
if (_session.Client is null)
{
ErrorMessage = L.T("Keine Verbindung zum Gerät.");
ErrorMessage = "Keine Verbindung zum Gerät.";
HasExistingConfiguration = false;
return;
}
@@ -93,67 +93,11 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
}
/// <summary>Wird ausgelöst, wenn der Nutzer in der Grafik einen anderen Knoten anklickt (<c>NodifyEditor.SelectedItem</c>).</summary>
partial void OnSelectedTopologyItemChanged(object? value)
{
HighlightConnectedTo(value as TopologyNodeViewModel);
OnPropertyChanged(nameof(CanEditSelected));
}
partial void OnSelectedTopologyItemChanged(object? value) => HighlightConnectedTo(value as TopologyNodeViewModel);
[RelayCommand]
private void ClearSelection() => SelectedTopologyItem = null;
/// <summary>Bearbeiten im Diagramm (Issue #24, Mac „Übersicht“): nur mit „Professioneller Netzwerker“.</summary>
public bool CanEditSelected => UserSettings.Current.ProfessionalMode && SelectedTopologyItem is TopologyNodeViewModel { EditPath: not null };
/// <summary>Derselbe Bearbeiten-Bereich wie im Experte-Bereich, null wenn geschlossen.</summary>
[ObservableProperty]
private ExpertEditorViewModel? _editor;
[ObservableProperty]
private string? _editMessage;
[RelayCommand]
private async Task EditSelectedNodeAsync()
{
if (_session.Client is not { } client || SelectedTopologyItem is not TopologyNodeViewModel { EditPath: { } path, EditMatch: { } match })
{
return;
}
EditMessage = null;
try
{
var row = (await client.GetAsync(path)).FirstOrDefault(r => match.All(kv => kv.Key == "@bound"
? new[] { "to-addresses", "dst-address", "src-address" }.Any(k => r.GetValueOrDefault(k)?.Split('/')[0] == kv.Value.Split('/')[0])
: r.GetValueOrDefault(kv.Key) == kv.Value));
if (row is null)
{
EditMessage = L.T("Den Eintrag gibt es auf dem Router so nicht mehr — bitte die Übersicht neu laden.");
return;
}
var interfaces = (await client.GetAsync("/interface")).Select(i => i.GetValueOrDefault("name")).OfType<string>().ToList();
ExpertEditorViewModel? editor = null;
editor = new ExpertEditorViewModel(_session, path, MenuCatalog.Find(path), row, interfaces, async () =>
{
EditMessage = L.F("Gespeichert: {0}", editor!.LastSavedCommand);
await OnActivatedAsync();
});
editor.Closed += (_, _) =>
{
if (Editor == editor)
{
Editor = null;
}
};
Editor = editor;
}
catch (RouterOsException ex)
{
EditMessage = ex.Message;
}
}
/// <summary>
/// Hebt den gesamten mit dem angeklickten Knoten verbundenen Teilbaum hervor: die Eltern-Kette
/// bis zur Wurzel (der Weg zum Internet-Zugang, die "Hops") UND alle Nachfahren (alles, was von
@@ -267,7 +211,7 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
if (defaultRoute is not null)
{
var internetNode = new TopologyNodeViewModel("internet", L.T("Internet"), TopologyNodeKind.Internet, null, defaultRoute.Gateway);
var internetNode = new TopologyNodeViewModel("internet", "Internet", TopologyNodeKind.Internet, null, defaultRoute.Gateway);
nodesById[internetNode.Id] = internetNode;
}
@@ -294,18 +238,7 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
? $"{interfaceName} ({ssid})"
: interfaceName;
nodesById[id] = new TopologyNodeViewModel(id, label, kind, parentId)
{
EditPath = kind switch
{
TopologyNodeKind.Ethernet => "/interface/ethernet",
TopologyNodeKind.Bridge => "/interface/bridge",
TopologyNodeKind.Vlan => "/interface/vlan",
TopologyNodeKind.Wireless => "/interface/wireless",
_ => "/interface",
},
EditMatch = new Dictionary<string, string> { ["name"] = interfaceName },
};
nodesById[id] = new TopologyNodeViewModel(id, label, kind, parentId);
}
foreach (var address in snapshot.IpAddresses)
@@ -316,11 +249,7 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
}
var id = $"ip:{address.InterfaceName}:{address.AddressCidr}";
nodesById[id] = new TopologyNodeViewModel(id, address.AddressCidr, TopologyNodeKind.IpAddress, InterfaceNodeId(address.InterfaceName))
{
EditPath = "/ip/address",
EditMatch = new Dictionary<string, string> { ["address"] = address.AddressCidr, ["interface"] = address.InterfaceName },
};
nodesById[id] = new TopologyNodeViewModel(id, address.AddressCidr, TopologyNodeKind.IpAddress, InterfaceNodeId(address.InterfaceName));
}
foreach (var dhcp in snapshot.DhcpServers)
@@ -331,11 +260,7 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
}
var id = $"dhcp:{dhcp.Name}";
nodesById[id] = new TopologyNodeViewModel(id, dhcp.Name, TopologyNodeKind.DhcpServer, InterfaceNodeId(dhcp.InterfaceName), dhcp.AddressRange)
{
EditPath = "/ip/dhcp-server",
EditMatch = new Dictionary<string, string> { ["name"] = dhcp.Name },
};
nodesById[id] = new TopologyNodeViewModel(id, dhcp.Name, TopologyNodeKind.DhcpServer, InterfaceNodeId(dhcp.InterfaceName), dhcp.AddressRange);
}
// Firewall-/NAT-Regeln unter der IP-Adresse einhängen, deren Subnetz die gebundene Adresse
@@ -356,14 +281,10 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
var label = rule.Table == "nat"
? $"{rule.Protocol ?? "?"}{(string.IsNullOrWhiteSpace(rule.DstPort) ? string.Empty : $"/{rule.DstPort}")} → {rule.BoundAddress}"
: $"{rule.Chain} {rule.Action}";
var detail = rule.Table == "nat" ? L.T("Portfreigabe") : rule.BoundAddress;
var detail = rule.Table == "nat" ? "Portfreigabe" : rule.BoundAddress;
var id = $"fw:{firewallIndex}";
nodesById[id] = new TopologyNodeViewModel(id, label, TopologyNodeKind.Firewall, parentIpNode?.Id, detail)
{
EditPath = $"/ip/firewall/{rule.Table}",
EditMatch = new Dictionary<string, string> { ["chain"] = rule.Chain, ["action"] = rule.Action, ["@bound"] = rule.BoundAddress },
};
nodesById[id] = new TopologyNodeViewModel(id, label, TopologyNodeKind.Firewall, parentIpNode?.Id, detail);
}
AssignLayout(nodesById.Values, DiagramItems);
@@ -32,8 +32,4 @@ public partial class PortForwardEntry : ObservableObject
[ObservableProperty]
private string _internalPortRangeEnd = string.Empty;
/// <summary>Nicht leer, wenn eines der Felder dieser Zeile ein ungültiges Format hat.</summary>
[ObservableProperty]
private string? _validationMessage;
}
@@ -1,37 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Devices;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Ein Eintrag in der "Bekannte Router"-Liste im Verbindungs-Schritt.</summary>
public partial class SavedRouterEntry : ObservableObject
{
public SavedRouterEntry(SavedRouter router)
{
Id = router.Id;
Host = router.Host;
Username = router.Username;
SerialNumber = router.SerialNumber;
LastConnectedAt = router.LastConnectedAt;
_name = router.Name;
_location = router.Location ?? string.Empty;
}
public string Id { get; }
public string Host { get; }
public string Username { get; }
public string? SerialNumber { get; }
public DateTimeOffset? LastConnectedAt { get; }
public string Subtitle => $"{Host} · {Username}";
[ObservableProperty]
private string _name;
[ObservableProperty]
private string _location;
}
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -14,8 +13,7 @@ namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>
/// Letzter Schritt: zeigt alle in den vorherigen Schritten geplanten Änderungen im Klartext an.
/// Erst wenn der Nutzer hier ausdrücklich "Jetzt einrichten" klickt, wird ein Backup erstellt und
/// werden die Änderungen tatsächlich auf dem Gerät angewendet. Enthält nur, was in
/// besuchten Schritten gültig eingestellt wurde.
/// werden die Änderungen tatsächlich auf dem Gerät angewendet.
/// </summary>
public partial class SummaryStepViewModel : WizardStepViewModelBase
{
@@ -23,8 +21,8 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
public SummaryStepViewModel(WizardSession session)
: base(
title: L.T("Zusammenfassung"),
description: L.T("Das sind alle Änderungen, die wir jetzt an deinem Gerät vornehmen. Vor dem Anwenden erstellen wir automatisch ein Backup auf dem Gerät, falls du etwas rückgängig machen möchtest."))
title: "Zusammenfassung",
description: "Das sind alle Änderungen, die wir jetzt an deinem Gerät vornehmen. Vor dem Anwenden erstellen wir automatisch ein Backup auf dem Gerät, falls du etwas rückgängig machen möchtest.")
{
_session = session;
}
@@ -37,16 +35,6 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private string? _progressText;
/// <summary>Fortschrittsbalken: erledigte Schritte inkl. Sicherung (Mac v1.4.0).</summary>
[ObservableProperty]
private int _progressValue;
[ObservableProperty]
private int _progressMaximum = 1;
/// <summary>Abschluss-Übersicht: was tatsächlich ausgeführt wurde.</summary>
public ObservableCollection<string> CompletedSteps { get; } = [];
[ObservableProperty]
private string? _errorMessage;
@@ -56,7 +44,6 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
public override bool CanGoNext => IsFinished;
[ObservableProperty]
private string? _backupName;
@@ -72,17 +59,17 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
if (_session.WanSettings is { } wan)
{
Sections.Add(new SummarySection(L.T("Internet-Zugang"), SymbolRegular.Globe24, WanConfigurator.BuildFriendlySummary(wan)));
Sections.Add(new SummarySection("Internet-Zugang", SymbolRegular.Globe24, WanConfigurator.BuildFriendlySummary(wan)));
}
if (_session.LanSettings is { } lan)
{
Sections.Add(new SummarySection(L.T("Heimnetzwerk"), SymbolRegular.Home24, LanConfigurator.BuildFriendlySummary(lan)));
Sections.Add(new SummarySection("Heimnetzwerk", SymbolRegular.Home24, LanConfigurator.BuildFriendlySummary(lan)));
}
if (_session.VlanDefinitions is { Count: > 0 } vlans)
{
Sections.Add(new SummarySection(L.T("Zusätzliche Netzwerke"), SymbolRegular.HomeSplit24, VlanConfigurator.BuildFriendlySummary(vlans)));
Sections.Add(new SummarySection("Zusätzliche Netzwerke", SymbolRegular.HomeSplit24, VlanConfigurator.BuildFriendlySummary(vlans)));
}
if (_session.WirelessSettings is { } wireless)
@@ -92,7 +79,7 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
if (_session.FirewallSettings is { } firewall)
{
Sections.Add(new SummarySection(L.T("Firewall"), SymbolRegular.ShieldCheckmark24, FirewallConfigurator.BuildFriendlySummary(firewall)));
Sections.Add(new SummarySection("Firewall", SymbolRegular.ShieldCheckmark24, FirewallConfigurator.BuildFriendlySummary(firewall)));
}
IsFinished = false;
@@ -114,29 +101,19 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
}
IsApplying = true;
_session.IsDeviceBusy = true;
ErrorMessage = null;
CompletedSteps.Clear();
ApplyCommand.NotifyCanExecuteChanged();
try
{
ProgressText = "Sicherung wird auf dem Gerät erstellt …";
BackupName = await RouterBackupService.CreateBackupAsync(_session.Client);
var actions = GetAllActions();
ProgressMaximum = actions.Count + 1;
ProgressValue = 0;
// Die Sicherung ist ein eigener, sichtbarer Schritt.
ProgressText = L.F("Schritt 1 von {0}: Sicherung auf dem Gerät erstellen", ProgressMaximum);
BackupName = await _session.CreateSafetyBackupAsync(_session.Client);
CompletedSteps.Add(L.F("Sicherung „{0}“ auf dem Gerät erstellt", BackupName));
ProgressValue = 1;
for (var i = 0; i < actions.Count; i++)
{
ProgressText = L.F("Schritt {0} von {1}: {2}", i + 2, ProgressMaximum, actions[i].Description);
ProgressText = $"Schritt {i + 1} von {actions.Count}: {actions[i].Description}";
await actions[i].ApplyAsync(_session.Client, CancellationToken.None);
CompletedSteps.Add(actions[i].Description);
ProgressValue = i + 2;
}
ProgressText = null;
@@ -150,12 +127,11 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
{
ErrorMessage = BackupName is null
? ex.Message
: L.F("{0} Falls dein Gerät jetzt nicht wie erwartet funktioniert, kannst du auf dem Gerät selbst (z. B. über WinBox → Files, oder das Gerätedisplay) das automatisch erstellte Backup „{1}\" wiederherstellen.", ex.Message, BackupName);
: $"{ex.Message} Falls dein Gerät jetzt nicht wie erwartet funktioniert, kannst du auf dem Gerät selbst (z. B. über WinBox → Files, oder das Gerätedisplay) das automatisch erstellte Backup „{BackupName}\" wiederherstellen.";
}
finally
{
IsApplying = false;
_session.IsDeviceBusy = false;
ApplyCommand.NotifyCanExecuteChanged();
}
}
@@ -167,8 +143,8 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = L.T("Einrichtungsprotokoll speichern"),
Filter = L.T("PDF-Datei (*.pdf)|*.pdf"),
Title = "Einrichtungsprotokoll speichern",
Filter = "PDF-Datei (*.pdf)|*.pdf",
FileName = $"MikroTik-Einrichtung-{DateTime.Now:yyyy-MM-dd}.pdf",
};
@@ -180,7 +156,7 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
try
{
var data = new SummaryDocumentData(
_session.Device?.IdentityName ?? L.T("MikroTik"),
_session.Device?.IdentityName ?? "MikroTik",
_session.Device?.BoardModel ?? "unbekannt",
_session.WanSettings,
_session.LanSettings,
@@ -192,12 +168,12 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
SummaryPdfExporter.Export(data, dialog.FileName);
PdfExportIsError = false;
PdfExportMessage = L.F("Gespeichert unter „{0}\".", dialog.FileName);
PdfExportMessage = $"Gespeichert unter „{dialog.FileName}\".";
}
catch (Exception ex)
{
PdfExportIsError = true;
PdfExportMessage = L.F("PDF konnte nicht erstellt werden: {0}", ex.Message);
PdfExportMessage = $"PDF konnte nicht erstellt werden: {ex.Message}";
}
}
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Gemeinsame Layout-Maße für die IST-Zustand-Grafik (Node-Größe im XAML-Template gleich halten).</summary>
@@ -19,11 +18,11 @@ internal static class TopologyLayout
/// <summary>Kategorie-Reihenfolge (von links nach rechts) samt laientauglicher Beschriftung.</summary>
public static readonly (TopologyNodeKind[] Kinds, string Label)[] Bands =
[
([TopologyNodeKind.Internet], L.T("Internet")),
([TopologyNodeKind.Ethernet, TopologyNodeKind.Bridge, TopologyNodeKind.Vlan, TopologyNodeKind.Wireless, TopologyNodeKind.Other], L.T("Netzwerk-Schnittstellen")),
([TopologyNodeKind.IpAddress], L.T("IP-Adressen")),
([TopologyNodeKind.DhcpServer], L.T("DHCP-Server")),
([TopologyNodeKind.Firewall], L.T("Firewall-Regeln")),
([TopologyNodeKind.Internet], "Internet"),
([TopologyNodeKind.Ethernet, TopologyNodeKind.Bridge, TopologyNodeKind.Vlan, TopologyNodeKind.Wireless, TopologyNodeKind.Other], "Netzwerk-Schnittstellen"),
([TopologyNodeKind.IpAddress], "IP-Adressen"),
([TopologyNodeKind.DhcpServer], "DHCP-Server"),
([TopologyNodeKind.Firewall], "Firewall-Regeln"),
];
public static int BandIndexOf(TopologyNodeKind kind)
@@ -18,12 +18,6 @@ public partial class TopologyNodeViewModel(string id, string label, TopologyNode
/// <summary>Zusätzliche, laientaugliche Zusatzinfo für die Grafik (z. B. DHCP-Adressbereich, Gateway-IP).</summary>
public string? Detail { get; } = detail;
/// <summary>RouterOS-Menüpfad des Eintrags hinter diesem Knoten, null wenn nicht bearbeitbar (Issue #24).</summary>
public string? EditPath { get; init; }
/// <summary>Felder, an denen der Eintrag wiedergefunden wird; "@bound" = eine der Adressfelder einer Firewall-Regel.</summary>
public IReadOnlyDictionary<string, string>? EditMatch { get; init; }
[ObservableProperty]
private Point _location;
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Connectivity;
@@ -17,8 +16,8 @@ public partial class UpdateStepViewModel : WizardStepViewModelBase
public UpdateStepViewModel(WizardSession session)
: base(
title: L.T("RouterOS-Update"),
description: L.T("Falls eine neuere RouterOS-Version verfügbar ist, kannst du sie hier optional installieren. Das ist kein Muss — dein Gerät ist bereits fertig eingerichtet."))
title: "RouterOS-Update",
description: "Falls eine neuere RouterOS-Version verfügbar ist, kannst du sie hier optional installieren. Das ist kein Muss — dein Gerät ist bereits fertig eingerichtet.")
{
_session = session;
}
@@ -121,29 +120,15 @@ public partial class UpdateStepViewModel : WizardStepViewModelBase
try
{
_session.IsDeviceBusy = true;
await RouterOsUpdateService.StartUpdateAsync(_session.Client);
IsUpdateStarted = true;
// Herunterladen + Neustart dauern; danach von selbst wieder verbinden (Mac v1.6.0).
if (await _session.ReconnectAsync(TimeSpan.FromSeconds(30)))
{
await RecheckAsync();
}
}
catch (RouterOsException ex) when (!ex.IsConnectionLost)
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
catch (RouterOsException)
{
// Erwartet: das Gerät startet für das Update neu.
IsUpdateStarted = true;
await _session.ReconnectAsync(TimeSpan.FromSeconds(30));
}
finally
{
_session.IsDeviceBusy = false;
IsUpdating = false;
StartUpdateCommand.NotifyCanExecuteChanged();
}
@@ -31,13 +31,8 @@ public partial class VlanEntry : ObservableObject
[ObservableProperty]
private string _dnsServerOverride = string.Empty;
/// <summary>DHCP-Lease-Zeit als Jahre/Tage/Stunden; Änderungen melden sich als Änderung von <see cref="Lease"/>.</summary>
public LeaseTimeEditor Lease { get; } = new();
public VlanEntry()
{
Lease.PropertyChanged += (_, _) => OnPropertyChanged(nameof(Lease));
}
[ObservableProperty]
private string _dhcpLeaseTime = string.Empty;
/// <summary>
/// Anschlüsse, die zur Auswahl stehen, um sie fest (untagged/portbasiert) diesem VLAN zuzuordnen.
@@ -48,8 +43,4 @@ public partial class VlanEntry : ObservableObject
/// <summary>Nicht leer, wenn ein hier gewählter Anschluss bereits einem anderen VLAN zugeordnet ist.</summary>
[ObservableProperty]
private string? _portConflictMessage;
/// <summary>Nicht leer, wenn eines der Adress-/Bereichsfelder dieser Zeile ein ungültiges Format hat.</summary>
[ObservableProperty]
private string? _formatValidationMessage;
}
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Configuration;
@@ -16,8 +15,8 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
public VlanStepViewModel(WizardSession session)
: base(
title: L.T("VLANs"),
description: L.T("Optional: Lege zusätzliche, vom Heimnetz getrennte Netzwerke an, z. B. für Gäste oder smarte Geräte. Füge keinen Eintrag hinzu, wenn du das nicht brauchst."))
title: "VLANs",
description: "Optional: Lege zusätzliche, vom Heimnetz getrennte Netzwerke an, z. B. für Gäste oder smarte Geräte. Füge keinen Eintrag hinzu, wenn du das nicht brauchst.")
{
_session = session;
}
@@ -28,7 +27,6 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
public override bool CanGoNext => Vlans.All(IsValid);
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
@@ -51,17 +49,16 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
var entry = new VlanEntry
{
VlanId = (10 + (Vlans.Count * 10)).ToString(),
Name = L.F("Netzwerk {0}", Vlans.Count + 1),
Name = $"Netzwerk {Vlans.Count + 1}",
RouterAddress = $"192.168.{octet}.1",
PoolStart = $"192.168.{octet}.10",
PoolEnd = $"192.168.{octet}.254",
};
// PortConflictMessage/FormatValidationMessage werden von SaveToSession selbst geschrieben
// (siehe UpdatePortConflicts/UpdateFormatValidation) — davon ausgenommen, sonst würde sich
// SaveToSession bei jeder Neuberechnung selbst erneut auslösen.
// PortConflictMessage wird von SaveToSession selbst geschrieben (siehe UpdatePortConflicts) —
// davon ausgenommen, sonst würde sich SaveToSession bei jedem Konflikt selbst neu auslösen.
entry.PropertyChanged += (_, args) =>
{
if (args.PropertyName is not (nameof(VlanEntry.PortConflictMessage) or nameof(VlanEntry.FormatValidationMessage)))
if (args.PropertyName != nameof(VlanEntry.PortConflictMessage))
{
SaveToSession();
}
@@ -118,59 +115,21 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
entry.PortConflictMessage = conflictingPorts.Count == 0
? null
: L.F("Anschluss {0} ist bereits einem anderen Netzwerk zugeordnet — wähle ihn nur bei einem VLAN aus.", string.Join(", ", conflictingPorts.Select(p => $"„{p}\"")));
}
}
/// <summary>Formatprüfung eines VLAN-Eintrags — IP-/Präfix-/Bereichsangaben, keine Portkonflikte (siehe <see cref="UpdatePortConflicts"/>).</summary>
private static string? ComputeFormatValidationMessage(VlanEntry entry)
{
if (!NetworkValidation.IsValidIPv4(entry.RouterAddress))
{
return L.T("Router-Adresse: keine gültige IP-Adresse.");
}
if (!NetworkValidation.IsValidPrefixLength(entry.PrefixLength))
{
return L.T("Präfix: bitte eine Zahl zwischen 0 und 32 eingeben.");
}
if (!NetworkValidation.IsValidIPv4(entry.PoolStart) || !NetworkValidation.IsValidIPv4(entry.PoolEnd))
{
return L.T("Adressbereich: bitte gültige IP-Adressen für Anfang und Ende eingeben.");
}
if (!NetworkValidation.IsOrderedRange(entry.PoolStart, entry.PoolEnd))
{
return L.T("Adressbereich: die erste Adresse muss vor (oder gleich) der letzten liegen.");
}
if (!string.IsNullOrWhiteSpace(entry.DnsServerOverride) && !NetworkValidation.IsValidIPv4(entry.DnsServerOverride))
{
return L.T("DNS-Server-Override: keine gültige IP-Adresse.");
}
return null;
}
private void UpdateFormatValidation()
{
foreach (var entry in Vlans)
{
entry.FormatValidationMessage = ComputeFormatValidationMessage(entry);
: $"Anschluss {string.Join(", ", conflictingPorts.Select(p => $"„{p}\""))} ist bereits einem anderen Netzwerk zugeordnet — wähle ihn nur bei einem VLAN aus.";
}
}
private static bool IsValid(VlanEntry entry) =>
int.TryParse(entry.VlanId, out var id) && id is >= 2 and <= 4094
&& !string.IsNullOrWhiteSpace(entry.Name)
&& entry.PortConflictMessage is null
&& entry.FormatValidationMessage is null;
&& !string.IsNullOrWhiteSpace(entry.RouterAddress)
&& !string.IsNullOrWhiteSpace(entry.PoolStart)
&& !string.IsNullOrWhiteSpace(entry.PoolEnd)
&& entry.PortConflictMessage is null;
private void SaveToSession()
{
UpdatePortConflicts();
UpdateFormatValidation();
OnPropertyChanged(nameof(CanGoNext));
if (!CanGoNext || _session.LanBridgeName is null)
@@ -189,7 +148,7 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
v.PoolEnd,
v.IsolateFromLan,
string.IsNullOrWhiteSpace(v.DnsServerOverride) ? null : v.DnsServerOverride,
v.Lease.Value,
string.IsNullOrWhiteSpace(v.DhcpLeaseTime) ? null : v.DhcpLeaseTime,
v.AccessPortOptions.Where(p => p.IsSelected).Select(p => p.Name).ToList()))
.ToList();
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Configuration;
@@ -18,8 +17,8 @@ public partial class WanStepViewModel : WizardStepViewModelBase
public WanStepViewModel(WizardSession session)
: base(
title: L.T("Internet-Zugang"),
description: L.T("Wähle die Schnittstelle, an der dein Internet-Kabel (vom Modem/Router deines Anbieters) angeschlossen ist."))
title: "Internet-Zugang",
description: "Wähle die Schnittstelle, an der dein Internet-Kabel (vom Modem/Router deines Anbieters) angeschlossen ist.")
{
_session = session;
}
@@ -53,11 +52,9 @@ public partial class WanStepViewModel : WizardStepViewModelBase
public bool IsExpertMode => _session.IsExpertMode;
[ObservableProperty]
private string? _validationMessage;
public override bool CanGoNext => ValidationMessage is null;
public override bool CanGoNext =>
!string.IsNullOrWhiteSpace(SelectedInterface)
&& (!UseStaticAddress || (!string.IsNullOrWhiteSpace(StaticAddress) && !string.IsNullOrWhiteSpace(StaticGateway)));
public override Task OnActivatedAsync()
{
@@ -90,42 +87,8 @@ public partial class WanStepViewModel : WizardStepViewModelBase
partial void OnNatSourceAddressChanged(string value) => SaveToSession();
private string? ComputeValidationMessage()
{
if (string.IsNullOrWhiteSpace(SelectedInterface))
{
return L.T("Bitte eine Internet-Schnittstelle auswählen.");
}
if (UseStaticAddress)
{
if (!NetworkValidation.IsValidCidr(StaticAddress))
{
return L.T("Feste IP-Adresse: bitte im Format „Adresse/Präfix\" eingeben, z. B. „203.0.113.5/24\".");
}
if (!NetworkValidation.IsValidIPv4(StaticGateway))
{
return L.T("Gateway: keine gültige IP-Adresse.");
}
}
if (!string.IsNullOrWhiteSpace(Mtu) && !(int.TryParse(Mtu, out var mtu) && mtu is >= 68 and <= 65535))
{
return L.T("MTU: bitte eine Zahl zwischen 68 und 65535 eingeben, oder leer lassen.");
}
if (!string.IsNullOrWhiteSpace(NatSourceAddress) && !NetworkValidation.IsValidCidr(NatSourceAddress))
{
return L.T("NAT-Subnetz: bitte im Format „Adresse/Präfix\" eingeben, z. B. „192.168.88.0/24\".");
}
return null;
}
private void SaveToSession()
{
ValidationMessage = ComputeValidationMessage();
OnPropertyChanged(nameof(CanGoNext));
_session.WanInterfaceName = SelectedInterface;
@@ -1,4 +1,3 @@
using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using MikrotikInstaller.Core.Configuration;
@@ -18,7 +17,7 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
public WlanStepViewModel(WizardSession session)
: base(
title: "WLAN",
description: L.T("Lege den Namen (SSID) und das Passwort für dein WLAN fest. Das Passwort muss mindestens 8 Zeichen lang sein."))
description: "Lege den Namen (SSID) und das Passwort für dein WLAN fest. Das Passwort muss mindestens 8 Zeichen lang sein.")
{
_session = session;
}
@@ -51,7 +50,6 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
public bool IsExpertMode => _session.IsExpertMode;
/// <summary>Auch bei "Backup wiederherstellen" überspringen — nicht nur bei fehlendem WLAN.</summary>
public override bool ShouldSkip => _session.Device?.HasWireless != true;
public override bool CanGoNext =>
@@ -1,5 +1,3 @@
using MikrotikInstaller.Core.Localization;
using MikrotikInstaller.Core.Backup;
using MikrotikInstaller.Core.Configuration;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Devices;
@@ -16,27 +14,13 @@ public sealed class WizardSession
{
public IRouterOsClient? Client { get; set; }
/// <summary>Die zuletzt erfolgreich verwendeten Zugangsdaten — für "Bekannte Router" im
/// Geräte-Erkennungs-Schritt, sobald dort Modell/Seriennummer bekannt sind.</summary>
public RouterOsCredentials? Credentials { get; set; }
/// <summary>"Passwort merken" aus dem Verbindungs-Schritt: aus = ein früher gespeichertes Passwort wird gelöscht.</summary>
public bool RememberPassword { get; set; } = true;
public RouterDevice? Device { get; set; }
/// <summary>
/// Ob der Nutzer im Moduswahl-Schritt den Experten-Modus gewählt hat. Steuert, ob die
/// nachfolgenden Schritte zusätzliche, technischere Felder anzeigen. Standard: Laien-Modus (false).
/// </summary>
public bool IsExpertMode
{
// Ohne "Professioneller Netzwerker" (Einstellungen) gibt es keinen Experten-Modus (Mac v1.3.0).
get => _isExpertMode && UserSettings.Current.ProfessionalMode;
set => _isExpertMode = value;
}
private bool _isExpertMode;
public bool IsExpertMode { get; set; }
/// <summary>Vom WAN-Schritt gewählte Internet-Schnittstelle, damit spätere Schritte sie referenzieren können.</summary>
public string? WanInterfaceName { get; set; }
@@ -73,100 +57,6 @@ public sealed class WizardSession
/// </summary>
public RouterOsUpdateInfo? UpdateInfo { get; set; }
/// <summary>Verbindungsstatus für die Sidebar (Wiederverbinden nach Neustart/Abbruch).</summary>
public ConnectionState State { get; } = new();
/// <summary>Während Backup, Restore, Werksreset oder Anwenden pausiert die Herzschlag-Prüfung (Mac v1.6.0).</summary>
public bool IsDeviceBusy { get; set; }
/// <summary>Die Verbindung ist endgültig vorbei (z. B. nach Werksreset) — die Sidebar zeigt wieder „Nicht verbunden".</summary>
public event EventHandler? ConnectionEnded;
public async Task EndConnectionAsync()
{
await DisposeClientAsync();
Device = null;
State.StatusText = null;
ConnectionEnded?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Baut die Verbindung mit den zuletzt genutzten Zugangsdaten neu auf, z. B. nach einem Neustart.
/// <paramref name="initialDelay"/> gibt dem Gerät Zeit, tatsächlich herunterzufahren — sonst würde man
/// sich noch mit dem alten, gerade abschaltenden System verbinden. Gibt false zurück, wenn das Gerät
/// nach etwa 5 Minuten noch nicht erreichbar ist.
/// </summary>
public async Task<bool> ReconnectAsync(TimeSpan initialDelay)
{
if (Credentials is not { } credentials || State.IsReconnecting)
{
return false;
}
State.IsReconnecting = true;
State.StatusText = L.T("Warte auf das Gerät …");
await DisposeClientAsync();
try
{
Client = await RouterOsReconnector.ReconnectAsync(
ct => RouterOsClientFactory.ConnectAsync(credentials, ct),
initialDelay,
retryDelay: TimeSpan.FromSeconds(5),
maxAttempts: 60,
attempt => State.StatusText = L.F("Verbindung wird wiederhergestellt … (Versuch {0})", attempt));
State.StatusText = null;
return true;
}
catch (RouterOsException)
{
State.StatusText = L.T("Gerät nicht erreichbar. Bitte über „Wechseln“ neu verbinden.");
return false;
}
finally
{
State.IsReconnecting = false;
}
}
/// <summary>Vor der ersten Änderung im Experte-Bereich/in der Übersicht wurde schon gesichert.</summary>
public bool ExpertBackupDone { get; set; }
/// <summary>Zuletzt von Hand erstelltes Backup (Name, Zeitpunkt).</summary>
public (string Name, DateTimeOffset At)? LastManualBackup { get; set; }
/// <summary>
/// Sicherung vor einer Änderung. Eine von Hand erstellte Sicherung ersetzt 10 Minuten lang die
/// automatische, damit nicht für jeden Handgriff ein weiteres Backup entsteht (Mac v1.4.0).
/// Gibt den Namen der verwendeten Sicherung zurück.
/// </summary>
public async Task<string> CreateSafetyBackupAsync(IRouterOsClient client)
{
if (LastManualBackup is { } manual && DateTimeOffset.Now - manual.At < TimeSpan.FromMinutes(10))
{
return manual.Name;
}
return await RouterBackupService.CreateBackupAsync(
client, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber, UserSettings.BackupPassword);
}
/// <summary>Vergisst den Einrichtungsplan des vorherigen Geräts, damit nach einem Gerätewechsel nichts davon angewendet wird.</summary>
public void ResetPlan()
{
IsExpertMode = false;
WanInterfaceName = LanBridgeName = null;
WanActions = LanActions = VlanActions = WirelessActions = FirewallActions = null;
WanSettings = null;
LanSettings = null;
VlanDefinitions = null;
WirelessSettings = null;
FirewallSettings = null;
UpdateInfo = null;
ExpertBackupDone = false;
LastManualBackup = null;
}
public async Task DisposeClientAsync()
{
if (Client is not null)
@@ -1,63 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Controls.ExpertEditorView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls">
<!-- DataContext: ExpertEditorViewModel. -->
<Border Background="{DynamicResource ControlFillColorSecondaryBrush}" CornerRadius="8" Padding="14" Margin="0,0,0,12">
<StackPanel>
<TextBlock FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10">
<Run Text="{Binding Title, Mode=OneWay}" />
<Run Text="{Binding Path, Mode=OneWay}" FontSize="12" FontFamily="Consolas" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<ItemsControl ItemsSource="{Binding Fields}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" MaxWidth="170" />
<controls:InfoHint Text="{Binding Help}" />
</StackPanel>
<ui:TextBox Grid.Column="1" Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding IsText, Converter={StaticResource BoolToVisibilityConverter}}" />
<ComboBox Grid.Column="1" IsEditable="True" ItemsSource="{Binding Options}" Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding HasOptions, Converter={StaticResource BoolToVisibilityConverter}}" />
<CheckBox Grid.Column="1" IsChecked="{Binding IsChecked}"
Visibility="{Binding IsBool, Converter={StaticResource BoolToVisibilityConverter}}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="10" Margin="0,4,0,10">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Weitere Parameter (eine Zeile je name=wert)'}" />
<controls:InfoHint Text="{l:T 'Für Felder, die oben nicht aufgeführt sind, z. B. „comment=Gäste“. Die Namen entsprechen den RouterOS-Parametern.'}" />
</StackPanel>
<ui:TextBox Text="{Binding ExtraParameters, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True" MinLines="2" FontFamily="Consolas" />
</StackPanel>
</Border>
<TextBlock Text="{l:T 'Wird ausgeführt:'}" FontWeight="SemiBold" Margin="0,0,0,4"
Visibility="{Binding PreviewText, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding PreviewText}" FontFamily="Consolas" TextWrapping="Wrap" Margin="0,0,0,10"
Visibility="{Binding PreviewText, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{l:T 'Keine Änderung — nichts zu speichern.'}" FontSize="12" Margin="0,0,0,10" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding PreviewText, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,10"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<StackPanel Orientation="Horizontal">
<ui:Button Content="{l:T 'Speichern'}" Appearance="Primary" Margin="0,0,8,0" Command="{Binding SaveCommand}" />
<ui:Button Content="{l:T 'Schließen'}" Command="{Binding CloseCommand}" />
<ui:ProgressRing IsIndeterminate="True" Width="18" Height="18" Margin="12,0,0,0"
Visibility="{Binding IsSaving, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
</StackPanel>
</Border>
</UserControl>
@@ -1,9 +0,0 @@
namespace MikrotikInstaller.App.Views.Controls;
public partial class ExpertEditorView : System.Windows.Controls.UserControl
{
public ExpertEditorView()
{
InitializeComponent();
}
}
@@ -1,15 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Controls.LeaseTimePicker"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
<!-- DataContext: LeaseTimeEditor. Alles 0 = RouterOS-Standardwert. -->
<StackPanel Orientation="Horizontal">
<ui:NumberBox Value="{Binding Years, UpdateSourceTrigger=PropertyChanged}" Minimum="0" Maximum="10" MaxDecimalPlaces="0" Width="110" SpinButtonPlacementMode="Compact" />
<TextBlock Text="{l:T 'Jahre'}" VerticalAlignment="Center" Margin="6,0,16,0" />
<ui:NumberBox Value="{Binding Days, UpdateSourceTrigger=PropertyChanged}" Minimum="0" Maximum="364" MaxDecimalPlaces="0" Width="110" SpinButtonPlacementMode="Compact" />
<TextBlock Text="{l:T 'Tage'}" VerticalAlignment="Center" Margin="6,0,16,0" />
<ui:NumberBox Value="{Binding Hours, UpdateSourceTrigger=PropertyChanged}" Minimum="0" Maximum="23" MaxDecimalPlaces="0" Width="110" SpinButtonPlacementMode="Compact" />
<TextBlock Text="{l:T 'Stunden'}" VerticalAlignment="Center" Margin="6,0,0,0" />
</StackPanel>
</UserControl>
@@ -1,11 +0,0 @@
using System.Windows.Controls;
namespace MikrotikInstaller.App.Views.Controls;
public partial class LeaseTimePicker : UserControl
{
public LeaseTimePicker()
{
InitializeComponent();
}
}
@@ -1,71 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.HelpView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
<TabControl Margin="0,16,0,0">
<TabItem Header="{l:T 'Handbuch'}">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="8,8,16,8">
<ItemsControl ItemsSource="{Binding Manual}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock x:Name="Block" Text="{Binding Text}" TextWrapping="Wrap" Margin="0,0,0,8" />
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Kind}" Value="Heading1">
<Setter TargetName="Block" Property="FontSize" Value="24" />
<Setter TargetName="Block" Property="FontWeight" Value="SemiBold" />
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Heading2">
<Setter TargetName="Block" Property="FontSize" Value="19" />
<Setter TargetName="Block" Property="FontWeight" Value="SemiBold" />
<Setter TargetName="Block" Property="Margin" Value="0,16,0,8" />
<Setter TargetName="Block" Property="Foreground" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Heading3">
<Setter TargetName="Block" Property="FontSize" Value="15" />
<Setter TargetName="Block" Property="FontWeight" Value="SemiBold" />
<Setter TargetName="Block" Property="Margin" Value="0,10,0,6" />
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Bullet">
<Setter TargetName="Block" Property="Margin" Value="16,0,0,6" />
<Setter TargetName="Block" Property="Text" Value="{Binding Text, StringFormat=• {0}}" />
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Numbered">
<Setter TargetName="Block" Property="Margin" Value="16,0,0,6" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</TabItem>
<TabItem Header="{l:T 'Begriffe'}">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ui:TextBox Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="{l:T 'Begriff suchen, z. B. DHCP'}" Margin="0,0,0,12">
<ui:TextBox.Icon>
<ui:SymbolIcon Symbol="Search24" />
</ui:TextBox.Icon>
</ui:TextBox>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Terms}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource ControlFillColorSecondaryBrush}" CornerRadius="6" Padding="12,8" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Text}" TextWrapping="Wrap" Margin="0,4,0,0" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</TabItem>
</TabControl>
</UserControl>
@@ -1,9 +0,0 @@
namespace MikrotikInstaller.App.Views;
public partial class HelpView : System.Windows.Controls.UserControl
{
public HelpView()
{
InitializeComponent();
}
}
@@ -1,62 +0,0 @@
<ui:FluentWindow x:Class="MikrotikInstaller.App.Views.IntroWindow"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:vm="clr-namespace:MikrotikInstaller.App.ViewModels"
Title="{l:T 'Willkommen'}" Width="560" Height="560"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica">
<!-- Einführung beim ersten Start (Mac v1.4.0): dieselben Einstellungen wie im Einstellungen-Fenster,
erscheint bei jedem Start, bis "Fertig" oder "Überspringen" gedrückt wurde. -->
<Window.DataContext>
<vm:SettingsViewModel />
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="{l:T 'Willkommen'}" ShowMinimize="False" ShowMaximize="False" />
<StackPanel Grid.Row="1" Margin="28,8,28,8">
<TextBlock Text="{l:T 'Willkommen beim MikroTik Installer'}" FontSize="22" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock TextWrapping="Wrap" Margin="0,0,0,20" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{l:T 'Kurz ein paar Grundeinstellungen — alles lässt sich später unter „Einstellungen“ in der Seitenleiste ändern.'}" />
<TextBlock Text="{l:T 'Sprache'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<ComboBox ItemsSource="{Binding Languages}" DisplayMemberPath="Name" SelectedValuePath="Code"
SelectedValue="{Binding Language}" HorizontalAlignment="Left" MinWidth="220" Margin="0,0,0,20" />
<TextBlock Text="{l:T 'Darstellung'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<RadioButton Content="{l:T 'Wie System'}" GroupName="IntroTheme" Margin="0,0,16,0"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=System}" />
<RadioButton Content="{l:T 'Hell'}" GroupName="IntroTheme" Margin="0,0,16,0"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Light}" />
<RadioButton Content="{l:T 'Dunkel'}" GroupName="IntroTheme"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Dark}" />
</StackPanel>
<TextBlock Text="{l:T 'Experten-Funktionen'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<CheckBox Content="{l:T 'Ich bin professioneller Netzwerker'}" IsChecked="{Binding ProfessionalMode}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Margin="28,2,0,20" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{l:T 'Blendet zusätzliche, technischere RouterOS-Einstellungen ein. Für die meisten Haushalte nicht nötig.'}" />
<TextBlock Text="{l:T 'Updates'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<CheckBox Content="{l:T 'Beim Verbinden nach RouterOS-Updates suchen'}" IsChecked="{Binding CheckUpdatesOnConnect}" Margin="0,0,0,20" />
<TextBlock Text="{l:T 'Ausprobieren'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<CheckBox Content="{l:T 'Testumgebung anbieten (simulierte Router ohne eigene Hardware)'}" IsChecked="{Binding TestEnvironment}" />
</StackPanel>
<Border Grid.Row="2" BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="28,16">
<ui:Button Content="{l:T 'Überspringen'}" Margin="0,0,8,0" Click="OnSkip" />
<ui:Button Content="{l:T 'Fertig'}" Appearance="Primary" IsDefault="True" Click="OnDone" />
</StackPanel>
</Border>
</Grid>
</ui:FluentWindow>
@@ -1,28 +0,0 @@
using System.Windows;
using MikrotikInstaller.App.ViewModels;
using Wpf.Ui.Controls;
namespace MikrotikInstaller.App.Views;
public partial class IntroWindow : FluentWindow
{
public IntroWindow()
{
InitializeComponent();
}
/// <summary>Übernimmt die gewählten Einstellungen und merkt sich, dass die Einführung erledigt ist.</summary>
private void OnDone(object sender, RoutedEventArgs e)
{
((SettingsViewModel)DataContext).SaveCommand.Execute(null);
MarkCompleted();
}
private void OnSkip(object sender, RoutedEventArgs e) => MarkCompleted();
private void MarkCompleted()
{
UserSettings.Save(UserSettings.Current with { IntroCompleted = true });
Close();
}
}
@@ -1,66 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.SettingsView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls">
<StackPanel Margin="0,16,0,0" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock Text="{l:T 'Sprache'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<ComboBox ItemsSource="{Binding Languages}" DisplayMemberPath="Name" SelectedValuePath="Code"
SelectedValue="{Binding Language}" HorizontalAlignment="Left" MinWidth="220" Margin="0,0,0,20" />
<TextBlock Text="{l:T 'Darstellung'}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<RadioButton Content="{l:T 'Wie System'}" GroupName="Theme" Margin="0,0,16,0"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=System}" />
<RadioButton Content="{l:T 'Hell'}" GroupName="Theme" Margin="0,0,16,0"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Light}" />
<RadioButton Content="{l:T 'Dunkel'}" GroupName="Theme"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Dark}" />
</StackPanel>
<TextBlock Text="{l:T 'Allgemein'}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="{l:T 'Beim Verbinden nach RouterOS-Updates suchen'}" IsChecked="{Binding CheckUpdatesOnConnect}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="{l:T 'Professioneller Netzwerker'}" IsChecked="{Binding ProfessionalMode}" />
<controls:InfoHint Text="{l:T 'Blendet den Experten-Modus im Einrichtungs-Assistenten ein: zusätzliche, technischere RouterOS-Einstellungen in den einzelnen Schritten. Für die meisten Haushalte nicht nötig.'}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="{l:T 'Testumgebung anbieten'}" IsChecked="{Binding TestEnvironment}" />
<controls:InfoHint Text="{l:T 'Zeigt im Verbindungs-Schritt simulierte Testgeräte, mit denen du den Assistenten ohne eigenen Router ausprobieren kannst. Änderungen bleiben nur bis zum Beenden der App erhalten.'}" />
</StackPanel>
<CheckBox Content="{l:T 'Einführung beim nächsten Start erneut zeigen'}" IsChecked="{Binding RestartIntro}" Margin="0,0,0,20" />
<TextBlock Text="{l:T 'Sicherungen'}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="{l:T 'Ordner für Backups auf diesem PC'}" Margin="0,0,0,4" />
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBox Grid.Column="0" Text="{Binding BackupFolder, UpdateSourceTrigger=PropertyChanged}" />
<ui:Button Grid.Column="1" Content="{l:T 'Wählen …'}" Margin="8,0,0,0" Command="{Binding ChooseBackupFolderCommand}" />
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Backup-Passwort'}" />
<controls:InfoHint Text="{l:T 'Mit Passwort werden neue Backups von RouterOS verschlüsselt — auf dem Gerät und als Kopie auf diesem PC. Ohne das Passwort lassen sie sich nicht mehr zurückspielen, bewahre es also gut auf.'}" />
</StackPanel>
<TextBlock Text="{l:T 'Ein Backup-Passwort ist gesetzt. Leer lassen, um es zu behalten.'}" FontSize="12" Margin="0,0,0,4"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding HasBackupPassword, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{l:T 'Kein Backup-Passwort gesetzt — neue Backups sind unverschlüsselt.'}" FontSize="12" Margin="0,0,0,4"
Foreground="{StaticResource MikrotikWarningBrush}"
Visibility="{Binding HasBackupPassword, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
<ui:PasswordBox Password="{Binding NewBackupPassword, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8" />
<CheckBox Content="{l:T 'Backup-Passwort entfernen'}" IsChecked="{Binding RemoveBackupPassword}"
Visibility="{Binding HasBackupPassword, Converter={StaticResource BoolToVisibilityConverter}}" />
<ui:Button Content="{l:T 'Speichern'}" Appearance="Primary" Command="{Binding SaveCommand}" Margin="0,20,0,0" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,12,0,0"
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,12,0,0"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
</UserControl>
@@ -1,9 +0,0 @@
namespace MikrotikInstaller.App.Views;
public partial class SettingsView : System.Windows.Controls.UserControl
{
public SettingsView()
{
InitializeComponent();
}
}
@@ -1,93 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.SetupWizardView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
<!-- Eigene Scroll-Fläche, damit die Schrittleiste oben und Zurück/Weiter unten stehen bleiben. -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Schrittleiste: jeder Schritt direkt anklickbar -->
<ItemsControl Grid.Row="0" ItemsSource="{Binding StepNavItems}" Margin="0,12,0,4">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<ui:Button Appearance="Transparent" Padding="8,6" Margin="0,0,4,4"
Command="{Binding DataContext.GoToStepCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding Number}">
<ui:Button.Style>
<Style TargetType="ui:Button" BasedOn="{StaticResource {x:Type ui:Button}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter Property="Background" Value="{StaticResource MikrotikAccentSoftBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</ui:Button.Style>
<StackPanel Orientation="Horizontal">
<Border Width="22" Height="22" CornerRadius="11" Margin="0,0,8,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource ControlStrongFillColorDisabledBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<TextBlock Text="{Binding Number}" FontSize="11" FontWeight="SemiBold" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding IsCompleted, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
<ui:SymbolIcon Symbol="Checkmark24" FontSize="12" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding IsCompleted, Converter={StaticResource BoolToVisibilityConverter}}" />
</Grid>
</Border>
<TextBlock Text="{Binding Title}" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</ui:Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="0,0,8,16">
<StackPanel>
<TextBlock Text="{Binding CurrentStep.Title}" FontSize="20" FontWeight="SemiBold" Margin="0,12,0,0" />
<ContentControl Content="{Binding CurrentStep}" />
</StackPanel>
</ScrollViewer>
<Border Grid.Row="2" BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,20">
<ui:Button Content="{l:T 'Zurück'}" Command="{Binding GoBackCommand}" Margin="0,0,8,0" />
<ui:Button Content="{l:T 'Weiter'}" Appearance="Primary" Command="{Binding GoNextCommand}"
Visibility="{Binding IsLastStep, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -1,9 +0,0 @@
namespace MikrotikInstaller.App.Views;
public partial class SetupWizardView : System.Windows.Controls.UserControl
{
public SetupWizardView()
{
InitializeComponent();
}
}
@@ -1,56 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.StartView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:vm="clr-namespace:MikrotikInstaller.App.ViewModels">
<UserControl.Resources>
<Style x:Key="QuickTile" TargetType="ui:Button" BasedOn="{StaticResource {x:Type ui:Button}}">
<Setter Property="Command" Value="{Binding DataContext.NavigateCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Margin" Value="0,0,8,8" />
</Style>
</UserControl.Resources>
<StackPanel Margin="0,16,0,0">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,16" Opacity="0.8" />
<UniformGrid Columns="4" Margin="0,0,-8,8">
<ui:Button Style="{StaticResource QuickTile}" CommandParameter="{x:Static vm:AppArea.Setup}">
<StackPanel>
<ui:SymbolIcon Symbol="Wand24" FontSize="22" HorizontalAlignment="Left" Foreground="{StaticResource MikrotikAccentBrush}" />
<TextBlock Text="{l:T 'Einrichten'}" FontWeight="SemiBold" Margin="0,6,0,0" />
<TextBlock Text="{l:T 'Internet, Heimnetz, WLAN, Firewall'}" FontSize="12" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</ui:Button>
<ui:Button Style="{StaticResource QuickTile}" CommandParameter="{x:Static vm:AppArea.Overview}">
<StackPanel>
<ui:SymbolIcon Symbol="Organization24" FontSize="22" HorizontalAlignment="Left" Foreground="{StaticResource MikrotikAccentBrush}" />
<TextBlock Text="{l:T 'Übersicht'}" FontWeight="SemiBold" Margin="0,6,0,0" />
<TextBlock Text="{l:T 'Vorhandene Netzwerke als Grafik'}" FontSize="12" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</ui:Button>
<ui:Button Style="{StaticResource QuickTile}" CommandParameter="{x:Static vm:AppArea.Devices}">
<StackPanel>
<ui:SymbolIcon Symbol="Desktop24" FontSize="22" HorizontalAlignment="Left" Foreground="{StaticResource MikrotikAccentBrush}" />
<TextBlock Text="{l:T 'Geräte'}" FontWeight="SemiBold" Margin="0,6,0,0" />
<TextBlock Text="{l:T 'Wer ist im Netzwerk?'}" FontSize="12" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</ui:Button>
<ui:Button Style="{StaticResource QuickTile}" CommandParameter="{x:Static vm:AppArea.Backup}">
<StackPanel>
<ui:SymbolIcon Symbol="ArrowSync24" FontSize="22" HorizontalAlignment="Left" Foreground="{StaticResource MikrotikAccentBrush}" />
<TextBlock Text="{l:T 'Backup &amp; Wartung'}" FontWeight="SemiBold" Margin="0,6,0,0" />
<TextBlock Text="{l:T 'Sichern, Neustart, Firmware'}" FontSize="12" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</ui:Button>
</UniformGrid>
<ContentControl Content="{Binding Device}" />
<TextBlock Text="{Binding Update.Title}" FontSize="20" FontWeight="SemiBold" Margin="0,28,0,0" />
<ContentControl Content="{Binding Update}" />
</StackPanel>
</UserControl>
@@ -1,9 +0,0 @@
namespace MikrotikInstaller.App.Views;
public partial class StartView : System.Windows.Controls.UserControl
{
public StartView()
{
InitializeComponent();
}
}
@@ -1,243 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.BackupManagementStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="600">
<UserControl.Resources>
<CollectionViewSource x:Key="BackupsByDay" Source="{Binding AvailableBackups}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="DayLabel" />
</CollectionViewSource.GroupDescriptions>
</CollectionViewSource>
</UserControl.Resources>
<StackPanel Margin="0,16,0,0">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,12" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoadingDevice, Converter={StaticResource BoolToVisibilityConverter}}" Margin="0,0,0,16">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Gerät wird ausgelesen …'}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Margin="0,0,0,16" Visibility="{Binding Device, Converter={StaticResource NullToVisibilityConverter}}">
<Run Text="{l:T 'Verbunden mit: '}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Run Text="{Binding Device.IdentityName, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text=" (" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Run Text="{Binding Device.BoardModel, Mode=OneWay}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Run Text=")" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,16"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding MaintenanceMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikAccentBrush}" FontWeight="SemiBold" Margin="0,0,0,16"
Visibility="{Binding MaintenanceMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<StackPanel Visibility="{Binding ShowMainContent, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<TextBlock Text="{l:T 'Vorhandene Backups auf diesem Gerät'}" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" />
<ui:Button Content="{l:T 'Aktualisieren'}" Appearance="Secondary" Margin="12,0,0,0"
IsEnabled="{Binding IsLoadingBackups, Converter={StaticResource InverseBooleanConverter}}"
Command="{Binding RefreshCommand}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="ArrowSync24" />
</ui:Button.Icon>
</ui:Button>
<ui:Button Content="{l:T 'Neues Backup erstellen'}" Appearance="Secondary" Margin="8,0,0,0"
Command="{Binding CreateBackupNowCommand}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Save24" />
</ui:Button.Icon>
</ui:Button>
</StackPanel>
<TextBlock TextWrapping="Wrap" Foreground="{StaticResource MikrotikWarningBrush}" Margin="0,0,0,12"
Text="{l:T 'Neue Backups werden unverschlüsselt gespeichert. Ein Backup-Passwort kannst du in den Einstellungen festlegen.'}"
Visibility="{Binding IsBackupUnencrypted, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding CreateBackupMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,12"
Visibility="{Binding CreateBackupMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoadingBackups, Converter={StaticResource BoolToVisibilityConverter}}" Margin="0,0,0,12">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Backups werden ausgelesen …'}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="{l:T 'Es wurden keine Backups auf diesem Gerät gefunden.'}" Opacity="0.6" Margin="0,0,0,12">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsLoadingBackups}" Value="False" />
<Condition Binding="{Binding AvailableBackups.Count}" Value="0" />
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible" />
</MultiDataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ListBox ItemsSource="{Binding Source={StaticResource BackupsByDay}}" SelectedItem="{Binding SelectedBackup}" Margin="0,0,0,12"
BorderThickness="0" Background="Transparent" ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" Margin="0,8,0,6"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="12">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource MikrotikAccentBrush}" />
<Setter Property="BorderThickness" Value="2" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="LockClosed16" Margin="0,0,6,0" ToolTip="{l:T 'Verschlüsselt'}"
Visibility="{Binding IsEncrypted, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold" />
</StackPanel>
<TextBlock Text="{Binding CreationTimeLabel}" Opacity="0.6" FontSize="12" />
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Margin="0,2,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource MikrotikAccentBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSameModelDifferentDevice}" Value="True">
<Setter Property="Foreground" Value="{StaticResource MikrotikWarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsUnknown}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsBlocked}" Value="True">
<Setter Property="Foreground" Value="{StaticResource MikrotikRedBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<CheckBox Content="{l:T 'Ich bestätige, dass ich dieses Backup auf eigene Gefahr wiederherstellen möchte.'}" Margin="0,0,0,12"
IsChecked="{Binding ConfirmRiskyBackup}"
Visibility="{Binding SelectedBackup.RequiresConfirmation, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Collapsed}" />
<StackPanel Orientation="Horizontal">
<ui:Button Content="{l:T 'Jetzt wiederherstellen'}" Appearance="Primary" Margin="0,0,8,0"
Command="{Binding ApplyRestoreCommand}" />
<ui:Button Content="{l:T 'Auf PC speichern'}" Appearance="Secondary" Command="{Binding DownloadBackupCommand}"
ToolTip="{l:T 'Lädt das ausgewählte Backup per SFTP in den Backup-Ordner aus den Einstellungen.'}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="ArrowDownload24" />
</ui:Button.Icon>
</ui:Button>
</StackPanel>
<Grid Margin="0,12,0,0" Visibility="{Binding DownloadedPath, Converter={StaticResource StringToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Foreground="{StaticResource MikrotikAccentBrush}">
<Run Text="{l:T 'Gespeichert unter: '}" />
<Run Text="{Binding DownloadedPath, Mode=OneWay}" />
</TextBlock>
<ui:Button Grid.Column="1" Content="{l:T 'Ordner öffnen'}" Appearance="Transparent" Margin="8,0,0,0" VerticalAlignment="Center" Command="{Binding OpenDownloadFolderCommand}" />
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,16,0,0"
Visibility="{Binding IsRestoring, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{Binding RestoreProgressText}" VerticalAlignment="Center" TextWrapping="Wrap" />
</StackPanel>
<!-- Wartung (Issue #28, #44): Neustart, RouterBOARD-Firmware, Werksreset. -->
<TextBlock Text="{l:T 'Wartung'}" FontSize="16" FontWeight="SemiBold" Margin="0,32,0,12" />
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{l:T 'Neu starten'}" FontWeight="SemiBold" VerticalAlignment="Center" />
<controls:InfoHint Text="{l:T 'Startet den Router neu. Die Verbindung bricht kurz ab, der Assistent verbindet sich danach von selbst wieder.'}" />
</StackPanel>
<ui:Button Content="{l:T 'Jetzt neu starten'}" Appearance="Secondary" Margin="0,8,0,0" Command="{Binding RebootCommand}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="ArrowClockwise24" />
</ui:Button.Icon>
</ui:Button>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="16" Margin="0,0,0,12"
Visibility="{Binding Firmware, Converter={StaticResource NullToVisibilityConverter}}">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{l:T 'RouterBOARD-Firmware'}" FontWeight="SemiBold" VerticalAlignment="Center" />
<controls:InfoHint Text="{l:T 'Die Firmware ist das Startprogramm der Hardware, getrennt von RouterOS. Eine neue Version kommt mit einem RouterOS-Update mit und wird erst nach Einspielen und Neustart aktiv.'}" />
</StackPanel>
<TextBlock Margin="0,4,0,0">
<Run Text="{l:T 'Installiert: '}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Run Text="{Binding Firmware.CurrentFirmware, Mode=OneWay}" />
<Run Text="{l:T ' Verfügbar: '}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Run Text="{Binding Firmware.UpgradeFirmware, Mode=OneWay}" />
</TextBlock>
<ui:Button Content="{l:T 'Firmware aktualisieren und neu starten'}" Appearance="Secondary" Margin="0,8,0,0" Command="{Binding UpgradeFirmwareCommand}" />
</StackPanel>
</Border>
<Border BorderBrush="{StaticResource MikrotikRedBrush}" BorderThickness="1" CornerRadius="6" Padding="16">
<StackPanel>
<TextBlock Text="{l:T 'Werkseinstellungen wiederherstellen'}" FontWeight="SemiBold" Foreground="{StaticResource MikrotikRedBrush}" />
<TextBlock TextWrapping="Wrap" Margin="0,4,0,8"
Text="{l:T 'Notfallschalter: setzt das Gerät auf die MikroTik-Werkseinstellungen zurück. Vorher wird eine Sicherung erstellt. Danach ist das Gerät meist nur noch unter 192.168.88.1 mit dem Benutzer „admin“ und dem Passwort vom Aufkleber (oder leer) erreichbar.'}" />
<CheckBox Content="{l:T 'Ich habe verstanden, dass die aktuelle Konfiguration dabei gelöscht wird.'}" IsChecked="{Binding ConfirmFactoryReset}" Margin="0,0,0,8" />
<ui:Button Content="{l:T 'Auf Werkseinstellungen zurücksetzen'}" Appearance="Danger" Command="{Binding FactoryResetCommand}" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel Visibility="{Binding FactoryResetDone, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{l:T 'Das Gerät wird zurückgesetzt.'}" FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,8" />
<TextBlock TextWrapping="Wrap" Opacity="0.8"
Text="{l:T 'Die Verbindung wurde beendet. Nach dem Neustart ist das Gerät in der Regel unter 192.168.88.1 erreichbar — verbinde dich im ersten Schritt neu. Vor dem Zurücksetzen wurde eine Sicherung erstellt, die du danach unter „Backup &amp; Wartung“ zurückspielen kannst.'}" />
</StackPanel>
<StackPanel Visibility="{Binding RestoreFinished, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{l:T 'Fertig! Das Backup wird zurückgespielt.'}" FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,8" />
<TextBlock TextWrapping="Wrap" Opacity="0.8"
Text="{l:T 'Das Gerät startet jetzt automatisch neu — eine kurze Verbindungsunterbrechung ist dabei normal. Vor dem Zurückspielen wurde zusätzlich eine Sicherung deines vorherigen Zustands erstellt.'}" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -1,11 +0,0 @@
using System.Windows.Controls;
namespace MikrotikInstaller.App.Views.Steps;
public partial class BackupManagementStepView : UserControl
{
public BackupManagementStepView()
{
InitializeComponent();
}
}
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.ConnectionStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.ConnectionStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -12,125 +11,27 @@
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,24" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Adresse des Geräts (IP oder Hostname)'}" />
<controls:InfoHint Text="{l:T 'Die Adresse, unter der dein Router in deinem Netzwerk erreichbar ist – meist etwas wie 192.168.88.1. Steht z. B. auf dem Gerät oder in der Anleitung deines Routers.'}" />
<TextBlock Text="Adresse des Geräts (IP oder Hostname)" />
<controls:InfoHint Text="Die Adresse, unter der dein Router in deinem Netzwerk erreichbar ist – meist etwas wie 192.168.88.1. Steht z. B. auf dem Gerät oder in der Anleitung deines Routers." />
</StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBox Grid.Column="0" Text="{Binding Host, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="z. B. 192.168.88.1" />
<ui:Button Grid.Column="1" Content="{l:T 'Router im Netzwerk suchen'}" Appearance="Secondary" Margin="8,0,0,0"
Command="{Binding SearchNeighborsCommand}"
IsEnabled="{Binding IsSearchingNeighbors, Converter={StaticResource InverseBooleanConverter}}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Search24" />
</ui:Button.Icon>
</ui:Button>
</Grid>
<ui:TextBox Text="{Binding Host, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="z. B. 192.168.88.1" Margin="0,0,0,16" />
<!-- Router-Suche per MNDP: findet Router über die MAC-Adresse, auch ohne passende IP (Mac v1.6.0). -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,8" Visibility="{Binding IsSearchingNeighbors, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="16" Height="16" Margin="0,0,8,0" />
<TextBlock Text="{l:T 'Suche Router im Netzwerk …'}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="{Binding NeighborSearchMessage}" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding NeighborSearchMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<ItemsControl ItemsSource="{Binding Neighbors}" Margin="0,0,0,8">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="10,6" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock>
<Run Text="{Binding Identity, Mode=OneWay, TargetNullValue=(ohne Namen)}" FontWeight="SemiBold" />
<Run Text="{Binding Board, Mode=OneWay}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}">
<Run Text="{Binding IPv4Address, Mode=OneWay, TargetNullValue=keine IP-Adresse}" />
<Run Text=" · " />
<Run Text="{Binding MacAddress, Mode=OneWay}" />
</TextBlock>
<TextBlock FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource MikrotikWarningBrush}"
Text="{l:T 'Ohne IP-Adresse nur per WinBox über die MAC-Adresse erreichbar (in WinBox unter „Neighbors“ anklicken).'}"
Visibility="{Binding IPv4Address, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}" />
</StackPanel>
<ui:Button Grid.Column="1" Content="{l:T 'IP übernehmen'}" Appearance="Secondary" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding DataContext.UseNeighborCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
Visibility="{Binding IPv4Address, Converter={StaticResource NullToVisibilityConverter}}" />
<ui:Button Grid.Column="2" Content="{l:T 'MAC kopieren'}" Appearance="Secondary" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding DataContext.CopyMacCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Height="8" />
<TextBlock Text="Benutzername" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="admin" Margin="0,0,0,16" />
<!-- Nebeneinander statt untereinander, damit "Verbindung testen" im Standardfenster ohne Scrollen erreichbar bleibt (Issue #31). -->
<Grid Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="{l:T 'Benutzername'}" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="{l:T 'admin'}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'Passwort'}" Margin="0,0,0,4" />
<ui:PasswordBox Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<TextBlock Text="Passwort" Margin="0,0,0,4" />
<ui:PasswordBox Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<CheckBox Content="{l:T 'Passwort merken'}" IsChecked="{Binding RememberPassword}" Margin="0,0,24,0" />
<CheckBox Content="{l:T 'Verschlüsselte Verbindung verwenden'}" IsChecked="{Binding UseEncryption}" />
<controls:InfoHint Text="{l:T 'Verschlüsselt die Verbindung zu deinem Router. Nur aktivieren, wenn du das auf dem Gerät bereits eingerichtet hast – sonst schlägt die Verbindung fehl. Im Zweifel einfach ausgeschaltet lassen.'}" />
<CheckBox Content="Verschlüsselte Verbindung verwenden" IsChecked="{Binding UseEncryption}" />
<controls:InfoHint Text="Verschlüsselt die Verbindung zu deinem Router. Nur aktivieren, wenn du das auf dem Gerät bereits eingerichtet hast – sonst schlägt die Verbindung fehl. Im Zweifel einfach ausgeschaltet lassen." />
</StackPanel>
<ui:Button Content="{l:T 'Verbindung testen'}" Appearance="Secondary" HorizontalAlignment="Left"
<ui:Button Content="Verbindung testen" Appearance="Secondary" HorizontalAlignment="Left"
Command="{Binding TestConnectionCommand}" />
<!-- Testumgebung: simulierte Geräte ohne eigene Hardware (Mac v1.8.0). -->
<Border Margin="0,20,0,0" Padding="12" CornerRadius="6" Background="{DynamicResource ControlFillColorSecondaryBrush}"
Visibility="{Binding ShowTestEnvironment, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="{l:T 'Testumgebung'}" FontWeight="SemiBold" />
<controls:InfoHint Text="{l:T 'Simulierte Router zum Ausprobieren, ganz ohne eigene Hardware. Nichts davon geht ins Netz; Änderungen bleiben nur bis zum Beenden der App erhalten. Abschaltbar in den Einstellungen.'}" />
</StackPanel>
<ItemsControl ItemsSource="{Binding TestDevices}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<ui:Button Appearance="Secondary" Margin="0,0,8,0" HorizontalContentAlignment="Left"
Command="{Binding DataContext.ConnectToTestDeviceCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}">
<StackPanel>
<TextBlock Text="{Binding IdentityName}" FontWeight="SemiBold" FontSize="12" />
<TextBlock Text="{Binding BoardModel}" FontSize="11" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
</ui:Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,16,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.DeviceOverviewStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.DeviceOverviewStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -13,18 +12,15 @@
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Gerät wird ausgelesen …'}" VerticalAlignment="Center" />
<TextBlock Text="Gerät wird ausgelesen …" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}">
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,12" />
<ui:Button Content="{l:T 'Erneut versuchen'}" Appearance="Secondary" HorizontalAlignment="Left"
<ui:Button Content="Erneut versuchen" Appearance="Secondary" HorizontalAlignment="Left"
Command="{Binding RetryCommand}" />
</StackPanel>
<TextBlock Text="{Binding CredentialWarning}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikWarningBrush}" Margin="0,0,0,12"
Visibility="{Binding CredentialWarning, Converter={StaticResource StringToVisibilityConverter}}" />
<StackPanel Visibility="{Binding Device, Converter={StaticResource NullToVisibilityConverter}}">
<ui:CardExpander IsExpanded="True" Margin="0,0,0,16">
<ui:CardExpander.Icon>
@@ -38,36 +34,25 @@
</ui:CardExpander.Header>
<StackPanel Margin="8,12,8,8">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{l:T 'RouterOS-Version: '}" FontWeight="SemiBold" />
<TextBlock Text="RouterOS-Version: " FontWeight="SemiBold" />
<TextBlock Text="{Binding Device.RouterOsVersion}" />
<controls:InfoHint Text="{l:T 'RouterOS ist das Betriebssystem, das auf MikroTik-Geräten läuft – vergleichbar mit Windows auf einem PC.'}" />
<controls:InfoHint Text="RouterOS ist das Betriebssystem, das auf MikroTik-Geräten läuft – vergleichbar mit Windows auf einem PC." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="{l:T 'Architektur: '}" FontWeight="SemiBold" />
<TextBlock Text="Architektur: " FontWeight="SemiBold" />
<TextBlock Text="{Binding Device.Architecture}" />
<controls:InfoHint Text="{l:T 'Die Art des Prozessors in deinem Gerät. Für die Einrichtung nicht wichtig, nur zur Information.'}" />
<controls:InfoHint Text="Die Art des Prozessors in deinem Gerät. Für die Einrichtung nicht wichtig, nur zur Information." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
Visibility="{Binding Device.SerialNumber, Converter={StaticResource NullToVisibilityConverter}}">
<TextBlock Text="{l:T 'Seriennummer: '}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Device.SerialNumber}" Background="Transparent">
<TextBlock.InputBindings>
<MouseBinding MouseAction="LeftClick" Command="{Binding SerialNumberClickedCommand}" />
</TextBlock.InputBindings>
</TextBlock>
</StackPanel>
<TextBlock Text="{l:T '🎂 The Cake is a Lie!'}" FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,4,0,0"
Visibility="{Binding ShowCake, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Margin="0,4,0,0">
<Run Text="{l:T 'WLAN-fähig: '}" FontWeight="SemiBold" />
<Run Text="WLAN-fähig: " FontWeight="SemiBold" />
<Run Text="{Binding Device.HasWireless}" />
</TextBlock>
</StackPanel>
</ui:CardExpander>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="{l:T 'Netzwerkschnittstellen'}" FontWeight="SemiBold" />
<controls:InfoHint Text="{l:T 'Die Anschlüsse und Verbindungen deines Routers, z. B. Netzwerkkabel-Ports (Ethernet) oder WLAN. Darüber verbindest du später Geräte und das Internet.'}" />
<TextBlock Text="Netzwerkschnittstellen" FontWeight="SemiBold" />
<controls:InfoHint Text="Die Anschlüsse und Verbindungen deines Routers, z. B. Netzwerkkabel-Ports (Ethernet) oder WLAN. Darüber verbindest du später Geräte und das Internet." />
</StackPanel>
<ItemsControl ItemsSource="{Binding Interfaces}">
<ItemsControl.ItemTemplate>
@@ -79,22 +64,7 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<!-- Link-Status als Punkt: grün = Link, grau = kein Link (Mac v1.3.0). -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{DynamicResource ControlStrongFillColorDisabledBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasLink}" Value="True">
<Setter Property="Fill" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{Binding Name}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="0" Text="{Binding Name}" />
<TextBlock Grid.Column="1" Text="{Binding KindLabel}" Opacity="0.6" Margin="0,0,16,0" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}">
<TextBlock.Style>
@@ -1,328 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.ExpertStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="900">
<UserControl.Resources>
<CollectionViewSource x:Key="MenusByGroup" Source="{Binding Menus}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Group" />
</CollectionViewSource.GroupDescriptions>
</CollectionViewSource>
<Style x:Key="SectionBlock" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="14" />
<Setter Property="Margin" Value="0,0,0,12" />
</Style>
</UserControl.Resources>
<Grid Margin="0,16,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Menüs und Werkzeuge -->
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,12" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<TextBlock Text="{l:T 'Eigener Menüpfad'}" Margin="0,0,0,4" />
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBox Text="{Binding CustomPath, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="{l:T 'z. B. /ip/arp'}" />
<ui:Button Grid.Column="1" Content="{l:T 'Öffnen'}" Margin="6,0,0,0" Command="{Binding OpenCustomPathCommand}" />
</Grid>
<ListBox ItemsSource="{Binding Source={StaticResource MenusByGroup}}" SelectedItem="{Binding SelectedMenu}"
MaxHeight="560" ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="12" Margin="4,10,0,4"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
<!-- Inhalt -->
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'Links ein Menü oder Werkzeug wählen.'}" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding SelectedMenu, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,12"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,12" FontFamily="Consolas"
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<!-- Menü: Beschreibung, Einträge, Editor -->
<StackPanel Visibility="{Binding IsMenuSelected, Converter={StaticResource BoolToVisibilityConverter}}">
<Border Style="{StaticResource SectionBlock}">
<StackPanel>
<TextBlock FontSize="18" FontWeight="SemiBold">
<Run Text="{Binding SelectedMenu.Title, Mode=OneWay}" />
<Run Text="{Binding SelectedMenu.Path, Mode=OneWay}" FontSize="12" FontFamily="Consolas" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock Text="{Binding SelectedMenu.Schema.Summary}" TextWrapping="Wrap" Margin="0,6,0,0" />
<TextBlock Text="{Binding SelectedMenu.Schema.Explanation}" TextWrapping="Wrap" Margin="0,6,0,0" FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<TextBlock Text="{Binding SelectedMenu.Schema.Warning}" TextWrapping="Wrap" Margin="0,6,0,0" FontSize="12"
Foreground="{StaticResource MikrotikWarningBrush}"
Visibility="{Binding SelectedMenu.Schema.Warning, Converter={StaticResource StringToVisibilityConverter}, FallbackValue=Collapsed}" />
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<ui:Button Content="{l:T 'Neuer Eintrag'}" Appearance="Secondary" Margin="0,0,8,0" Command="{Binding NewEntryCommand}"
Visibility="{Binding CanAdd, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Add24" />
</ui:Button.Icon>
</ui:Button>
<ui:Button Content="{l:T 'Aktualisieren'}" Appearance="Secondary" Command="{Binding RefreshCommand}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="ArrowSync24" />
</ui:Button.Icon>
</ui:Button>
<ui:ProgressRing IsIndeterminate="True" Width="18" Height="18" Margin="12,0,0,0"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
<!-- Einträge mit Zebra-Streifen; deaktivierte ausgegraut, dynamische/Standard-Einträge ohne Löschen. -->
<ItemsControl ItemsSource="{Binding Entries}" AlternationCount="2" Margin="0,0,0,12">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="Row" Padding="10,6" CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="Summary" Text="{Binding Summary}" TextWrapping="Wrap" />
<TextBlock Text="{l:T ' deaktiviert'}" FontSize="11" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding IsDisabled, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{l:T ' dynamisch'}" FontSize="11" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding IsDynamic, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
<ui:Button Grid.Column="1" Appearance="Transparent" Padding="6,2" ToolTip="{l:T 'Bearbeiten'}"
Command="{Binding DataContext.EditEntryCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}" CommandParameter="{Binding}">
<ui:SymbolIcon Symbol="Edit20" />
</ui:Button>
<ui:Button Grid.Column="2" Appearance="Transparent" Padding="6,2" ToolTip="{l:T 'Löschen'}"
Command="{Binding DataContext.DeleteEntryCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}" CommandParameter="{Binding}"
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:SymbolIcon Symbol="Delete20" />
</ui:Button>
</Grid>
</Border>
<DataTemplate.Triggers>
<Trigger Property="ItemsControl.AlternationIndex" Value="1">
<Setter TargetName="Row" Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
</Trigger>
<DataTrigger Binding="{Binding IsDisabled}" Value="True">
<Setter TargetName="Summary" Property="Opacity" Value="0.5" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Editor (gemeinsam mit der Übersicht, Issue #24) -->
<ContentControl Content="{Binding Editor}" />
</StackPanel>
<!-- Werkzeuge -->
<StackPanel DataContext="{Binding Tools}" Visibility="{Binding DataContext.IsToolSelected, RelativeSource={RelativeSource AncestorType=UserControl}, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{Binding ToolError}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,12"
Visibility="{Binding ToolError, Converter={StaticResource StringToVisibilityConverter}}" />
<!-- Netzwerke isolieren -->
<Border Style="{StaticResource SectionBlock}" Visibility="{Binding IsIsolate, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{l:T 'Netzwerke isolieren'}" FontSize="18" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Margin="0,6,0,12"
Text="{l:T 'Trennt ein Netz (z. B. Gäste) von einem anderen: keine Verbindungen zwischen beiden, keine Router-Verwaltung aus dem isolierten Netz, nur DNS/DHCP bleiben erlaubt. Bestehende Verbindungen werden gekappt.'}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{l:T 'Dieses Netz isolieren'}" Margin="0,0,0,4" />
<ComboBox ItemsSource="{Binding DataContext.Tools.InterfaceNames, RelativeSource={RelativeSource AncestorType=UserControl}}" SelectedItem="{Binding IsolateInterface}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'vom Netz'}" Margin="0,0,0,4" />
<ComboBox ItemsSource="{Binding DataContext.Tools.InterfaceNames, RelativeSource={RelativeSource AncestorType=UserControl}}" SelectedItem="{Binding IsolateFrom}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- Neues LAN anlegen -->
<Border Style="{StaticResource SectionBlock}" Visibility="{Binding IsNewLan, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{l:T 'Neues LAN anlegen'}" FontSize="18" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Margin="0,6,0,12"
Text="{l:T 'Gibt einem einzelnen Anschluss (Kabel oder WLAN) ein eigenes Netz mit automatischer Adressvergabe, z. B. für Gäste. Der Rückweg ist „Port in Bridge einfügen“.'}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="{l:T 'Anschluss'}" VerticalAlignment="Center" Margin="0,0,0,8" />
<ComboBox Grid.Column="1" ItemsSource="{Binding PhysicalPorts}" SelectedItem="{Binding NewLanInterface}" Margin="0,0,0,8" />
<StackPanel Grid.Row="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,8">
<TextBlock Text="{l:T 'Router-Adresse mit Präfix'}" />
<controls:InfoHint Text="{l:T 'Adresse des Routers in diesem neuen Netz, z. B. 10.10.10.1/24 — /24 erlaubt bis zu 254 Geräte.'}" />
</StackPanel>
<ui:TextBox Grid.Row="1" Grid.Column="1" Text="{Binding NewLanAddress, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8" />
<TextBlock Grid.Row="2" Text="{l:T 'Adressbereich'}" VerticalAlignment="Center" Margin="0,0,0,8" />
<Grid Grid.Row="2" Grid.Column="1" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ui:TextBox Text="{Binding NewLanPoolStart, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Column="1" Text="{l:T 'bis'}" VerticalAlignment="Center" Margin="8,0" />
<ui:TextBox Grid.Column="2" Text="{Binding NewLanPoolEnd, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
<StackPanel Grid.Row="3" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,8">
<TextBlock Text="{l:T 'Adresse behalten für'}" />
<controls:InfoHint Text="{l:T 'DHCP-Lease-Zeit. Richtwerte: Gäste 1 Tag, Heimgeräte 1 Woche. Alles 0 = RouterOS-Standard.'}" />
</StackPanel>
<controls:LeaseTimePicker Grid.Row="3" Grid.Column="1" DataContext="{Binding NewLanLease}" Margin="0,0,0,8" />
<CheckBox Grid.Row="4" Grid.ColumnSpan="2" Content="{l:T 'Vom Heimnetz isolieren (empfohlen für Gäste)'}" IsChecked="{Binding NewLanIsolate}" />
</Grid>
</StackPanel>
</Border>
<!-- Port in Bridge einfügen -->
<Border Style="{StaticResource SectionBlock}" Visibility="{Binding IsRejoin, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{l:T 'Port in Bridge einfügen'}" FontSize="18" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Margin="0,6,0,12"
Text="{l:T 'Holt einen Anschluss (auch WLAN), der ein eigenes Netz hatte, zurück ins Heimnetz: Adresse, DHCP und Isolationsregeln dieses Anschlusses werden entfernt.'}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{l:T 'Anschluss (nicht in einer Bridge)'}" Margin="0,0,0,4" />
<ComboBox ItemsSource="{Binding FreePorts}" SelectedItem="{Binding RejoinPort}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'in Bridge'}" Margin="0,0,0,4" />
<ComboBox ItemsSource="{Binding Bridges}" SelectedItem="{Binding RejoinBridge}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- Plan + Ausführen (gemeinsam für die drei Netz-Werkzeuge) -->
<StackPanel Visibility="{Binding IsPackages, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}">
<TextBlock Text="{Binding ToolWarning}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikWarningBrush}" Margin="0,0,0,10"
Visibility="{Binding ToolWarning, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{l:T 'Geplante Schritte'}" FontWeight="SemiBold" Margin="0,0,0,6"
Visibility="{Binding PlanLines.Count, Converter={StaticResource CountToVisibilityConverter}}" />
<ItemsControl ItemsSource="{Binding PlanLines}" Margin="0,0,0,10">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding StringFormat=• {0}}" TextWrapping="Wrap" Margin="0,0,0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ui:Button Content="{l:T 'Anwenden'}" Appearance="Primary" Command="{Binding ApplyPlanCommand}" HorizontalAlignment="Left" Margin="0,0,0,12" />
<StackPanel Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}">
<ProgressBar Value="{Binding ProgressValue}" Maximum="{Binding ProgressMaximum}" Height="6" Margin="0,0,0,6" />
<TextBlock Text="{Binding ProgressText}" TextWrapping="Wrap" />
</StackPanel>
<ItemsControl ItemsSource="{Binding CompletedSteps}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<ui:SymbolIcon Symbol="Checkmark16" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,6,0" />
<TextBlock Text="{Binding}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Pakete -->
<Border Style="{StaticResource SectionBlock}" Visibility="{Binding IsPackages, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{l:T 'Pakete'}" FontSize="18" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Margin="0,6,0,8"
Text="{l:T 'RouterOS-Zusatzpakete wie hotspot installieren oder entfernen. Der Router lädt sie von MikroTik herunter und startet danach neu; die Verbindung wird automatisch wiederhergestellt.'}" />
<TextBlock Text="{Binding PackageInfo}" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="{StaticResource MikrotikWarningBrush}"
Visibility="{Binding PackageInfo, Converter={StaticResource StringToVisibilityConverter}}" />
<ItemsControl ItemsSource="{Binding Packages}" AlternationCount="2" Margin="0,0,0,10">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="Row" Padding="8,4" CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<CheckBox IsChecked="{Binding IsWanted}" IsEnabled="{Binding CanChange}" VerticalAlignment="Top" />
<StackPanel Grid.Column="1">
<TextBlock>
<Run Text="{Binding Package.Name, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text="{Binding StatusLabel, Mode=OneWay}" FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock Text="{Binding Package.Description}" FontSize="12" TextWrapping="Wrap"
Visibility="{Binding Package.Description, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
</Grid>
</Border>
<DataTemplate.Triggers>
<Trigger Property="ItemsControl.AlternationIndex" Value="1">
<Setter TargetName="Row" Property="Background" Value="{DynamicResource ControlFillColorTertiaryBrush}" />
</Trigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal">
<ui:Button Content="{l:T 'Änderungen anwenden'}" Appearance="Primary" Margin="0,0,8,0" Command="{Binding ApplyPackagesCommand}" />
<ui:Button Content="{l:T 'Liste neu laden'}" Command="{Binding RefreshPackagesCommand}" />
<ui:ProgressRing IsIndeterminate="True" Width="18" Height="18" Margin="12,0,0,0"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</Grid>
</UserControl>
@@ -1,9 +0,0 @@
namespace MikrotikInstaller.App.Views.Steps;
public partial class ExpertStepView : System.Windows.Controls.UserControl
{
public ExpertStepView()
{
InitializeComponent();
}
}
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.FirewallStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.FirewallStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -12,8 +11,8 @@
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,20" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="{l:T 'Portfreigaben (optional)'}" FontWeight="SemiBold" />
<controls:InfoHint Text="{l:T 'Macht ein bestimmtes Gerät in deinem Heimnetz gezielt aus dem Internet erreichbar, z. B. für einen Spiele- oder NAS-Server. Nur einrichten, wenn du weißt, dass du das brauchst.'}" />
<TextBlock Text="Portfreigaben (optional)" FontWeight="SemiBold" />
<controls:InfoHint Text="Macht ein bestimmtes Gerät in deinem Heimnetz gezielt aus dem Internet erreichbar, z. B. für einen Spiele- oder NAS-Server. Nur einrichten, wenn du weißt, dass du das brauchst." />
</StackPanel>
<ItemsControl ItemsSource="{Binding PortForwards}">
@@ -30,18 +29,18 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="{l:T 'Bezeichnung (z. B. Spieleserver)'}" Margin="0,0,0,4" />
<TextBlock Text="Bezeichnung (z. B. Spieleserver)" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Protokoll'}" />
<controls:InfoHint Text="{l:T 'Die Art der Verbindung, die dein Programm benötigt – steht meist in dessen Anleitung. Im Zweifel TCP probieren.'}" />
<TextBlock Text="Protokoll" />
<controls:InfoHint Text="Die Art der Verbindung, die dein Programm benötigt – steht meist in dessen Anleitung. Im Zweifel TCP probieren." />
</StackPanel>
<ComboBox ItemsSource="{Binding DataContext.Protocols, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
SelectedItem="{Binding Protocol}" />
</StackPanel>
<ui:Button Grid.Column="4" Content="{l:T 'Entfernen'}" Appearance="Secondary" VerticalAlignment="Bottom"
<ui:Button Grid.Column="4" Content="Entfernen" Appearance="Secondary" VerticalAlignment="Bottom"
Command="{Binding DataContext.RemovePortForwardCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
</Grid>
@@ -56,32 +55,27 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Port von außen'}" />
<controls:InfoHint Text="{l:T 'Die Nummer, über die dein Dienst aus dem Internet erreichbar sein soll, z. B. 25565 für einen bestimmten Spieleserver.'}" />
<TextBlock Text="Port von außen" />
<controls:InfoHint Text="Die Nummer, über die dein Dienst aus dem Internet erreichbar sein soll, z. B. 25565 für einen bestimmten Spieleserver." />
</StackPanel>
<ui:TextBox Text="{Binding ExternalPort, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Ziel-Gerät (IP-Adresse)'}" />
<controls:InfoHint Text="{l:T 'Die feste Adresse des Geräts in deinem Heimnetz, das erreichbar sein soll.'}" />
<TextBlock Text="Ziel-Gerät (IP-Adresse)" />
<controls:InfoHint Text="Die feste Adresse des Geräts in deinem Heimnetz, das erreichbar sein soll." />
</StackPanel>
<ui:TextBox Text="{Binding TargetAddress, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="4">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Port am Gerät'}" />
<controls:InfoHint Text="{l:T 'Die Portnummer, auf der der Dienst am Zielgerät selbst lauscht – meist identisch mit dem Port von außen.'}" />
<TextBlock Text="Port am Gerät" />
<controls:InfoHint Text="Die Portnummer, auf der der Dienst am Zielgerät selbst lauscht – meist identisch mit dem Port von außen." />
</StackPanel>
<ui:TextBox Text="{Binding InternalPort, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding ValidationMessage}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
TextWrapping="Wrap" Margin="0,4,0,0" />
<StackPanel Margin="0,8,0,0"
Visibility="{Binding DataContext.IsExpertMode, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid Margin="0,0,0,8">
@@ -94,26 +88,26 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Nur von dieser Adresse erreichbar (optional)'}" />
<controls:InfoHint Text="{l:T 'Beschränkt diese Portfreigabe auf eine bestimmte Quell-IP-Adresse statt für jeden aus dem Internet.'}" />
<TextBlock Text="Nur von dieser Adresse erreichbar (optional)" />
<controls:InfoHint Text="Beschränkt diese Portfreigabe auf eine bestimmte Quell-IP-Adresse statt für jeden aus dem Internet." />
</StackPanel>
<ui:TextBox Text="{Binding AllowedSourceAddress, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'bis Port außen (Bereich)'}" />
<controls:InfoHint Text="{l:T 'Optional: Endpunkt eines Portbereichs statt eines einzelnen Ports.'}" />
<TextBlock Text="bis Port außen (Bereich)" />
<controls:InfoHint Text="Optional: Endpunkt eines Portbereichs statt eines einzelnen Ports." />
</StackPanel>
<ui:TextBox Text="{Binding ExternalPortRangeEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="{l:T 'bis Port intern (Bereich)'}" Margin="0,0,0,4" />
<TextBlock Text="bis Port intern (Bereich)" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding InternalPortRangeEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal">
<CheckBox Content="{l:T 'Zugriffe auf diese Regel protokollieren'}" IsChecked="{Binding LogRule}" />
<controls:InfoHint Text="{l:T 'Schreibt jeden Treffer dieser Regel ins RouterOS-Systemprotokoll — nützlich zur Fehlersuche, erzeugt aber zusätzliche Log-Einträge.'}" />
<CheckBox Content="Zugriffe auf diese Regel protokollieren" IsChecked="{Binding LogRule}" />
<controls:InfoHint Text="Schreibt jeden Treffer dieser Regel ins RouterOS-Systemprotokoll — nützlich zur Fehlersuche, erzeugt aber zusätzliche Log-Einträge." />
</StackPanel>
</StackPanel>
</StackPanel>
@@ -122,21 +116,17 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<ui:Button Content="{l:T '+ Portfreigabe hinzufügen'}" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,20"
<ui:Button Content="+ Portfreigabe hinzufügen" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,20"
Command="{Binding AddPortForwardCommand}" />
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{l:T 'Erweiterte Einstellungen'}" FontWeight="SemiBold" Margin="0,0,0,12" />
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,0,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Adressen sperren (optional)'}" />
<controls:InfoHint Text="{l:T 'Eine oder mehrere IP-Adressen bzw. Subnetze (CIDR), die grundsätzlich blockiert werden, z. B. \'203.0.113.0/24\'. Mit Komma oder Zeilenumbruch trennen.'}" />
<TextBlock Text="Adressen sperren (optional)" />
<controls:InfoHint Text="Eine oder mehrere IP-Adressen bzw. Subnetze (CIDR), die grundsätzlich blockiert werden, z. B. '203.0.113.0/24'. Mit Komma oder Zeilenumbruch trennen." />
</StackPanel>
<ui:TextBox Text="{Binding BlockedAddressesText, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True" TextWrapping="Wrap" Height="60" />
<TextBlock Text="{Binding BlockedAddressesValidationMessage}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Visibility="{Binding BlockedAddressesValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
TextWrapping="Wrap" Margin="0,4,0,0" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -1,158 +0,0 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.LanScanStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="600">
<StackPanel Margin="0,16,0,0">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,12" Opacity="0.8" />
<ui:Button Content="{l:T 'Aktualisieren'}" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,16"
IsEnabled="{Binding IsLoading, Converter={StaticResource InverseBooleanConverter}}"
Command="{Binding RetryCommand}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="ArrowSync24" />
</ui:Button.Icon>
</ui:Button>
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Geräte werden ausgelesen …'}" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}">
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,12" />
</StackPanel>
<TextBlock Text="{l:T 'Es wurden keine Geräte gefunden.'}" Opacity="0.6"
Visibility="{Binding ShowEmptyHint, Converter={StaticResource BoolToVisibilityConverter}}" />
<ItemsControl ItemsSource="{Binding PortGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,0,16" Padding="12,10" CornerRadius="8" BorderThickness="4,0,0,0" BorderBrush="{Binding AccentColor}"
Background="{DynamicResource ControlFillColorSecondaryBrush}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<ui:SymbolIcon Symbol="PlugConnected24" Margin="0,0,6,0" Foreground="{Binding AccentColor}" />
<TextBlock Text="{Binding PortLabel}" FontWeight="SemiBold" />
</StackPanel>
<ItemsControl ItemsSource="{Binding Devices}" AlternationCount="2">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="DeviceRow" Padding="8,8" CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Column="0">
<TextBlock>
<Run Text="{Binding HostNameDisplay, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text=" — " Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Run Text="{Binding IpAddressDisplay, Mode=OneWay}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock Text="{Binding MacAddress}" Opacity="0.5" FontSize="12" />
<TextBlock Text="{Binding ErrorMessage}" Foreground="{StaticResource MikrotikRedBrush}"
TextWrapping="Wrap" FontSize="12"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding ActionHint}" Opacity="0.6"
TextWrapping="Wrap" FontSize="12" Margin="0,2,0,0"
Visibility="{Binding ActionHint, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding StatusLabel}" Opacity="0.6" VerticalAlignment="Center" Margin="12,0" />
<ui:Button Grid.Column="3" Content="{l:T 'Werkzeuge'}" Appearance="Secondary" Margin="8,0,0,0" VerticalAlignment="Top"
Command="{Binding DataContext.ToggleToolsCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}"
CommandParameter="{Binding}" />
<!-- Netzwerk-Werkzeuge (Issue #23): Ping/Traceroute/DNS vom Router aus, Port-Scan von diesem PC. -->
<StackPanel Grid.Row="1" Grid.ColumnSpan="4" Margin="0,8,0,0"
Visibility="{Binding ShowTools, Converter={StaticResource BoolToVisibilityConverter}}">
<WrapPanel IsEnabled="{Binding IsToolRunning, Converter={StaticResource InverseBooleanConverter}}">
<ui:Button Content="{l:T 'Ping'}" Appearance="Secondary" Margin="0,0,6,6"
Command="{Binding DataContext.PingCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}" CommandParameter="{Binding}"
IsEnabled="{Binding Device.IpAddress, Converter={StaticResource NullToBoolConverter}}" />
<ui:Button Content="{l:T 'Traceroute'}" Appearance="Secondary" Margin="0,0,6,6"
Command="{Binding DataContext.TracerouteCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}" CommandParameter="{Binding}"
IsEnabled="{Binding Device.IpAddress, Converter={StaticResource NullToBoolConverter}}" />
<ui:Button Content="{l:T 'DNS-Auflösung'}" Appearance="Secondary" Margin="0,0,6,6"
Command="{Binding DataContext.ResolveCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}" CommandParameter="{Binding}"
IsEnabled="{Binding Device.HostName, Converter={StaticResource NullToBoolConverter}}" />
<ui:Button Content="{l:T 'Port-Scan (von diesem PC)'}" Appearance="Secondary" Margin="0,0,6,6"
Command="{Binding DataContext.ScanPortsCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}" CommandParameter="{Binding}"
IsEnabled="{Binding Device.IpAddress, Converter={StaticResource NullToBoolConverter}}" />
</WrapPanel>
<ItemsControl ItemsSource="{Binding ToolOutput}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding PortResults}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- rot = offen, grün = geschlossen, grau = keine Antwort (wie Mac). -->
<Border CornerRadius="4" Padding="6,2" Margin="0,0,6,6" BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrongFillColorDisabledBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Open">
<Setter Property="BorderBrush" Value="{StaticResource MikrotikRedBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Closed">
<Setter Property="BorderBrush" Value="{StaticResource MikrotikAccentBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock FontSize="12">
<Run Text="{Binding Port, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text="{Binding ServiceName, Mode=OneWay}" />
</TextBlock>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock FontSize="11" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap"
Text="{l:T 'Rot = offen (dort antwortet ein Dienst), grün = geschlossen, grau = keine Antwort (Firewall oder Gerät aus).'}"
Visibility="{Binding PortResults.Count, Converter={StaticResource CountToVisibilityConverter}}" />
</StackPanel>
<ui:Button Grid.Column="2" Content="{Binding ActionLabel}" Appearance="Secondary"
IsEnabled="{Binding CanToggleStatic}"
ToolTip="{Binding ActionHint}"
Command="{Binding DataContext.ToggleStaticCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}"
CommandParameter="{Binding}" />
</Grid>
</Border>
<DataTemplate.Triggers>
<Trigger Property="ItemsControl.AlternationIndex" Value="1">
<Setter TargetName="DeviceRow" Property="Background" Value="{DynamicResource ControlFillColorTertiaryBrush}" />
</Trigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</UserControl>
@@ -1,11 +0,0 @@
using System.Windows.Controls;
namespace MikrotikInstaller.App.Views.Steps;
public partial class LanScanStepView : UserControl
{
public LanScanStepView()
{
InitializeComponent();
}
}
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.LanStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.LanStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -11,12 +10,9 @@
<StackPanel Margin="0,16,0,0">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,20" Opacity="0.8" />
<TextBlock Text="{Binding PrefillMessage}" TextWrapping="Wrap" Margin="0,0,0,16" Foreground="{StaticResource MikrotikAccentBrush}"
Visibility="{Binding PrefillMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Anschlüsse in deinem Heimnetzwerk'}" />
<controls:InfoHint Text="{l:T 'Wähle die Anschlüsse, an die deine Geräte zu Hause (PC, Konsole, Drucker, ...) angeschlossen werden.'}" />
<TextBlock Text="Anschlüsse in deinem Heimnetzwerk" />
<controls:InfoHint Text="Wähle die Anschlüsse, an die deine Geräte zu Hause (PC, Konsole, Drucker, ...) angeschlossen werden." />
</StackPanel>
<ItemsControl ItemsSource="{Binding AvailableInterfaces}" Margin="0,0,0,20">
<ItemsControl.ItemTemplate>
@@ -26,25 +22,9 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Port-Konflikte (Mac: "Port freimachen?"). -->
<Border BorderBrush="{StaticResource MikrotikWarningBrush}" BorderThickness="1" CornerRadius="6" Padding="12" Margin="0,0,0,20"
Visibility="{Binding HasPortConflicts, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{l:T 'Diese Anschlüsse sind noch anderweitig belegt:'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<ItemsControl ItemsSource="{Binding PortConflictLines}" Margin="0,0,0,8">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" Margin="0,0,0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<CheckBox Content="{l:T 'Port freimachen: diese Einträge beim Anwenden entfernen'}" IsChecked="{Binding ReleaseConflictingPorts}" />
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Name des Heimnetzwerks (intern)'}" />
<controls:InfoHint Text="{l:T 'Ein interner Name für dein Heimnetzwerk auf dem Router. Wird nirgends angezeigt – du kannst den Vorschlag einfach übernehmen.'}" />
<TextBlock Text="Name des Heimnetzwerks (intern)" />
<controls:InfoHint Text="Ein interner Name für dein Heimnetzwerk auf dem Router. Wird nirgends angezeigt – du kannst den Vorschlag einfach übernehmen." />
</StackPanel>
<ui:TextBox Text="{Binding BridgeName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="200" />
@@ -56,15 +36,15 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Router-Adresse in deinem Netzwerk'}" />
<controls:InfoHint Text="{l:T 'Die Adresse, unter der dein Router selbst in deinem Heimnetz erreichbar ist – das Gateway für alle deine Geräte.'}" />
<TextBlock Text="Router-Adresse in deinem Netzwerk" />
<controls:InfoHint Text="Die Adresse, unter der dein Router selbst in deinem Heimnetz erreichbar ist – das Gateway für alle deine Geräte." />
</StackPanel>
<ui:TextBox Text="{Binding RouterAddress, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Präfix'}" />
<controls:InfoHint Text="{l:T 'Gibt an, wie groß dein Heimnetzwerk ist. 24 erlaubt bis zu 254 Geräte – für die meisten Haushalte passend.'}" />
<TextBlock Text="Präfix" />
<controls:InfoHint Text="Gibt an, wie groß dein Heimnetzwerk ist. 24 erlaubt bis zu 254 Geräte – für die meisten Haushalte passend." />
</StackPanel>
<ui:TextBox Text="{Binding PrefixLength, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
@@ -78,13 +58,13 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Adressbereich von'}" />
<controls:InfoHint Text="{l:T 'Der Bereich, aus dem deine Geräte automatisch eine Adresse zugewiesen bekommen.'}" />
<TextBlock Text="Adressbereich von" />
<controls:InfoHint Text="Der Bereich, aus dem deine Geräte automatisch eine Adresse zugewiesen bekommen." />
</StackPanel>
<ui:TextBox Text="{Binding DhcpPoolStart, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'bis'}" Margin="0,0,0,4" />
<TextBlock Text="bis" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding DhcpPoolEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
@@ -97,42 +77,37 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'DNS-Server (bevorzugt)'}" />
<controls:InfoHint Text="{l:T 'Übersetzt Internetadressen wie \'google.de\' in die dahinterliegende Nummer. Die Vorgabe passt für die meisten – du musst hier nichts ändern.'}" />
<TextBlock Text="DNS-Server (bevorzugt)" />
<controls:InfoHint Text="Übersetzt Internetadressen wie 'google.de' in die dahinterliegende Nummer. Die Vorgabe passt für die meisten – du musst hier nichts ändern." />
</StackPanel>
<ui:TextBox Text="{Binding PrimaryDns, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'DNS-Server (alternativ, optional)'}" Margin="0,0,0,4" />
<TextBlock Text="DNS-Server (alternativ, optional)" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding SecondaryDns, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{l:T 'Erweiterte Einstellungen'}" FontWeight="SemiBold" Margin="0,8,0,12" />
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,8,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Adresse behalten für (DHCP-Lease-Zeit)'}" />
<controls:InfoHint Text="{l:T 'Wie lange eine vergebene Adresse einem Gerät zugeordnet bleibt. Richtwerte: Heimnetz 1 Woche, Gäste 1 Tag, feste Geräte gern länger. Alles 0 = RouterOS-Standardwert.'}" />
<TextBlock Text="DHCP-Lease-Zeit (optional)" />
<controls:InfoHint Text="Wie lange eine vergebene Adresse einem Gerät zugeordnet bleibt, z. B. '1d' für einen Tag. Leer lassen für den RouterOS-Standardwert." />
</StackPanel>
<controls:LeaseTimePicker DataContext="{Binding Lease}" Margin="0,0,0,16" HorizontalAlignment="Left" />
<ui:TextBox Text="{Binding DhcpLeaseTime, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="120" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Domainname (optional)'}" />
<controls:InfoHint Text="{l:T 'Ein interner Domainname für dein Heimnetz, den der DHCP-Server an Geräte weitergibt (z. B. \'heim.lan\').'}" />
<TextBlock Text="Domainname (optional)" />
<controls:InfoHint Text="Ein interner Domainname für dein Heimnetz, den der DHCP-Server an Geräte weitergibt (z. B. 'heim.lan')." />
</StackPanel>
<ui:TextBox Text="{Binding DomainName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Zeitserver / NTP (optional)'}" />
<controls:InfoHint Text="{l:T 'Ein NTP-Server, über den der Router seine Systemzeit synchronisiert, z. B. \'pool.ntp.org\'.'}" />
<TextBlock Text="Zeitserver / NTP (optional)" />
<controls:InfoHint Text="Ein NTP-Server, über den der Router seine Systemzeit synchronisiert, z. B. 'pool.ntp.org'." />
</StackPanel>
<ui:TextBox Text="{Binding NtpServer, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
</StackPanel>
<TextBlock Text="{Binding ValidationMessage}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
TextWrapping="Wrap" Margin="0,4,0,0" />
</StackPanel>
</UserControl>
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.ModeSelectionStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -19,8 +18,8 @@
<RadioButton Grid.Column="0" GroupName="WizardMode" VerticalAlignment="Top" Margin="0,2,12,0"
IsChecked="{Binding IsExpertMode, Converter={StaticResource InverseBooleanConverter}}" />
<StackPanel Grid.Column="1">
<TextBlock Text="{l:T 'Geführter Assistent'}" FontWeight="SemiBold" />
<TextBlock Text="{l:T 'Die einfachen, laientauglichen Fragen aus den nächsten Schritten reichen für die meisten Haushalte völlig aus.'}" TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
<TextBlock Text="Geführter Assistent" FontWeight="SemiBold" />
<TextBlock Text="Die einfachen, laientauglichen Fragen aus den nächsten Schritten reichen für die meisten Haushalte völlig aus." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
@@ -34,8 +33,8 @@
<RadioButton Grid.Column="0" GroupName="WizardMode" VerticalAlignment="Top" Margin="0,2,12,0"
IsChecked="{Binding IsExpertMode}" />
<StackPanel Grid.Column="1">
<TextBlock Text="{l:T 'Experten-Modus'}" FontWeight="SemiBold" />
<TextBlock Text="{l:T 'Jeder folgende Schritt zeigt zusätzliche, technischere RouterOS-Einstellungen (z. B. DHCP-Feinsteuerung, WPA3, Portfreigaben mit Quell-IP-Filter). Nur wählen, wenn dir diese Begriffe etwas sagen.'}" TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
<TextBlock Text="Experten-Modus" FontWeight="SemiBold" />
<TextBlock Text="Jeder folgende Schritt zeigt zusätzliche, technischere RouterOS-Einstellungen (z. B. DHCP-Feinsteuerung, WPA3, Portfreigaben mit Quell-IP-Filter). Nur wählen, wenn dir diese Begriffe etwas sagen." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.NetworkAnalysisStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -76,32 +75,21 @@
<TextBlock Grid.Row="0" Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,16" Opacity="0.8" />
<StackPanel Grid.Row="1" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal">
<ui:Button Content="{l:T 'Pfad zurücksetzen'}" Appearance="Secondary" Command="{Binding ClearSelectionCommand}"
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,12">
<ui:Button Content="Pfad zurücksetzen" Appearance="Secondary" Command="{Binding ClearSelectionCommand}"
Visibility="{Binding SelectedTopologyItem, Converter={StaticResource NullToVisibilityConverter}}" />
<ui:Button Content="{l:T 'Bearbeiten'}" Appearance="Secondary" Margin="8,0,0,0" Command="{Binding EditSelectedNodeCommand}"
Visibility="{Binding CanEditSelected, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Edit24" />
</ui:Button.Icon>
</ui:Button>
</StackPanel>
<TextBlock Text="{Binding EditMessage}" TextWrapping="Wrap" Margin="0,8,0,0" FontFamily="Consolas"
Visibility="{Binding EditMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<ContentControl Content="{Binding Editor}" Margin="0,8,0,0" />
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" VerticalAlignment="Top"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Bestehende Konfiguration wird ausgelesen …'}" VerticalAlignment="Center" />
<TextBlock Text="Bestehende Konfiguration wird ausgelesen …" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Row="2" Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" VerticalAlignment="Top"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Grid.Row="2" Text="{l:T 'Auf diesem Gerät wurde keine bestehende Konfiguration gefunden — vermutlich ein werksneues Gerät. Weiter geht\'s mit der Einrichtung.'}" TextWrapping="Wrap" Opacity="0.8" VerticalAlignment="Top">
<TextBlock Grid.Row="2" Text="Auf diesem Gerät wurde keine bestehende Konfiguration gefunden — vermutlich ein werksneues Gerät. Weiter geht's mit der Einrichtung." TextWrapping="Wrap" Opacity="0.8" VerticalAlignment="Top">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.SummaryStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.SummaryStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -11,44 +10,16 @@
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,20" Opacity="0.8" />
<StackPanel Visibility="{Binding IsFinished, Converter={StaticResource BoolToVisibilityConverter}}" Margin="0,0,0,20">
<TextBlock Text="{l:T 'Fertig! Dein Gerät wurde erfolgreich eingerichtet.'}" FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,8" />
<TextBlock Text="Fertig! Dein Gerät wurde erfolgreich eingerichtet." FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,8" />
<TextBlock TextWrapping="Wrap" Opacity="0.8">
<Run Text="{l:T 'Falls doch mal etwas nicht passt: Auf deinem Gerät liegt unter dem Namen „'}" />
<Run Text="Falls doch mal etwas nicht passt: Auf deinem Gerät liegt unter dem Namen „" />
<Run Text="{Binding BackupName}" FontWeight="SemiBold" />
<Run Text="{l:T '“ eine Sicherung deines vorherigen Zustands.'}" />
<Run Text="“ eine Sicherung deines vorherigen Zustands." />
</TextBlock>
</StackPanel>
<!-- Abschluss-Übersicht / bisher ausgeführte Schritte (auch nach einem Fehler hilfreich). -->
<StackPanel Margin="0,0,0,20">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsFinished}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" Value="Visible">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="{l:T 'Ausgeführt'}" FontWeight="SemiBold" Margin="0,0,0,6" />
<ItemsControl ItemsSource="{Binding CompletedSteps}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,0,3">
<ui:SymbolIcon Symbol="Checkmark16" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,8,0" VerticalAlignment="Top" />
<TextBlock Text="{Binding}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Visibility="{Binding IsFinished, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}">
<TextBlock Text="{l:T 'Das richten wir für dich ein'}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12" />
<TextBlock Text="Das richten wir für dich ein" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12" />
<ItemsControl ItemsSource="{Binding Sections}">
<ItemsControl.ItemTemplate>
@@ -77,22 +48,21 @@
</ItemsControl>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<ui:Button Content="{l:T 'Jetzt einrichten'}" Appearance="Primary" Margin="0,0,8,0"
<ui:Button Content="Jetzt einrichten" Appearance="Primary" Margin="0,0,8,0"
Command="{Binding ApplyCommand}" />
<ui:Button Content="{l:T 'Als PDF speichern'}" Appearance="Secondary"
<ui:Button Content="Als PDF speichern" Appearance="Secondary"
Command="{Binding ExportPdfCommand}" />
</StackPanel>
</StackPanel>
<ui:Button Content="{l:T 'Als PDF speichern'}" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,0"
<ui:Button Content="Als PDF speichern" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,0"
Command="{Binding ExportPdfCommand}"
Visibility="{Binding IsFinished, Converter={StaticResource BoolToVisibilityConverter}}" />
<StackPanel Margin="0,16,0,0" Visibility="{Binding IsApplying, Converter={StaticResource BoolToVisibilityConverter}}">
<ProgressBar Value="{Binding ProgressValue}" Maximum="{Binding ProgressMaximum}" Height="6" Margin="0,0,0,8" />
<TextBlock Text="{Binding ProgressText}" TextWrapping="Wrap" />
<TextBlock Text="{l:T 'Je nach Router-Hardware kann das eine Weile dauern — bitte das Fenster offen lassen.'}" FontSize="12" Margin="0,4,0,0"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Margin="0,16,0,0"
Visibility="{Binding IsApplying, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{Binding ProgressText}" VerticalAlignment="Center" TextWrapping="Wrap" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,16,0,0"
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.UpdateStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.UpdateStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -12,20 +11,20 @@
<StackPanel Orientation="Horizontal" Visibility="{Binding IsChecking, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Es wird geprüft, ob ein Update verfügbar ist …'}" VerticalAlignment="Center" />
<TextBlock Text="Es wird geprüft, ob ein Update verfügbar ist …" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Visibility="{Binding ShowCheckFailed, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock TextWrapping="Wrap" Opacity="0.8" Margin="0,0,0,12">
Es konnte nicht geprüft werden, ob ein Update verfügbar ist. Das liegt meist daran, dass dein Gerät (noch) keinen Internetzugang hat.
</TextBlock>
<ui:Button Content="{l:T 'Erneut prüfen'}" Appearance="Secondary" HorizontalAlignment="Left"
<ui:Button Content="Erneut prüfen" Appearance="Secondary" HorizontalAlignment="Left"
Command="{Binding RecheckCommand}" />
</StackPanel>
<StackPanel Visibility="{Binding ShowUpToDate, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock TextWrapping="Wrap" FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}">
<Run Text="{l:T 'Dein Gerät ist bereits auf dem neuesten Stand (Version'}" />
<Run Text="Dein Gerät ist bereits auf dem neuesten Stand (Version" />
<Run Text="{Binding InstalledVersion, Mode=OneWay}" />
<Run Text=")." />
</TextBlock>
@@ -33,7 +32,7 @@
<StackPanel Visibility="{Binding ShowUpdateAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock TextWrapping="Wrap" Margin="0,0,0,16">
<Run Text="{l:T 'Eine neuere Version ist verfügbar: '}" FontWeight="SemiBold" />
<Run Text="Eine neuere Version ist verfügbar: " FontWeight="SemiBold" />
<Run Text="{Binding InstalledVersion, Mode=OneWay}" />
<Run Text=" → " />
<Run Text="{Binding LatestVersion, Mode=OneWay}" FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" />
@@ -43,15 +42,15 @@
Beim Update startet dein Gerät automatisch neu. Das dauert normalerweise ein bis zwei Minuten, in denen dein Netzwerk kurz nicht erreichbar ist. In seltenen Fällen (z. B. Stromausfall während des Updates) kann ein Update fehlschlagen.
</TextBlock>
<CheckBox Content="{l:T 'Ich bin mir bewusst, dass mein Gerät dabei neu startet'}" IsChecked="{Binding ConfirmReboot}" Margin="0,0,0,16" />
<CheckBox Content="Ich bin mir bewusst, dass mein Gerät dabei neu startet" IsChecked="{Binding ConfirmReboot}" Margin="0,0,0,16" />
<ui:Button Content="{l:T 'Jetzt aktualisieren'}" Appearance="Primary" HorizontalAlignment="Left"
<ui:Button Content="Jetzt aktualisieren" Appearance="Primary" HorizontalAlignment="Left"
Command="{Binding StartUpdateCommand}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,16,0,0" Visibility="{Binding IsUpdating, Converter={StaticResource BoolToVisibilityConverter}}">
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
<TextBlock Text="{l:T 'Update wird gestartet …'}" VerticalAlignment="Center" />
<TextBlock Text="Update wird gestartet …" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Visibility="{Binding IsUpdateStarted, Converter={StaticResource BoolToVisibilityConverter}}">
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.VlanStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.VlanStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -25,17 +24,17 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="{l:T 'Name'}" Margin="0,0,0,4" />
<TextBlock Text="Name" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'VLAN-ID'}" />
<controls:InfoHint Text="{l:T 'Eine Nummer, die dieses Netzwerk eindeutig kennzeichnet (2–4094). Jede Nummer darf nur einmal vergeben werden.'}" />
<TextBlock Text="VLAN-ID" />
<controls:InfoHint Text="Eine Nummer, die dieses Netzwerk eindeutig kennzeichnet (2–4094). Jede Nummer darf nur einmal vergeben werden." />
</StackPanel>
<ui:TextBox Text="{Binding VlanId, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<ui:Button Grid.Column="4" Content="{l:T 'Entfernen'}" Appearance="Secondary" VerticalAlignment="Bottom"
<ui:Button Grid.Column="4" Content="Entfernen" Appearance="Secondary" VerticalAlignment="Bottom"
Command="{Binding DataContext.RemoveVlanCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
</Grid>
@@ -50,26 +49,21 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Router-Adresse'}" />
<controls:InfoHint Text="{l:T 'Die Adresse, unter der dein Router in diesem zusätzlichen Netzwerk erreichbar ist.'}" />
<TextBlock Text="Router-Adresse" />
<controls:InfoHint Text="Die Adresse, unter der dein Router in diesem zusätzlichen Netzwerk erreichbar ist." />
</StackPanel>
<ui:TextBox Text="{Binding RouterAddress, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="{l:T 'Adressbereich von'}" Margin="0,0,0,4" />
<TextBlock Text="Adressbereich von" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding PoolStart, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="{l:T 'bis'}" Margin="0,0,0,4" />
<TextBlock Text="bis" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding PoolEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding FormatValidationMessage}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Visibility="{Binding FormatValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
TextWrapping="Wrap" Margin="0,4,0,0" />
<StackPanel Margin="0,12,0,0"
Visibility="{Binding DataContext.IsExpertMode, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid Margin="0,0,0,8">
@@ -80,36 +74,34 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Präfix'}" />
<controls:InfoHint Text="{l:T 'Gibt an, wie groß dieses VLAN-Netzwerk ist. 24 erlaubt bis zu 254 Geräte.'}" />
<TextBlock Text="Präfix" />
<controls:InfoHint Text="Gibt an, wie groß dieses VLAN-Netzwerk ist. 24 erlaubt bis zu 254 Geräte." />
</StackPanel>
<ui:TextBox Text="{Binding PrefixLength, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'DNS-Server-Override (optional)'}" />
<controls:InfoHint Text="{l:T 'Nutzt einen eigenen DNS-Server für dieses VLAN statt der Router-Adresse selbst.'}" />
<TextBlock Text="DNS-Server-Override (optional)" />
<controls:InfoHint Text="Nutzt einen eigenen DNS-Server für dieses VLAN statt der Router-Adresse selbst." />
</StackPanel>
<ui:TextBox Text="{Binding DnsServerOverride, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="{l:T 'DHCP-Lease-Zeit (optional)'}" />
<controls:InfoHint Text="{l:T 'Wie lange eine vergebene Adresse einem Gerät in diesem VLAN zugeordnet bleibt. Richtwerte: Gäste 1 Tag, Heimgeräte 1 Woche. Alles 0 = RouterOS-Standard.'}" />
</StackPanel>
<StackPanel Margin="0,0,0,8">
<controls:LeaseTimePicker DataContext="{Binding Lease}" />
<TextBlock Text="DHCP-Lease-Zeit (optional)" />
<controls:InfoHint Text="Wie lange eine vergebene Adresse einem Gerät in diesem VLAN zugeordnet bleibt, z. B. '1d'." />
<ui:TextBox Text="{Binding DhcpLeaseTime, UpdateSourceTrigger=PropertyChanged}" Margin="12,0,0,0" MinWidth="100" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<CheckBox Content="{l:T 'Von deinem Heimnetz isolieren'}" IsChecked="{Binding IsolateFromLan}" />
<controls:InfoHint Text="{l:T 'Geräte in diesem VLAN können dann nicht mehr auf dein Heimnetz zugreifen und umgekehrt — sinnvoll z. B. für ein Gästenetz.'}" />
<CheckBox Content="Von deinem Heimnetz isolieren" IsChecked="{Binding IsolateFromLan}" />
<controls:InfoHint Text="Geräte in diesem VLAN können dann nicht mehr auf dein Heimnetz zugreifen und umgekehrt — sinnvoll z. B. für ein Gästenetz." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Fest zuordnen (portbasiert)'}" />
<controls:InfoHint Text="{l:T 'Wähle Anschlüsse, die automatisch zu diesem Netzwerk gehören sollen — z. B. wenn dort ein einzelnes Gerät ohne eigene VLAN-Unterstützung angeschlossen wird. Ein Anschluss kann jeweils nur einem Netzwerk zugeordnet sein.'}" />
<TextBlock Text="Fest zuordnen (portbasiert)" />
<controls:InfoHint Text="Wähle Anschlüsse, die automatisch zu diesem Netzwerk gehören sollen — z. B. wenn dort ein einzelnes Gerät ohne eigene VLAN-Unterstützung angeschlossen wird. Ein Anschluss kann jeweils nur einem Netzwerk zugeordnet sein." />
</StackPanel>
<ItemsControl ItemsSource="{Binding AccessPortOptions}">
<ItemsControl.ItemTemplate>
@@ -129,7 +121,7 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<ui:Button Content="{l:T '+ Netzwerk hinzufügen'}" Appearance="Secondary" HorizontalAlignment="Left"
<ui:Button Content="+ Netzwerk hinzufügen" Appearance="Secondary" HorizontalAlignment="Left"
Command="{Binding AddVlanCommand}" />
</StackPanel>
</UserControl>
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.WanStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.WanStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -12,59 +11,54 @@
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,24" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Internet-Schnittstelle'}" />
<controls:InfoHint Text="{l:T 'Der Anschluss deines Routers, an dem das Kabel von deinem Modem oder deiner Glasfaserdose angeschlossen ist.'}" />
<TextBlock Text="Internet-Schnittstelle" />
<controls:InfoHint Text="Der Anschluss deines Routers, an dem das Kabel von deinem Modem oder deiner Glasfaserdose angeschlossen ist." />
</StackPanel>
<ComboBox ItemsSource="{Binding AvailableInterfaces}" SelectedItem="{Binding SelectedInterface}"
Margin="0,0,0,20" HorizontalAlignment="Left" MinWidth="220" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<CheckBox Content="{l:T 'Feste IP-Adresse statt automatischem Bezug (DHCP) verwenden'}" IsChecked="{Binding UseStaticAddress}" />
<controls:InfoHint Text="{l:T 'DHCP verteilt die Internetadresse automatisch – die übliche und einfachste Einstellung. Nur aktivieren, wenn dir dein Anbieter ausdrücklich eine feste (unveränderliche) IP-Adresse vorgegeben hat.'}" />
<CheckBox Content="Feste IP-Adresse statt automatischem Bezug (DHCP) verwenden" IsChecked="{Binding UseStaticAddress}" />
<controls:InfoHint Text="DHCP verteilt die Internetadresse automatisch – die übliche und einfachste Einstellung. Nur aktivieren, wenn dir dein Anbieter ausdrücklich eine feste (unveränderliche) IP-Adresse vorgegeben hat." />
</StackPanel>
<StackPanel Visibility="{Binding UseStaticAddress, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Feste IP-Adresse (mit Präfix, z. B. 203.0.113.5/24)'}" />
<controls:InfoHint Text="{l:T 'Die genaue Adresse, die dir dein Internetanbieter zugewiesen hat. Die Zahl nach dem Schrägstrich (Präfix) gibt die Größe des Netzwerks an – steht in den Unterlagen deines Anbieters.'}" />
<TextBlock Text="Feste IP-Adresse (mit Präfix, z. B. 203.0.113.5/24)" />
<controls:InfoHint Text="Die genaue Adresse, die dir dein Internetanbieter zugewiesen hat. Die Zahl nach dem Schrägstrich (Präfix) gibt die Größe des Netzwerks an – steht in den Unterlagen deines Anbieters." />
</StackPanel>
<ui:TextBox Text="{Binding StaticAddress, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Gateway (Adresse deines Internetanbieters)'}" />
<controls:InfoHint Text="{l:T 'Die Adresse des Geräts deines Anbieters, über das dein Router ins Internet gelangt – ebenfalls von deinem Anbieter vorgegeben.'}" />
<TextBlock Text="Gateway (Adresse deines Internetanbieters)" />
<controls:InfoHint Text="Die Adresse des Geräts deines Anbieters, über das dein Router ins Internet gelangt – ebenfalls von deinem Anbieter vorgegeben." />
</StackPanel>
<ui:TextBox Text="{Binding StaticGateway, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
</StackPanel>
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{l:T 'Erweiterte Einstellungen'}" FontWeight="SemiBold" Margin="0,8,0,12" />
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,8,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="{l:T 'Standardroute vom Internetanbieter übernehmen (nur bei DHCP)'}" IsChecked="{Binding UseDhcpDefaultRoute}" />
<controls:InfoHint Text="{l:T 'Legt fest, ob der DHCP-Client automatisch die Standardroute ins Internet einträgt. In den meisten Fällen aktiviert lassen.'}" />
<CheckBox Content="Standardroute vom Internetanbieter übernehmen (nur bei DHCP)" IsChecked="{Binding UseDhcpDefaultRoute}" />
<controls:InfoHint Text="Legt fest, ob der DHCP-Client automatisch die Standardroute ins Internet einträgt. In den meisten Fällen aktiviert lassen." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<CheckBox Content="{l:T 'DNS-Server vom Internetanbieter übernehmen (nur bei DHCP)'}" IsChecked="{Binding UseDhcpDns}" />
<controls:InfoHint Text="{l:T 'Legt fest, ob der DHCP-Client die vom Anbieter gelieferten DNS-Server übernimmt.'}" />
<CheckBox Content="DNS-Server vom Internetanbieter übernehmen (nur bei DHCP)" IsChecked="{Binding UseDhcpDns}" />
<controls:InfoHint Text="Legt fest, ob der DHCP-Client die vom Anbieter gelieferten DNS-Server übernimmt." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'MTU (optional)'}" />
<controls:InfoHint Text="{l:T 'Maximale Paketgröße auf dieser Schnittstelle. Nur ändern, wenn dein Anbieter (z. B. bei PPPoE/DSL) einen bestimmten Wert vorgibt.'}" />
<TextBlock Text="MTU (optional)" />
<controls:InfoHint Text="Maximale Paketgröße auf dieser Schnittstelle. Nur ändern, wenn dein Anbieter (z. B. bei PPPoE/DSL) einen bestimmten Wert vorgibt." />
</StackPanel>
<ui:TextBox Text="{Binding Mtu, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="120" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'NAT nur für dieses Subnetz (optional)'}" />
<controls:InfoHint Text="{l:T 'Beschränkt die Internet-Weiterleitung (Masquerade) auf ein bestimmtes Subnetz, z. B. \'192.168.88.0/24\', statt für dein gesamtes Heimnetz.'}" />
<TextBlock Text="NAT nur für dieses Subnetz (optional)" />
<controls:InfoHint Text="Beschränkt die Internet-Weiterleitung (Masquerade) auf ein bestimmtes Subnetz, z. B. '192.168.88.0/24', statt für dein gesamtes Heimnetz." />
</StackPanel>
<ui:TextBox Text="{Binding NatSourceAddress, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
</StackPanel>
<TextBlock Text="{Binding ValidationMessage}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
TextWrapping="Wrap" Margin="0,4,0,0" />
</StackPanel>
</UserControl>
@@ -1,5 +1,4 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.WlanStepView"
xmlns:l="clr-namespace:MikrotikInstaller.App.Localization"
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.WlanStepView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
@@ -12,46 +11,46 @@
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,20" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<CheckBox Content="{l:T 'Kein WLAN einrichten (z. B. bei einem reinen Switch oder Router ohne Funk)'}"
<CheckBox Content="Kein WLAN einrichten (z. B. bei einem reinen Switch oder Router ohne Funk)"
IsChecked="{Binding SkipWireless}" />
<controls:InfoHint Text="{l:T 'Dein Gerät hat zwar eine WLAN-Schnittstelle, sie bleibt aber unangetastet und wird nicht eingerichtet, wenn du hier ein Häkchen setzt.'}" />
<controls:InfoHint Text="Dein Gerät hat zwar eine WLAN-Schnittstelle, sie bleibt aber unangetastet und wird nicht eingerichtet, wenn du hier ein Häkchen setzt." />
</StackPanel>
<StackPanel IsEnabled="{Binding SkipWireless, Converter={StaticResource InverseBooleanConverter}}">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'WLAN-Schnittstelle'}" />
<controls:InfoHint Text="{l:T 'Der WLAN-Funkteil deines Routers, über den sich Geräte drahtlos verbinden.'}" />
<TextBlock Text="WLAN-Schnittstelle" />
<controls:InfoHint Text="Der WLAN-Funkteil deines Routers, über den sich Geräte drahtlos verbinden." />
</StackPanel>
<ComboBox ItemsSource="{Binding AvailableInterfaces}" SelectedItem="{Binding SelectedInterface}"
Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'WLAN-Name (SSID)'}" />
<controls:InfoHint Text="{l:T 'Der Name, den andere Geräte in der Liste verfügbarer WLAN-Netzwerke sehen und auswählen.'}" />
<TextBlock Text="WLAN-Name (SSID)" />
<controls:InfoHint Text="Der Name, den andere Geräte in der Liste verfügbarer WLAN-Netzwerke sehen und auswählen." />
</StackPanel>
<ui:TextBox Text="{Binding Ssid, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="300" />
<TextBlock Text="{l:T 'WLAN-Passwort (mindestens 8 Zeichen)'}" Margin="0,0,0,4" />
<TextBlock Text="WLAN-Passwort (mindestens 8 Zeichen)" Margin="0,0,0,4" />
<ui:PasswordBox Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="300" />
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{l:T 'Erweiterte Einstellungen'}" FontWeight="SemiBold" Margin="0,8,0,12" />
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,8,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Sicherheitsmodus'}" />
<controls:InfoHint Text="{l:T 'WPA2 ist mit praktisch jedem Gerät kompatibel. WPA3 ist sicherer, wird aber nicht von jedem älteren Gerät unterstützt. Gemischt erlaubt beides gleichzeitig.'}" />
<TextBlock Text="Sicherheitsmodus" />
<controls:InfoHint Text="WPA2 ist mit praktisch jedem Gerät kompatibel. WPA3 ist sicherer, wird aber nicht von jedem älteren Gerät unterstützt. Gemischt erlaubt beides gleichzeitig." />
</StackPanel>
<ComboBox ItemsSource="{Binding SecurityModes}" SelectedItem="{Binding SecurityMode}"
Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="180" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<CheckBox Content="{l:T 'WLAN-Name (SSID) verstecken'}" IsChecked="{Binding HideSsid}" />
<controls:InfoHint Text="{l:T 'Dein WLAN erscheint dann nicht mehr in der Liste verfügbarer Netzwerke — Geräte müssen den Namen manuell eingeben. Bietet keinen echten Sicherheitsgewinn, nur weniger Sichtbarkeit.'}" />
<CheckBox Content="WLAN-Name (SSID) verstecken" IsChecked="{Binding HideSsid}" />
<controls:InfoHint Text="Dein WLAN erscheint dann nicht mehr in der Liste verfügbarer Netzwerke — Geräte müssen den Namen manuell eingeben. Bietet keinen echten Sicherheitsgewinn, nur weniger Sichtbarkeit." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{l:T 'Band/Kanal (optional)'}" />
<controls:InfoHint Text="{l:T 'Schränkt das Funkband ein, z. B. \'5ghz-a/n/ac\' oder \'2ghz-b/g/n\'. Leer lassen, damit RouterOS automatisch wählt.'}" />
<TextBlock Text="Band/Kanal (optional)" />
<controls:InfoHint Text="Schränkt das Funkband ein, z. B. '5ghz-a/n/ac' oder '2ghz-b/g/n'. Leer lassen, damit RouterOS automatisch wählt." />
</StackPanel>
<ui:TextBox Text="{Binding Band, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
</StackPanel>
@@ -1,243 +0,0 @@
using MikrotikInstaller.Core.Backup;
using MikrotikInstaller.Core.Tests.Configuration;
namespace MikrotikInstaller.Core.Tests.Backup;
public class BackupRestoreServiceTests
{
[Fact]
public async Task ListBackupsAsync_FiltersToBackupFilesAndParsesModelOnly()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/file"] =
[
new Dictionary<string, string> { ["name"] = "mikrotik-installer-RB750Gr3-20260101-120000.backup", ["creation-time"] = "2026-01-01T12:00:00Z" },
new Dictionary<string, string> { ["name"] = "export.rsc" },
new Dictionary<string, string> { ["name"] = "winbox-backup.backup", ["creation-time"] = "2025-06-01T08:00:00Z" },
];
var backups = await BackupRestoreService.ListBackupsAsync(client);
Assert.Equal(2, backups.Count);
Assert.Equal("RB750Gr3", backups[0].Model);
Assert.Null(backups[0].Architecture);
Assert.Null(backups[0].SerialNumber);
Assert.Null(backups[1].Model);
}
[Fact]
public async Task ListBackupsAsync_ParsesModelAndArchitectureWithoutSerial()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/file"] =
[
new Dictionary<string, string> { ["name"] = "mikrotik-installer-RB750Gr3_arm-20260101-120000.backup", ["creation-time"] = "2026-01-01T12:00:00Z" },
];
var backup = Assert.Single(await BackupRestoreService.ListBackupsAsync(client));
Assert.Equal("RB750Gr3", backup.Model);
Assert.Equal("arm", backup.Architecture);
Assert.Null(backup.SerialNumber);
}
[Fact]
public async Task ListBackupsAsync_ParsesModelArchitectureAndSerialNumber()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/file"] =
[
new Dictionary<string, string> { ["name"] = "mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", ["creation-time"] = "2026-01-01T12:00:00Z" },
];
var backup = Assert.Single(await BackupRestoreService.ListBackupsAsync(client));
Assert.Equal("RB750Gr3", backup.Model);
Assert.Equal("arm", backup.Architecture);
Assert.Equal("ABC123", backup.SerialNumber);
}
[Fact]
public async Task ListBackupsAsync_OrdersByCreationTimeDescending()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/file"] =
[
new Dictionary<string, string> { ["name"] = "older.backup", ["creation-time"] = "2025-01-01T00:00:00Z" },
new Dictionary<string, string> { ["name"] = "newer.backup", ["creation-time"] = "2026-01-01T00:00:00Z" },
];
var backups = await BackupRestoreService.ListBackupsAsync(client);
Assert.Equal("newer.backup", backups[0].FileName);
Assert.Equal("older.backup", backups[1].FileName);
}
[Fact]
public void CheckCompatibility_ExactMatch_IsMatch()
{
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", "ABC123", null);
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "ABC123");
Assert.Equal(BackupCompatibility.Match, result);
}
[Fact]
public void CheckCompatibility_SameModelAndArchitectureButDifferentSerial_IsSameModelDifferentDevice()
{
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", "ABC123", null);
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "XYZ999");
Assert.Equal(BackupCompatibility.SameModelDifferentDevice, result);
}
[Fact]
public void CheckCompatibility_SameModelAndArchitectureButNoSerialKnownOnEitherSide_IsMatch()
{
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", null, null);
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", null);
Assert.Equal(BackupCompatibility.Match, result);
}
[Fact]
public void CheckCompatibility_DifferentModel_IsMismatchRegardlessOfSerial()
{
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", "ABC123", null);
var result = BackupRestoreService.CheckCompatibility(backup, "hAP ac2", "arm", "ABC123");
Assert.Equal(BackupCompatibility.Mismatch, result);
}
[Fact]
public void CheckCompatibility_SameModelButDifferentArchitecture_IsMismatch()
{
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", null, null);
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "mipsbe", null);
Assert.Equal(BackupCompatibility.Mismatch, result);
}
[Fact]
public void CheckCompatibility_ModelOnlyBackupWithMatchingModel_IsMatch()
{
// Ältere Backups ohne eingebettete Architektur/Seriennummer: reiner Modell-Abgleich als Fallback.
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", null, null, null);
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "ABC123");
Assert.Equal(BackupCompatibility.Match, result);
}
[Fact]
public void CheckCompatibility_NoModelInFileName_IsUnknown()
{
var backup = new BackupFileInfo("winbox-backup.backup", null, null, null, null);
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "ABC123");
Assert.Equal(BackupCompatibility.Unknown, result);
}
[Fact]
public async Task RestoreAsync_Match_ExecutesBackupLoad()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", "RB750Gr3", "arm", "ABC123", null);
await BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "ABC123", confirmedByUser: false);
var call = Assert.Single(client.ExecuteCalls);
Assert.Equal("/system/backup/load", call.Path);
Assert.Equal(backup.FileName, call.Parameters?["name"]);
}
[Fact]
public async Task RestoreAsync_Mismatch_ThrowsEvenWithConfirmation()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3-20260101-120000.backup", "RB750Gr3", null, null, null);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BackupRestoreService.RestoreAsync(client, backup, "hAP ac2", "arm", null, confirmedByUser: true));
Assert.Empty(client.ExecuteCalls);
}
[Fact]
public async Task RestoreAsync_SameModelDifferentDeviceWithoutConfirmation_Throws()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", "RB750Gr3", "arm", "ABC123", null);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "XYZ999", confirmedByUser: false));
Assert.Empty(client.ExecuteCalls);
}
[Fact]
public async Task RestoreAsync_SameModelDifferentDeviceWithConfirmation_ExecutesBackupLoad()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", "RB750Gr3", "arm", "ABC123", null);
await BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "XYZ999", confirmedByUser: true);
Assert.Single(client.ExecuteCalls);
}
[Fact]
public async Task RestoreAsync_UnknownWithoutConfirmation_Throws()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("winbox-backup.backup", null, null, null, null);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "ABC123", confirmedByUser: false));
Assert.Empty(client.ExecuteCalls);
}
[Fact]
public async Task RestoreAsync_UnknownWithConfirmation_ExecutesBackupLoad()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("winbox-backup.backup", null, null, null, null);
await BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "ABC123", confirmedByUser: true);
Assert.Single(client.ExecuteCalls);
}
[Fact]
public async Task ListBackupsAsync_EncryptedSuffix_IsParsedAndFlagged()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/file"] =
[
new Dictionary<string, string> { ["name"] = "mikrotik-installer-hAP-ac2_arm_ABC123-20261016-120000-enc.backup" },
];
var backup = Assert.Single(await BackupRestoreService.ListBackupsAsync(client));
Assert.True(backup.IsEncrypted);
Assert.Equal("ABC123", backup.SerialNumber);
}
[Fact]
public async Task RestoreAsync_WithPassword_PassesItToBackupLoad()
{
var client = new RecordingRouterOsClient();
var backup = new BackupFileInfo("mikrotik-installer-hAP-ac2_arm_ABC123-20261016-120000-enc.backup", "hAP-ac2", "arm", "ABC123", null, IsEncrypted: true);
await BackupRestoreService.RestoreAsync(client, backup, "hAP ac2", "arm", "ABC123", confirmedByUser: false, password: "geheim");
Assert.Equal("geheim", Assert.Single(client.ExecuteCalls).Parameters!["password"]);
}
}
@@ -1,36 +0,0 @@
using MikrotikInstaller.Core.Backup;
namespace MikrotikInstaller.Core.Tests.Backup;
public class KnownHostsStoreTests : IDisposable
{
private readonly string _filePath = Path.Combine(Path.GetTempPath(), $"known-hosts-test-{Guid.NewGuid()}.json");
public void Dispose()
{
if (File.Exists(_filePath))
{
File.Delete(_filePath);
}
}
[Fact]
public void RememberThenGet_PersistsPerHost()
{
new KnownHostsStore(_filePath).Remember("192.168.88.1", "SHA256:abc");
var store = new KnownHostsStore(_filePath);
Assert.Equal("SHA256:abc", store.Get("192.168.88.1"));
Assert.Null(store.Get("192.168.88.2"));
}
[Fact]
public void Remember_ReplacesChangedKey()
{
var store = new KnownHostsStore(_filePath);
store.Remember("r", "SHA256:alt");
store.Remember("r", "SHA256:neu");
Assert.Equal("SHA256:neu", store.Get("r"));
}
}
@@ -15,88 +15,4 @@ public class RouterBackupServiceTests
Assert.False(string.IsNullOrWhiteSpace(backupName));
Assert.Contains(client.ExecuteCalls, c => c.Path == "/system/backup/save" && c.Parameters!["name"] == backupName);
}
[Fact]
public async Task CreateBackupAsync_WithBoardModelOnly_EmbedsSanitizedModelInName()
{
var client = new RecordingRouterOsClient();
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2");
Assert.StartsWith("mikrotik-installer-hAP-ac2-", backupName);
}
[Fact]
public async Task CreateBackupAsync_WithBoardModelAndArchitecture_EmbedsBothSeparatedByUnderscore()
{
var client = new RecordingRouterOsClient();
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2", architecture: "arm");
Assert.StartsWith("mikrotik-installer-hAP-ac2_arm-", backupName);
}
[Fact]
public async Task CreateBackupAsync_WithModelArchitectureAndSerialNumber_EmbedsAllThree()
{
var client = new RecordingRouterOsClient();
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2", architecture: "arm", serialNumber: "ABC123");
Assert.StartsWith("mikrotik-installer-hAP-ac2_arm_ABC123-", backupName);
}
[Fact]
public async Task CreateBackupAsync_WithSerialNumberButNoArchitecture_OmitsSerialNumber()
{
var client = new RecordingRouterOsClient();
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2", architecture: null, serialNumber: "ABC123");
Assert.StartsWith("mikrotik-installer-hAP-ac2-", backupName);
Assert.DoesNotContain("ABC123", backupName);
}
[Fact]
public async Task CreateBackupAsync_WithArchitectureButNoModel_OmitsEverything()
{
var client = new RecordingRouterOsClient();
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: null, architecture: "arm", serialNumber: "ABC123");
Assert.StartsWith("mikrotik-installer-", backupName);
Assert.DoesNotContain("arm", backupName);
Assert.DoesNotContain("ABC123", backupName);
}
[Fact]
public async Task CreateBackupAsync_WithPassword_EncryptsAndMarksName()
{
var client = new RecordingRouterOsClient();
var backupName = await RouterBackupService.CreateBackupAsync(client, "hAP ac2", "arm", "ABC123", password: "geheim");
Assert.EndsWith("-enc", backupName);
var call = Assert.Single(client.ExecuteCalls);
Assert.Equal("geheim", call.Parameters!["password"]);
Assert.Equal("aes-sha256", call.Parameters!["encryption"]);
}
[Fact]
public async Task CreateBackupAsync_WithoutPassword_SendsNoPassword()
{
var client = new RecordingRouterOsClient();
await RouterBackupService.CreateBackupAsync(client, "hAP ac2");
Assert.False(Assert.Single(client.ExecuteCalls).Parameters!.ContainsKey("password"));
}
[Theory]
[InlineData("hAP ac2", "hAP-ac2")]
[InlineData("CHR (Simulator)", "CHR--Simulator-")]
[InlineData("RB750Gr3", "RB750Gr3")]
[InlineData("x86_64", "x86-64")]
public void SanitizeForFileName_ReplacesNonAlphanumericCharacters(string input, string expected) =>
Assert.Equal(expected, RouterBackupService.SanitizeForFileName(input));
}
@@ -1,79 +0,0 @@
using MikrotikInstaller.Core.Configuration;
using MikrotikInstaller.Core.Connectivity;
namespace MikrotikInstaller.Core.Tests.Configuration;
public class DuplicateRuleGuardTests
{
[Fact]
public async Task ExistsAsync_MatchingSubsetOfFields_ReturnsTrue()
{
var guard = new DuplicateRuleGuard();
var client = new RecordingRouterOsClient();
client.GetResponses["/ip/firewall/filter"] =
[
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "input", ["action"] = "accept", ["bytes"] = "12345" },
];
var exists = await guard.ExistsAsync(client, "/ip/firewall/filter",
new Dictionary<string, string> { ["chain"] = "input", ["action"] = "accept" }, CancellationToken.None);
Assert.True(exists);
}
[Fact]
public async Task ExistsAsync_NoMatchingRow_ReturnsFalse()
{
var guard = new DuplicateRuleGuard();
var client = new RecordingRouterOsClient();
client.GetResponses["/ip/firewall/filter"] =
[
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "input", ["action"] = "drop" },
];
var exists = await guard.ExistsAsync(client, "/ip/firewall/filter",
new Dictionary<string, string> { ["chain"] = "input", ["action"] = "accept" }, CancellationToken.None);
Assert.False(exists);
}
[Fact]
public async Task ExistsAsync_SecondCallForSamePath_DoesNotFetchAgain()
{
var guard = new DuplicateRuleGuard();
var client = new CountingRouterOsClient();
await guard.ExistsAsync(client, "/ip/firewall/filter", new Dictionary<string, string> { ["chain"] = "input" }, CancellationToken.None);
await guard.ExistsAsync(client, "/ip/firewall/filter", new Dictionary<string, string> { ["chain"] = "forward" }, CancellationToken.None);
Assert.Equal(1, client.GetCallCount);
}
/// <summary>Zählt GetAsync-Aufrufe, um die Einmalig-pro-Pfad-Momentaufnahme von DuplicateRuleGuard zu belegen.</summary>
private sealed class CountingRouterOsClient : IRouterOsClient
{
public int GetCallCount { get; private set; }
public RouterOsProtocol Protocol => RouterOsProtocol.Rest;
public Task<IReadOnlyList<IReadOnlyDictionary<string, string>>> GetAsync(string path, CancellationToken cancellationToken = default)
{
GetCallCount++;
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, string>>>([]);
}
public Task<string> AddAsync(string path, IReadOnlyDictionary<string, string> parameters, CancellationToken cancellationToken = default) =>
Task.FromResult("*1");
public Task SetAsync(string path, string id, IReadOnlyDictionary<string, string> parameters, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task RemoveAsync(string path, string id, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task ExecuteAsync(string path, IReadOnlyDictionary<string, string>? parameters = null, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -85,62 +85,6 @@ public class FirewallConfiguratorTests
&& c.Parameters["chain"] == "forward" && c.Parameters.GetValueOrDefault("src-address-list") == "blocked" && c.Parameters["action"] == "drop");
}
[Fact]
public async Task BuildActions_AllBaseRulesAlreadyExist_AddsNoneOfThem()
{
var settings = new FirewallSettings("ether1", "bridge-lan", []);
var actions = FirewallConfigurator.BuildActions(settings);
var client = new RecordingRouterOsClient();
client.GetResponses["/ip/firewall/filter"] =
[
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "input", ["connection-state"] = "established,related,untracked", ["action"] = "accept" },
new Dictionary<string, string> { [".id"] = "*2", ["chain"] = "input", ["connection-state"] = "invalid", ["action"] = "drop" },
new Dictionary<string, string> { [".id"] = "*3", ["chain"] = "input", ["protocol"] = "icmp", ["action"] = "accept" },
new Dictionary<string, string> { [".id"] = "*4", ["chain"] = "input", ["in-interface"] = "bridge-lan", ["action"] = "accept" },
new Dictionary<string, string> { [".id"] = "*5", ["chain"] = "forward", ["connection-state"] = "established,related,untracked", ["action"] = "accept" },
new Dictionary<string, string> { [".id"] = "*6", ["chain"] = "forward", ["connection-state"] = "invalid", ["action"] = "drop" },
new Dictionary<string, string> { [".id"] = "*7", ["chain"] = "forward", ["in-interface"] = "ether1", ["connection-state"] = "new", ["action"] = "drop" },
new Dictionary<string, string> { [".id"] = "*8", ["chain"] = "input", ["in-interface"] = "ether1", ["action"] = "drop" },
];
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Empty(client.AddCalls.Where(c => c.Path == "/ip/firewall/filter"));
}
[Fact]
public async Task BuildActions_PortForwardRulesAlreadyExist_SkipsButAddsGenuinelyNewOnes()
{
var settings = new FirewallSettings("ether1", "bridge-lan",
[new PortForward("Spieleserver", "udp", 25565, "192.168.88.50", 25565)]);
var actions = FirewallConfigurator.BuildActions(settings);
var client = new RecordingRouterOsClient();
var natParameters = new Dictionary<string, string>
{
[".id"] = "*9",
["chain"] = "dstnat",
["protocol"] = "udp",
["dst-port"] = "25565",
["in-interface"] = "ether1",
["action"] = "dst-nat",
["to-addresses"] = "192.168.88.50",
["to-ports"] = "25565",
};
client.GetResponses["/ip/firewall/nat"] = [natParameters];
// Die zugehörige Forward-Filter-Regel existiert bewusst NICHT — muss trotzdem angelegt werden.
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Empty(client.AddCalls.Where(c => c.Path == "/ip/firewall/nat"));
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter" && c.Parameters.GetValueOrDefault("dst-address") == "192.168.88.50");
}
[Fact]
public void BuildFriendlySummary_WithoutPortForwards_OnlyMentionsProtection()
{
@@ -60,38 +60,11 @@ public class LanConfiguratorTests
}
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server" && c.Parameters["lease-time"] == "1d");
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server/network" && c.Parameters["domain"] == "heim.lan");
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server/network" && c.Parameters["domain-name"] == "heim.lan");
Assert.Contains(client.ExecuteCalls, c => c.Path == "/system/ntp/client/set"
&& c.Parameters!["enabled"] == "yes" && c.Parameters["servers"] == "pool.ntp.org");
}
[Fact]
public async Task BuildActions_ExistingConfiguration_UpdatesInsteadOfDuplicating()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/interface/bridge"] = [new Dictionary<string, string> { [".id"] = "*B", ["name"] = "bridge-lan" }];
client.GetResponses["/interface/bridge/port"] =
[
new Dictionary<string, string> { [".id"] = "*P2", ["bridge"] = "bridge-lan", ["interface"] = "ether2" },
new Dictionary<string, string> { [".id"] = "*P3", ["bridge"] = "bridge-alt", ["interface"] = "ether3" },
];
client.GetResponses["/ip/dhcp-server"] = [new Dictionary<string, string> { [".id"] = "*D", ["name"] = "defconf", ["interface"] = "bridge-lan", ["address-pool"] = "default-dhcp", ["disabled"] = "false" }];
foreach (var action in LanConfigurator.BuildActions(DefaultSettings()))
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.DoesNotContain(client.AddCalls, c => c.Path is "/interface/bridge" or "/interface/bridge/port" or "/ip/dhcp-server");
// ether3 hing in einer anderen Bridge und wird umgehängt.
Assert.Contains(client.SetCalls, c => c.Path == "/interface/bridge/port" && c.Id == "*P3" && c.Parameters["bridge"] == "bridge-lan");
Assert.DoesNotContain(client.SetCalls, c => c.Id == "*P2");
// Der vorhandene DHCP-Server behält seinen Namen, bekommt aber den neuen Pool.
var dhcp = Assert.Single(client.SetCalls, c => c.Path == "/ip/dhcp-server");
Assert.Equal("pool-bridge-lan", dhcp.Parameters["address-pool"]);
Assert.False(dhcp.Parameters.ContainsKey("name"));
}
[Fact]
public void BuildFriendlySummary_MentionsNetworkAndMemberInterfaces()
{
@@ -1,60 +0,0 @@
using MikrotikInstaller.Core.Configuration;
namespace MikrotikInstaller.Core.Tests.Configuration;
public class LeaseTimeAndExistingLanTests
{
[Theory]
[InlineData(0, 0, 0, null)]
[InlineData(0, 1, 0, "1d")]
[InlineData(0, 0, 12, "12h")]
[InlineData(1, 2, 3, "367d3h")]
public void ToRouterOs_FormatsDuration(int years, int days, int hours, string? expected) =>
Assert.Equal(expected, new LeaseTime(years, days, hours).ToRouterOs());
[Theory]
[InlineData("1d00:00:00", 0, 1, 0)]
[InlineData("3w2d", 0, 23, 0)]
[InlineData("10m", 0, 0, 1)]
[InlineData("12:00:00", 0, 0, 12)]
[InlineData("367d3h", 1, 2, 3)]
[InlineData("", 0, 0, 0)]
public void Parse_ReadsRouterOsDurationsWithoutTruncation(string text, int years, int days, int hours) =>
Assert.Equal(new LeaseTime(years, days, hours), LeaseTime.Parse(text));
[Fact]
public async Task ExistingLanReader_ReadsBridgeAddressPoolDnsAndMembers()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/interface/bridge"] = [new Dictionary<string, string> { ["name"] = "bridge" }];
client.GetResponses["/ip/address"] =
[
new Dictionary<string, string> { ["address"] = "203.0.113.5/24", ["interface"] = "ether1" },
new Dictionary<string, string> { ["address"] = "10.0.0.1/24", ["interface"] = "bridge" },
];
client.GetResponses["/interface/bridge/port"] =
[
new Dictionary<string, string> { ["bridge"] = "bridge", ["interface"] = "ether2" },
new Dictionary<string, string> { ["bridge"] = "bridge", ["interface"] = "wlan1" },
];
client.GetResponses["/ip/dhcp-server"] = [new Dictionary<string, string> { ["interface"] = "bridge", ["address-pool"] = "dhcp", ["lease-time"] = "3w" }];
client.GetResponses["/ip/pool"] = [new Dictionary<string, string> { ["name"] = "dhcp", ["ranges"] = "10.0.0.10-10.0.0.200" }];
client.GetResponses["/ip/dhcp-server/network"] = [new Dictionary<string, string> { ["address"] = "10.0.0.0/24", ["domain"] = "heim.lan" }];
client.GetResponses["/ip/dns"] = [new Dictionary<string, string> { ["servers"] = "9.9.9.9,1.1.1.1" }];
var lan = await ExistingLanReader.ReadAsync(client);
Assert.NotNull(lan);
Assert.Equal("bridge", lan.BridgeName);
Assert.Equal("10.0.0.1/24", lan.RouterAddressCidr);
Assert.Equal(["ether2", "wlan1"], lan.MemberInterfaces);
Assert.Equal(("10.0.0.10", "10.0.0.200"), (lan.DhcpPoolStart, lan.DhcpPoolEnd));
Assert.Equal(("9.9.9.9", "1.1.1.1"), (lan.PrimaryDnsServer, lan.SecondaryDnsServer));
Assert.Equal("3w", lan.DhcpLeaseTime);
Assert.Equal("heim.lan", lan.DomainName);
}
[Fact]
public async Task ExistingLanReader_NoBridgeWithAddress_ReturnsNull() =>
Assert.Null(await ExistingLanReader.ReadAsync(new RecordingRouterOsClient()));
}
@@ -1,46 +0,0 @@
using MikrotikInstaller.Core.Configuration;
namespace MikrotikInstaller.Core.Tests.Configuration;
public class NetworkValidationTests
{
[Theory]
[InlineData("192.168.88.1", true)]
[InlineData("0.0.0.0", true)]
[InlineData("255.255.255.255", true)]
[InlineData("192.168.88.1/24", false)]
[InlineData("300.1.1.1", false)]
[InlineData("", false)]
[InlineData("not-an-ip", false)]
[InlineData("::1", false)]
public void IsValidIPv4_MatchesExpectation(string value, bool expected) =>
Assert.Equal(expected, NetworkValidation.IsValidIPv4(value));
[Theory]
[InlineData("192.168.88.1/24", true)]
[InlineData("10.0.0.0/8", true)]
[InlineData("192.168.88.1/0", true)]
[InlineData("192.168.88.1/32", true)]
[InlineData("192.168.88.1/33", false)]
[InlineData("192.168.88.1/-1", false)]
[InlineData("192.168.88.1", false)]
[InlineData("192.168.88.1/", false)]
[InlineData("abc/24", false)]
public void IsValidCidr_MatchesExpectation(string value, bool expected) =>
Assert.Equal(expected, NetworkValidation.IsValidCidr(value));
[Theory]
[InlineData("192.168.88.1", true)]
[InlineData("192.168.88.0/24", true)]
[InlineData("not-valid", false)]
public void IsValidIPv4OrCidr_MatchesExpectation(string value, bool expected) =>
Assert.Equal(expected, NetworkValidation.IsValidIPv4OrCidr(value));
[Theory]
[InlineData("192.168.88.10", "192.168.88.254", true)]
[InlineData("192.168.88.10", "192.168.88.10", true)]
[InlineData("192.168.88.254", "192.168.88.10", false)]
[InlineData("192.168.88.10", "not-an-ip", false)]
public void IsOrderedRange_MatchesExpectation(string from, string to, bool expected) =>
Assert.Equal(expected, NetworkValidation.IsOrderedRange(from, to));
}
@@ -1,39 +0,0 @@
using MikrotikInstaller.Core.Configuration;
namespace MikrotikInstaller.Core.Tests.Configuration;
public class PortConflictsTests
{
[Fact]
public async Task FindAsync_ReportsAddressDhcpClientAndServerOnSelectedPortsOnly()
{
var client = new RecordingRouterOsClient();
client.GetResponses["/ip/address"] =
[
new Dictionary<string, string> { [".id"] = "*A1", ["address"] = "10.1.1.1/24", ["interface"] = "ether3" },
new Dictionary<string, string> { [".id"] = "*A2", ["address"] = "192.168.88.1/24", ["interface"] = "bridge" },
new Dictionary<string, string> { [".id"] = "*A3", ["address"] = "10.9.9.9/24", ["interface"] = "ether4", ["dynamic"] = "true" },
];
client.GetResponses["/ip/dhcp-client"] = [new Dictionary<string, string> { [".id"] = "*C1", ["interface"] = "ether4" }];
client.GetResponses["/ip/dhcp-server"] = [new Dictionary<string, string> { [".id"] = "*S1", ["interface"] = "ether3", ["name"] = "dhcp3" }];
var conflicts = await PortConflicts.FindAsync(client, ["ether3", "ether4"]);
Assert.Equal(["*A1", "*C1", "*S1"], conflicts.Select(c => c.Id));
Assert.Equal("10.1.1.1", conflicts[0].Address);
}
[Fact]
public async Task BuildReleaseActions_RemovesEachConflict()
{
var client = new RecordingRouterOsClient();
var conflicts = new[] { new PortConflict("ether3", "/ip/address", "*A1", "IP-Adresse 10.1.1.1/24", "10.1.1.1") };
foreach (var action in PortConflicts.BuildReleaseActions(conflicts))
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Equal(("/ip/address", "*A1"), Assert.Single(client.RemoveCalls));
}
}
@@ -32,13 +32,8 @@ internal sealed class RecordingRouterOsClient : IRouterOsClient
return Task.CompletedTask;
}
public List<(string Path, string Id)> RemoveCalls { get; } = [];
public Task RemoveAsync(string path, string id, CancellationToken cancellationToken = default)
{
RemoveCalls.Add((path, id));
return Task.CompletedTask;
}
public Task RemoveAsync(string path, string id, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task ExecuteAsync(string path, IReadOnlyDictionary<string, string>? parameters = null, CancellationToken cancellationToken = default)
{
@@ -51,80 +51,6 @@ public class VlanConfiguratorTests
Assert.Contains(isolationDrops, c => c.Parameters["in-interface"] == "bridge-lan" && c.Parameters["out-interface"] == "vlan20");
}
[Fact]
public async Task BuildActions_IsolationRulesAlreadyExist_DoesNotDuplicateThem()
{
var vlans = new List<VlanDefinition>
{
new(VlanId: 20, Name: "Gäste", RouterAddressCidr: "192.168.20.1/24", DhcpPoolStart: "192.168.20.10", DhcpPoolEnd: "192.168.20.254",
IsolateFromLan: true),
};
var actions = VlanConfigurator.BuildActions("bridge-lan", vlans);
var client = new RecordingRouterOsClient();
client.GetResponses["/ip/firewall/filter"] =
[
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "forward", ["in-interface"] = "vlan20", ["out-interface"] = "bridge-lan", ["action"] = "drop" },
new Dictionary<string, string> { [".id"] = "*2", ["chain"] = "forward", ["in-interface"] = "bridge-lan", ["out-interface"] = "vlan20", ["action"] = "drop" },
];
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
// Die bereits vorhandenen (älteren, kommentarlosen) Forward-Regeln werden nicht doppelt angelegt.
Assert.Empty(client.AddCalls.Where(c => c.Path == "/ip/firewall/filter" && c.Parameters["chain"] == "forward"));
}
[Fact]
public async Task BuildActions_Isolation_BlocksRouterManagementButAllowsDnsDhcp()
{
var vlans = new List<VlanDefinition>
{
new(VlanId: 20, Name: "Gäste", RouterAddressCidr: "192.168.20.1/24", DhcpPoolStart: "192.168.20.10", DhcpPoolEnd: "192.168.20.254",
IsolateFromLan: true),
};
var client = new RecordingRouterOsClient();
foreach (var action in VlanConfigurator.BuildActions("bridge-lan", vlans))
{
await action.ApplyAsync(client, CancellationToken.None);
}
var input = client.AddCalls.Where(c => c.Path == "/ip/firewall/filter" && c.Parameters["chain"] == "input").ToList();
Assert.Contains(input, c => c.Parameters["in-interface"] == "vlan20" && c.Parameters["action"] == "accept"
&& c.Parameters["protocol"] == "udp" && c.Parameters["dst-port"] == "53,67");
// Die Sperre muss hinter den Freigaben stehen, sonst wäre auch DNS gesperrt.
var dropIndex = input.FindIndex(c => c.Parameters["in-interface"] == "vlan20" && c.Parameters["action"] == "drop" && !c.Parameters.ContainsKey("protocol"));
Assert.True(dropIndex > input.FindIndex(c => c.Parameters["action"] == "accept"));
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter" && c.Parameters["comment"] == "Isolation vlan20 -> bridge-lan");
}
[Fact]
public async Task BuildActions_Isolation_FlushesOnlyConnectionsOfIsolatedNetwork()
{
var vlans = new List<VlanDefinition>
{
new(VlanId: 20, Name: "Gäste", RouterAddressCidr: "192.168.20.1/24", DhcpPoolStart: "192.168.20.10", DhcpPoolEnd: "192.168.20.254",
IsolateFromLan: true),
};
var client = new RecordingRouterOsClient();
client.GetResponses["/ip/firewall/connection"] =
[
new Dictionary<string, string> { [".id"] = "*A", ["src-address"] = "192.168.20.50:5000", ["dst-address"] = "192.168.88.10:445" },
new Dictionary<string, string> { [".id"] = "*B", ["src-address"] = "192.168.88.10:6000", ["dst-address"] = "192.168.20.50:22" },
new Dictionary<string, string> { [".id"] = "*C", ["src-address"] = "192.168.88.10:7000", ["dst-address"] = "1.1.1.1:443" },
];
foreach (var action in VlanConfigurator.BuildActions("bridge-lan", vlans))
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Equal(["*A", "*B"], client.RemoveCalls.Where(c => c.Path == "/ip/firewall/connection").Select(c => c.Id));
}
[Fact]
public void BuildActions_NoVlans_ReturnsEmptyList()
{
@@ -1,66 +0,0 @@
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Connectivity.Binary;
namespace MikrotikInstaller.Core.Tests.Connectivity;
public class BinaryRouterOsClientTests
{
[Fact]
public async Task Trap_ConsumesTrailingDone_SoNextCommandReadsItsOwnReply()
{
var replies = await EncodeAsync(
["!trap", "=message=failure: already have such entry"],
["!done"],
["!done", "=ret=*5"]);
await using var client = new BinaryRouterOsClient(new ReplayStream(replies));
var error = await Assert.ThrowsAsync<RouterOsException>(() =>
client.AddAsync("/ip/pool", new Dictionary<string, string> { ["name"] = "x" }));
Assert.Contains("already have such entry", error.Message);
var id = await client.AddAsync("/ip/pool", new Dictionary<string, string> { ["name"] = "y" });
Assert.Equal("*5", id);
}
[Fact]
public async Task RunAsync_ReturnsRowsOfCommand()
{
var replies = await EncodeAsync(
["!re", "=host=1.1.1.1", "=time=12ms"],
["!re", "=host=1.1.1.1", "=time=11ms"],
["!done"]);
await using var client = new BinaryRouterOsClient(new ReplayStream(replies));
var rows = await client.RunAsync("/ping", new Dictionary<string, string> { ["address"] = "1.1.1.1", ["count"] = "2" });
Assert.Equal(["12ms", "11ms"], rows.Select(r => r["time"]));
}
private static async Task<byte[]> EncodeAsync(params string[][] sentences)
{
await using var buffer = new MemoryStream();
foreach (var sentence in sentences)
{
await RouterOsWireFormat.WriteSentenceAsync(buffer, sentence, CancellationToken.None);
}
return buffer.ToArray();
}
/// <summary>Liest vorbereitete Antworten, verwirft alles Geschriebene.</summary>
private sealed class ReplayStream(byte[] replies) : Stream
{
private readonly MemoryStream _replies = new(replies);
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => _replies.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) { }
}
}
@@ -1,38 +0,0 @@
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using MikrotikInstaller.Core.Connectivity;
namespace MikrotikInstaller.Core.Tests.Connectivity;
public class CertificateTrustTests
{
private static readonly X509Certificate2 SelfSigned =
new CertificateRequest("CN=router", RSA.Create(2048), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddDays(1));
[Fact]
public void ValidCertificate_NeedsNoConfirmation()
{
var asked = false;
var credentials = new RouterOsCredentials("r", "u", "p") { TrustCertificate = _ => asked = true };
Assert.True(credentials.AcceptCertificate(SelfSigned, SslPolicyErrors.None));
Assert.False(asked);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void UntrustedCertificate_DecidedByTrustCallback(bool decision)
{
var credentials = new RouterOsCredentials("r", "u", "p") { TrustCertificate = _ => decision, AllowUntrustedCertificate = true };
Assert.Equal(decision, credentials.AcceptCertificate(SelfSigned, SslPolicyErrors.RemoteCertificateChainErrors));
}
[Fact]
public void WithoutTrustCallback_FallsBackToAllowUntrusted() =>
Assert.False(new RouterOsCredentials("r", "u", "p") { AllowUntrustedCertificate = false }
.AcceptCertificate(SelfSigned, SslPolicyErrors.RemoteCertificateChainErrors));
}
@@ -13,66 +13,6 @@ public class DemoRouterOsClientTests
Assert.False(DemoRouterOsClient.Matches(new RouterOsCredentials("127.127.127.127", "admin", "falsch")));
}
[Fact]
public void Matches_AllVirtualDeviceProfiles()
{
foreach (var profile in DemoRouterOsClient.Profiles)
{
Assert.True(DemoRouterOsClient.Matches(new RouterOsCredentials(profile.Host, "admin", "admin")));
}
}
[Fact]
public void Profiles_HaveDistinctHostsAndIdentities()
{
Assert.True(DemoRouterOsClient.Profiles.Count >= 3, "Es sollten mehrere virtuelle Testgeräte zur Auswahl stehen.");
Assert.Equal(DemoRouterOsClient.Profiles.Count, DemoRouterOsClient.Profiles.Select(p => p.Host).Distinct().Count());
Assert.Equal(DemoRouterOsClient.Profiles.Count, DemoRouterOsClient.Profiles.Select(p => p.IdentityName).Distinct().Count());
}
[Theory]
[MemberData(nameof(ProfileIndexes))]
public async Task Create_ReportsTheSelectedProfilesIdentity(int profileIndex)
{
var profile = DemoRouterOsClient.Profiles[profileIndex];
var client = DemoRouterOsClient.Create(new RouterOsCredentials(profile.Host, "admin", "admin"));
var resource = (await client.GetAsync("/system/resource")).Single();
var identity = (await client.GetAsync("/system/identity")).Single();
var routerboard = (await client.GetAsync("/system/routerboard")).Single();
Assert.Equal(profile.BoardModel, resource["board-name"]);
Assert.Equal(profile.Architecture, resource["architecture-name"]);
Assert.Equal(profile.IdentityName, identity["name"]);
Assert.Equal(profile.IsRouterBoard ? "yes" : "no", routerboard["routerboard"]);
Assert.Equal(profile.SerialNumber, routerboard.GetValueOrDefault("serial-number"));
}
public static TheoryData<int> ProfileIndexes()
{
var data = new TheoryData<int>();
for (var i = 0; i < DemoRouterOsClient.Profiles.Count; i++)
{
data.Add(i);
}
return data;
}
[Fact]
public async Task Create_EachProfileHasABackupThatMatchesItself()
{
foreach (var profile in DemoRouterOsClient.Profiles)
{
var client = DemoRouterOsClient.Create(new RouterOsCredentials(profile.Host, "admin", "admin"));
var files = await client.GetAsync("/file");
var sanitizedModel = MikrotikInstaller.Core.Backup.RouterBackupService.SanitizeForFileName(profile.BoardModel);
var sanitizedArch = MikrotikInstaller.Core.Backup.RouterBackupService.SanitizeForFileName(profile.Architecture);
Assert.Contains(files, f => f["name"].StartsWith($"mikrotik-installer-{sanitizedModel}_{sanitizedArch}"));
}
}
[Fact]
public async Task GetAsync_ReturnsSimulatedInterfacesAndResource()
{
@@ -97,15 +37,4 @@ public class DemoRouterOsClientTests
Assert.False(string.IsNullOrEmpty(id));
Assert.Contains(rows, r => r["name"] == "bridge-lan" && r[".id"] == id);
}
[Fact]
public async Task ExecuteAsync_BackupSave_AddsFileWithBackupExtension()
{
var client = new DemoRouterOsClient();
await client.ExecuteAsync("/system/backup/save", new Dictionary<string, string> { ["name"] = "mein-test-backup" });
var files = await client.GetAsync("/file");
Assert.Contains(files, f => f["name"] == "mein-test-backup.backup");
}
}
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more