Files
Mikrotik-Installer/src/MikrotikInstaller.App/ViewModels/Steps/ExpertTools.cs
T
adminandClaude Opus 5.5 cd0504d9ca
Build / build (push) Canceled after 0s
Experte-Bereich: kuratierte Menues mit Feldhilfen, Werkzeuge und Pakete (#21, #62-#67)
- Dritter Bereich 'Experte' in 'Was moechtest du tun?', nur mit 'Professioneller Netzwerker'
- 50 kuratierte RouterOS-Menues mit 219 Feldhilfen, per Skript aus dem Katalog der
  Mac-Version uebersetzt (Expert/menu-catalog.json, eingebettet), plus eigener Menuepfad
- Auflisten/Bearbeiten/Anlegen/Loeschen mit Befehlsvorschau vor jeder Aenderung,
  Sicherung vor der ersten Aenderung; dynamische/Standard-Eintraege und feste Listen ohne
  Loeschen, deaktivierte ausgegraut, Zebra-Liste; Einstellungs-Menues per 'set' ohne ID
- Werkzeuge: Netzwerke isolieren (sperrt sich nicht selbst aus), Neues LAN anlegen
  (nimmt den Port aus der Bridge, Port-Konflikte, Lease-Zeit, Isolation), Port in Bridge
  einfuegen (auch WLAN; raeumt Adresse/DHCP/Pool/Netzwerk/Isolationsregeln), jeweils mit
  Plan vorab, Fortschritt und Abschluss-Uebersicht
- Pakete: Liste vom MikroTik-Server, Vormerken mit Ruecknahme bei Fehler, apply-changes,
  Speicher- und WLAN-Treiber-Warnung, automatisches Wiederverbinden
- Hotspot ohne Paket: verstaendlicher Hinweis statt roher Fehlermeldung
- Demo: Pakete, IDs fuer alle Listeneintraege (wie ein echter Router)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:16:52 +02:00

460 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } => "zur Installation vorgemerkt (nach Neustart)",
{ IsScheduledForUninstall: true } => "zum Entfernen vorgemerkt (nach Neustart)",
{ IsInstalled: true } => $"installiert, {Package.Version}",
_ => $"verfügbar, {Package.SizeBytes / 1024} KiB",
};
}
/// <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("Netzwerke isolieren", "Werkzeuge", null, null, "isolate"),
new("Neues LAN anlegen", "Werkzeuge", null, null, "newlan"),
new("Port in Bridge einfügen", "Werkzeuge", null, null, "rejoin"),
new("Pakete", "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 = $"„{iface}“ hat keine IP-Adresse — erst ein eigenes Netz anlegen (z. B. mit „Neues LAN anlegen“).";
return [];
}
var network = IpNetworkHelper.GetNetworkCidr(address);
if (_session.Credentials?.Host is { } host && IpNetworkHelper.Contains(network, host))
{
ToolWarning = $"Du bist über {host} mit dem Router verbunden, also aus dem Netz, das isoliert werden soll. Die Isolation würde dich aussperren — bitte über ein anderes Netz verbinden.";
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 = "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($"„{iface}“ hängt in der Bridge „{bridge}“ und wird herausgenommen. Ist dein PC an diesem Anschluss angeschlossen, bricht die Verbindung ab.");
}
if (conflicts.FirstOrDefault(c => c.Address is not null && c.Address == _session.Credentials?.Host) is { } own)
{
warnings.Add($"Achtung: Über {own.Address} auf „{iface}“ bist du gerade verbunden — die Verbindung bricht ab.");
}
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 = "Sicherung wird erstellt …";
CompletedSteps.Add($"Sicherung „{await _session.CreateSafetyBackupAsync(client)}“");
ProgressValue = 1;
for (var i = 0; i < plan.Count; i++)
{
ProgressText = $"Schritt {i + 2} von {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 = $"{ex.Message} (abgebrochen bei: {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 = "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("Haken setzen zum Installieren, Haken entfernen zum Entfernen.");
}
else
{
lines.Add($"Vorgemerkt: {install.Count} installieren, {remove.Count} entfernen. Der Router lädt die Pakete von MikroTik herunter und startet neu.");
}
if (freeBytes is { } free && needed > free)
{
lines.Add($"Zu wenig Speicher: benötigt {needed / 1024} KiB, frei {free / 1024} KiB.");
}
if (remove.Any(p => p.Package.IsWirelessDriver))
{
lines.Add("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(
$"Diese Befehle werden ausgeführt, danach startet der Router neu:\n\n{string.Join("\n", commands)}",
"Pakete ändern", System.Windows.MessageBoxButton.OKCancel, System.Windows.MessageBoxImage.Question) != System.Windows.MessageBoxResult.OK)
{
return;
}
IsBusy = true;
_session.IsDeviceBusy = true;
ToolError = null;
try
{
PackageInfo = "Sicherung wird erstellt …";
await _session.CreateSafetyBackupAsync(client);
PackageInfo = "Änderungen werden vorgemerkt und angewendet …";
await PackageService.ApplyAsync(client, install, remove);
PackageInfo = "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 ? "Fertig — alle Änderungen sind übernommen." : $"Nicht installiert: {string.Join(", ", missing)}.";
}
}
catch (Exception ex) when (ex is RouterOsException or InvalidOperationException)
{
ToolError = ex.Message;
}
finally
{
IsBusy = false;
_session.IsDeviceBusy = false;
}
}
}