Build / build (push) Canceled after 0s
- 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>
460 lines
16 KiB
C#
460 lines
16 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|