Experte-Bereich: kuratierte Menues mit Feldhilfen, Werkzeuge und Pakete (#21, #62-#67)
Build / build (push) Canceled after 0s
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>
This commit is contained in:
1 parent
3666cf4ab5
commit
cd0504d9ca
28 files changed
+4184
-19
No files matched your search
@@ -37,6 +37,9 @@
|
||||
<DataTemplate DataType="{x:Type steps:BackupManagementStepViewModel}">
|
||||
<stepViews:BackupManagementStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:ExpertStepViewModel}">
|
||||
<stepViews:ExpertStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:DeviceOverviewStepViewModel}">
|
||||
<stepViews:DeviceOverviewStepView />
|
||||
</DataTemplate>
|
||||
|
||||
@@ -12,4 +12,7 @@ public enum AppArea
|
||||
|
||||
/// <summary>Direkter Zugriff auf Backups (auflisten, erstellen, wiederherstellen) ohne den restlichen Assistenten.</summary>
|
||||
BackupManagement,
|
||||
|
||||
/// <summary>Generischer Zugriff auf RouterOS-Menüs plus Werkzeuge (Issue #21), nur mit „Professioneller Netzwerker“.</summary>
|
||||
Expert,
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public partial class MainViewModel : ObservableObject
|
||||
new ConnectionStepViewModel(_session),
|
||||
new AreaSelectionStepViewModel(_session),
|
||||
new BackupManagementStepViewModel(_session),
|
||||
new ExpertStepViewModel(_session),
|
||||
new DeviceOverviewStepViewModel(_session),
|
||||
new NetworkAnalysisStepViewModel(_session),
|
||||
new LanScanStepViewModel(_session),
|
||||
|
||||
@@ -19,18 +19,26 @@ public partial class AreaSelectionStepViewModel : WizardStepViewModelBase
|
||||
description: "Wähle, ob du dein Gerät komplett neu einrichten oder Backups und Wartung erledigen möchtest.")
|
||||
{
|
||||
_session = session;
|
||||
_isBackupManagement = session.SelectedArea == AppArea.BackupManagement;
|
||||
_area = session.SelectedArea;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isBackupManagement;
|
||||
private AppArea _area;
|
||||
|
||||
/// <summary>Der Experte-Bereich ist nur mit „Professioneller Netzwerker“ sichtbar (Mac v1.3.0).</summary>
|
||||
public bool ShowExpert => UserSettings.Current.ProfessionalMode;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
IsBackupManagement = _session.SelectedArea == AppArea.BackupManagement;
|
||||
if (_session.SelectedArea == AppArea.Expert && !ShowExpert)
|
||||
{
|
||||
_session.SelectedArea = AppArea.SetupWizard;
|
||||
}
|
||||
|
||||
Area = _session.SelectedArea;
|
||||
OnPropertyChanged(nameof(ShowExpert));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
partial void OnIsBackupManagementChanged(bool value) =>
|
||||
_session.SelectedArea = value ? AppArea.BackupManagement : AppArea.SetupWizard;
|
||||
partial void OnAreaChanged(AppArea value) => _session.SelectedArea = value;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
|
||||
public override bool CanGoNext => Device is not null;
|
||||
|
||||
/// <summary>Bei "Backup verwalten" liest die eigenständige Backup-Sektion das Gerät selbst aus, ohne diesen Schritt.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
private readonly List<DateTimeOffset> _serialClicks = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
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;
|
||||
private IReadOnlyDictionary<string, string>? _editingRow;
|
||||
private bool _safetyBackupDone;
|
||||
|
||||
public ExpertStepViewModel(WizardSession session)
|
||||
: base(
|
||||
title: "Experte",
|
||||
description: "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 override bool ShouldSkip => _session.SelectedArea != AppArea.Expert;
|
||||
|
||||
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; } = [];
|
||||
|
||||
public ObservableCollection<ExpertFieldViewModel> Fields { 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;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditing))]
|
||||
private string? _editorTitle;
|
||||
|
||||
public bool IsEditing => EditorTitle is not null;
|
||||
|
||||
/// <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;
|
||||
|
||||
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;
|
||||
CloseEditor();
|
||||
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, "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)
|
||||
? "Hotspot ist auf diesem Router ein eigenes Zusatzpaket und noch nicht installiert. Unter „Werkzeuge → Pakete“ lässt sich das Paket „hotspot“ installieren."
|
||||
: ex.Message.Contains("nicht unterstützt", StringComparison.Ordinal)
|
||||
? $"Den Menüpfad „{path}“ gibt es auf diesem Router nicht (RouterOS-Version oder fehlendes Paket)."
|
||||
: ex.Message;
|
||||
|
||||
[RelayCommand]
|
||||
private void EditEntry(ExpertEntryViewModel entry)
|
||||
{
|
||||
_editingRow = entry.Row;
|
||||
EditorTitle = entry.IsDynamic ? "Eintrag ansehen (dynamisch, von RouterOS verwaltet)" : "Eintrag bearbeiten";
|
||||
BuildFields(entry.Row);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewEntry()
|
||||
{
|
||||
_editingRow = null;
|
||||
EditorTitle = "Neuer Eintrag";
|
||||
BuildFields(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseEditor()
|
||||
{
|
||||
EditorTitle = null;
|
||||
Fields.Clear();
|
||||
ExtraParameters = string.Empty;
|
||||
PreviewText = null;
|
||||
}
|
||||
|
||||
private void BuildFields(IReadOnlyDictionary<string, string>? row)
|
||||
{
|
||||
Fields.Clear();
|
||||
ExtraParameters = string.Empty;
|
||||
var schema = SelectedMenu?.Schema;
|
||||
|
||||
foreach (var field in schema?.Fields ?? [])
|
||||
{
|
||||
var options = field.Kind switch
|
||||
{
|
||||
"enum" => field.Options ?? [],
|
||||
"interfacePick" => Tools.InterfaceNames,
|
||||
"bool" => [],
|
||||
_ => [],
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
AddField(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;
|
||||
}
|
||||
|
||||
AddField(new ExpertFieldViewModel(key, key, "Weiterer Parameter dieses Eintrags.", value is "true" or "false" ? "bool" : "text", [], value));
|
||||
}
|
||||
|
||||
UpdatePreview();
|
||||
}
|
||||
|
||||
private void AddField(ExpertFieldViewModel field)
|
||||
{
|
||||
field.PropertyChanged += (_, _) => UpdatePreview();
|
||||
Fields.Add(field);
|
||||
}
|
||||
|
||||
partial void OnExtraParametersChanged(string value) => UpdatePreview();
|
||||
|
||||
private Dictionary<string, string> CollectChanges()
|
||||
{
|
||||
var original = _editingRow 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()
|
||||
{
|
||||
if (!IsEditing || SelectedMenu?.Path is not { } path)
|
||||
{
|
||||
PreviewText = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var changes = CollectChanges();
|
||||
PreviewText = changes.Count == 0
|
||||
? null
|
||||
: SelectedMenu.Schema is { Singleton: true }
|
||||
? ExpertMenus.Preview(path, "set", null, changes)
|
||||
: ExpertMenus.Preview(path, _editingRow is null ? "add" : "set", _editingRow?.GetValueOrDefault(".id"), changes);
|
||||
}
|
||||
|
||||
private bool CanSave => PreviewText is not null && !IsLoading;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changes = CollectChanges();
|
||||
if (_editingRow is not null && SelectedMenu.Schema is not { Singleton: true } && !_editingRow.ContainsKey(".id"))
|
||||
{
|
||||
// Ohne ID würde aus „ändern“ ein neuer Eintrag.
|
||||
ErrorMessage = "Dieser Eintrag hat keine ID und kann hier nicht geändert werden.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
await EnsureSafetyBackupAsync(client);
|
||||
StatusMessage = $"Wird ausgeführt: {PreviewText}";
|
||||
await ExpertMenus.SaveAsync(client, SelectedMenu.Schema, path, _editingRow?.GetValueOrDefault(".id"), changes);
|
||||
StatusMessage = $"Gespeichert: {PreviewText}";
|
||||
CloseEditor();
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
StatusMessage = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
if (ErrorMessage is null)
|
||||
{
|
||||
await LoadEntriesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[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($"Diesen Eintrag löschen?\n\n{entry.Summary}\n\nBefehl: {preview}", "Eintrag löschen",
|
||||
System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
await EnsureSafetyBackupAsync(client);
|
||||
await client.RemoveAsync(path, id);
|
||||
StatusMessage = $"Gelöscht: {preview}";
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
await LoadEntriesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Vor der ersten Änderung im Experte-Bereich eine Sicherung (eine Handsicherung der letzten 10 Minuten zählt).</summary>
|
||||
private async Task EnsureSafetyBackupAsync(IRouterOsClient client)
|
||||
{
|
||||
if (_safetyBackupDone)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StatusMessage = "Sicherung vor der ersten Änderung wird erstellt …";
|
||||
await _session.CreateSafetyBackupAsync(client);
|
||||
_safetyBackupDone = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
|
||||
public override bool CanGoNext => PortForwards.All(IsValid) && BlockedAddressesValidationMessage is null;
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ public partial class LanScanStepViewModel : WizardStepViewModelBase
|
||||
public override bool CanGoNext => !IsLoading;
|
||||
|
||||
/// <summary>Bei "Backup verwalten" nicht relevant — die eigenständige Backup-Sektion braucht keinen LAN-Scan.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
public bool ShowEmptyHint => !IsLoading && ErrorMessage is null && !HasDevices;
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ public partial class LanStepViewModel : WizardStepViewModelBase
|
||||
public override bool CanGoNext => ValidationMessage is null;
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
public override async Task OnActivatedAsync()
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
|
||||
private bool _isExpertMode;
|
||||
|
||||
/// <summary>Ohne „Professioneller Netzwerker" gibt es nichts zu wählen — dann immer der geführte Assistent.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement || !UserSettings.Current.ProfessionalMode;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard || !UserSettings.Current.ProfessionalMode;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public override bool CanGoNext => !IsLoading;
|
||||
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
public override async Task OnActivatedAsync()
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public override bool CanGoNext => IsFinished;
|
||||
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _backupName;
|
||||
|
||||
@@ -28,7 +28,7 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
|
||||
public override bool CanGoNext => Vlans.All(IsValid);
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ public partial class WanStepViewModel : WizardStepViewModelBase
|
||||
public override bool CanGoNext => ValidationMessage is null;
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ 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.SelectedArea == AppArea.BackupManagement || _session.Device?.HasWireless != true;
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard || _session.Device?.HasWireless != true;
|
||||
|
||||
public override bool CanGoNext =>
|
||||
SkipWireless
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<RadioButton Grid.Column="0" GroupName="AppArea" VerticalAlignment="Top" Margin="0,2,12,0"
|
||||
IsChecked="{Binding IsBackupManagement, Converter={StaticResource InverseBooleanConverter}}" />
|
||||
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=SetupWizard}" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="Einrichtungs-Assistent" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Gerät komplett neu einrichten: Internet-Zugang, Heimnetzwerk, VLANs, WLAN und Firewall — Schritt für Schritt." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
|
||||
@@ -31,12 +31,28 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<RadioButton Grid.Column="0" GroupName="AppArea" VerticalAlignment="Top" Margin="0,2,12,0"
|
||||
IsChecked="{Binding IsBackupManagement}" />
|
||||
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=BackupManagement}" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="Backup & Wartung" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Backups auflisten, erstellen oder zurückspielen, das Gerät neu starten, die Firmware aktualisieren oder auf Werkseinstellungen zurücksetzen. Überspringt den restlichen Einrichtungs-Assistenten komplett." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="16" Margin="0,12,0,0"
|
||||
Visibility="{Binding ShowExpert, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<RadioButton Grid.Column="0" GroupName="AppArea" VerticalAlignment="Top" Margin="0,2,12,0"
|
||||
IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Expert}" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="Experte" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Direkter Zugriff auf RouterOS-Menüs (Firewall, Interfaces, IP, DHCP, WLAN, System …) mit Erklärungen zu jedem Feld, dazu Werkzeuge wie „Netzwerke isolieren“, „Neues LAN anlegen“, „Port in Bridge einfügen“ und „Pakete“. Jede Änderung zeigt vorher den genauen Befehl." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,374 @@
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.ExpertStepView"
|
||||
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="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="z. B. /ip/arp" />
|
||||
<ui:Button Grid.Column="1" Content="Ö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="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="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="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=" deaktiviert" FontSize="11" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Visibility="{Binding IsDisabled, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<TextBlock Text=" dynamisch" FontSize="11" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Visibility="{Binding IsDynamic, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</StackPanel>
|
||||
<ui:Button Grid.Column="1" Appearance="Transparent" Padding="6,2" ToolTip="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ö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 -->
|
||||
<Border Style="{StaticResource SectionBlock}" Visibility="{Binding IsEditing, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding EditorTitle}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
<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="Weitere Parameter (eine Zeile je name=wert)" />
|
||||
<controls:InfoHint Text="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="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="Keine Änderung — nichts zu speichern." FontSize="12" Margin="0,0,0,10" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Visibility="{Binding PreviewText, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}" />
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ui:Button Content="Speichern" Appearance="Primary" Margin="0,0,8,0" Command="{Binding SaveCommand}" />
|
||||
<ui:Button Content="Schließen" Command="{Binding CloseEditorCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</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="Netzwerke isolieren" FontSize="18" FontWeight="SemiBold" />
|
||||
<TextBlock TextWrapping="Wrap" Margin="0,6,0,12"
|
||||
Text="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="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="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="Neues LAN anlegen" FontSize="18" FontWeight="SemiBold" />
|
||||
<TextBlock TextWrapping="Wrap" Margin="0,6,0,12"
|
||||
Text="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="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="Router-Adresse mit Präfix" />
|
||||
<controls:InfoHint Text="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="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="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="Adresse behalten für" />
|
||||
<controls:InfoHint Text="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="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="Port in Bridge einfügen" FontSize="18" FontWeight="SemiBold" />
|
||||
<TextBlock TextWrapping="Wrap" Margin="0,6,0,12"
|
||||
Text="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="Anschluss (nicht in einer Bridge)" Margin="0,0,0,4" />
|
||||
<ComboBox ItemsSource="{Binding FreePorts}" SelectedItem="{Binding RejoinPort}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Text="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="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="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="Pakete" FontSize="18" FontWeight="SemiBold" />
|
||||
<TextBlock TextWrapping="Wrap" Margin="0,6,0,8"
|
||||
Text="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="Änderungen anwenden" Appearance="Primary" Margin="0,0,8,0" Command="{Binding ApplyPackagesCommand}" />
|
||||
<ui:Button Content="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>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MikrotikInstaller.App.Views.Steps;
|
||||
|
||||
public partial class ExpertStepView : System.Windows.Controls.UserControl
|
||||
{
|
||||
public ExpertStepView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using MikrotikInstaller.Core.Expert;
|
||||
using MikrotikInstaller.Core.Tests.Configuration;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Expert;
|
||||
|
||||
public class ExpertMenusTests
|
||||
{
|
||||
[Fact]
|
||||
public void Catalog_LoadsCuratedMenusWithFieldHelp()
|
||||
{
|
||||
Assert.True(MenuCatalog.All.Count >= 40);
|
||||
var filter = MenuCatalog.Find("ip firewall filter");
|
||||
Assert.NotNull(filter);
|
||||
Assert.Contains(filter.Fields, f => f.Key == "action" && f.Kind == "enum" && f.Options!.Contains("drop"));
|
||||
Assert.True(MenuCatalog.Find("/ip/dns")!.Singleton);
|
||||
Assert.True(MenuCatalog.Find("/ip/service")!.FixedList);
|
||||
// Jedes Menü hat Titel und Kurzbeschreibung, jedes Feld einen Hilfetext.
|
||||
Assert.All(MenuCatalog.All, m => Assert.False(string.IsNullOrWhiteSpace(m.Title + m.Summary)));
|
||||
Assert.All(MenuCatalog.All.SelectMany(m => m.Fields), f => Assert.False(string.IsNullOrWhiteSpace(f.Help)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dynamic", "true", false)]
|
||||
[InlineData("default", "true", false)]
|
||||
[InlineData("name", "default-trial", false)]
|
||||
[InlineData("comment", "eigene Regel", true)]
|
||||
public void CanDelete_HidesForRouterOwnedEntries(string key, string value, bool expected) =>
|
||||
Assert.Equal(expected, ExpertMenus.CanDelete(MenuCatalog.Find("/ip/firewall/filter"), new Dictionary<string, string> { [".id"] = "*1", [key] = value }));
|
||||
|
||||
[Fact]
|
||||
public void CanDelete_NeverForFixedListOrSingleton()
|
||||
{
|
||||
var row = new Dictionary<string, string> { [".id"] = "*1", ["name"] = "telnet" };
|
||||
Assert.False(ExpertMenus.CanDelete(MenuCatalog.Find("/ip/service"), row));
|
||||
Assert.False(ExpertMenus.CanDelete(MenuCatalog.Find("/ip/dns"), row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changes_OnlyModifiedFields()
|
||||
{
|
||||
var original = new Dictionary<string, string> { [".id"] = "*1", ["address"] = "10.0.0.1/24", ["interface"] = "bridge" };
|
||||
var edited = new Dictionary<string, string> { [".id"] = "*1", ["address"] = "10.0.0.2/24", ["interface"] = "bridge" };
|
||||
|
||||
Assert.Equal(new Dictionary<string, string> { ["address"] = "10.0.0.2/24" }, ExpertMenus.Changes(original, edited));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preview_ShowsRouterOsCommand() =>
|
||||
Assert.Equal("/ip address set *1 address=10.0.0.2/24 comment=\"mein Netz\"",
|
||||
ExpertMenus.Preview("/ip/address", "set", "*1", new Dictionary<string, string> { ["address"] = "10.0.0.2/24", ["comment"] = "mein Netz" }));
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_SingletonUsesSetWithoutId()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
|
||||
await ExpertMenus.SaveAsync(client, MenuCatalog.Find("/ip/dns"), "/ip/dns", null, new Dictionary<string, string> { ["servers"] = "9.9.9.9" });
|
||||
|
||||
Assert.Equal("/ip/dns/set", Assert.Single(client.ExecuteCalls).Path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MikrotikInstaller.Core.Configuration;
|
||||
using MikrotikInstaller.Core.Expert;
|
||||
using MikrotikInstaller.Core.Tests.Configuration;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Expert;
|
||||
|
||||
public class ExpertToolsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NewLan_TakesPortOutOfBridgeAndCreatesIsolatedNetwork()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/interface/bridge/port"] = [new Dictionary<string, string> { [".id"] = "*P4", ["bridge"] = "bridge", ["interface"] = "ether4" }];
|
||||
|
||||
var actions = ExpertToolPlans.NewLan(new NewLanSettings("ether4", "10.10.10.1/24", "10.10.10.10", "10.10.10.200", "1d", "bridge"), [], "bridge");
|
||||
foreach (var action in actions)
|
||||
{
|
||||
await action.ApplyAsync(client, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Contains(client.RemoveCalls, c => c == ("/interface/bridge/port", "*P4"));
|
||||
Assert.Contains(client.AddCalls, c => c.Path == "/ip/address" && c.Parameters["interface"] == "ether4");
|
||||
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server" && c.Parameters["lease-time"] == "1d" && c.Parameters["name"] == "dhcp-ether4");
|
||||
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter" && c.Parameters.GetValueOrDefault("comment") == "Isolation ether4 -> bridge");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejoinBridge_CleansUpPortAndAddsItToBridge()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/ip/address"] = [new Dictionary<string, string> { [".id"] = "*A", ["address"] = "10.10.10.1/24", ["interface"] = "ether4" }];
|
||||
client.GetResponses["/ip/dhcp-server"] = [new Dictionary<string, string> { [".id"] = "*S", ["name"] = "dhcp-ether4", ["interface"] = "ether4", ["address-pool"] = "pool-ether4" }];
|
||||
client.GetResponses["/ip/pool"] = [new Dictionary<string, string> { [".id"] = "*PL", ["name"] = "pool-ether4" }];
|
||||
client.GetResponses["/ip/dhcp-server/network"] = [new Dictionary<string, string> { [".id"] = "*N", ["address"] = "10.10.10.0/24" }];
|
||||
client.GetResponses["/ip/firewall/filter"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*F1", ["chain"] = "forward", ["in-interface"] = "ether4", ["out-interface"] = "bridge", ["action"] = "drop", ["comment"] = "Isolation ether4 -> bridge" },
|
||||
new Dictionary<string, string> { [".id"] = "*F2", ["chain"] = "forward", ["in-interface"] = "bridge", ["out-interface"] = "ether4", ["action"] = "drop" },
|
||||
new Dictionary<string, string> { [".id"] = "*F3", ["chain"] = "forward", ["in-interface"] = "ether4", ["action"] = "accept", ["comment"] = "eigene Regel" },
|
||||
];
|
||||
|
||||
foreach (var action in await ExpertToolPlans.RejoinBridgeAsync(client, "ether4", "bridge"))
|
||||
{
|
||||
await action.ApplyAsync(client, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(["*A", "*S", "*N", "*PL", "*F1", "*F2"], client.RemoveCalls.Select(c => c.Id));
|
||||
Assert.Contains(client.AddCalls, c => c.Path == "/interface/bridge/port" && c.Parameters["bridge"] == "bridge" && c.Parameters["interface"] == "ether4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Packages_ScheduleThenApply_AndRefuseSystemPackageRemoval()
|
||||
{
|
||||
PackageService.PollDelay = TimeSpan.Zero;
|
||||
var client = new RecordingRouterOsClient();
|
||||
var hotspot = new RouterPackage("*7", "hotspot", IsAvailable: true, IsDisabled: true, "", 300_000, "7.15.2");
|
||||
var routeros = new RouterPackage("*1", "routeros", false, false, "", 12_000_000, "7.15.2");
|
||||
|
||||
await PackageService.ApplyAsync(client, [hotspot], []);
|
||||
|
||||
Assert.Contains(client.ExecuteCalls, c => c.Path == "/system/package/enable" && c.Parameters![".id"] == "*7");
|
||||
Assert.Equal("/system/package/apply-changes", client.ExecuteCalls[^1].Path);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => PackageService.ApplyAsync(client, [], [routeros]));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using System.Net;
|
||||
|
||||
namespace MikrotikInstaller.Core.Configuration;
|
||||
|
||||
internal static class IpNetworkHelper
|
||||
public static class IpNetworkHelper
|
||||
{
|
||||
/// <summary>Berechnet aus "192.168.88.1/24" die Netzwerkadresse "192.168.88.0/24".</summary>
|
||||
public static string GetNetworkCidr(string addressCidr)
|
||||
|
||||
@@ -40,6 +40,15 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
public DemoRouterOsClient(DemoDeviceProfile profile)
|
||||
{
|
||||
_data = BuildInitialData(profile);
|
||||
|
||||
// Wie ein echter Router: jeder Listeneintrag hat eine ID (Einstellungs-Menüs wie /ip/dns nicht).
|
||||
foreach (var (path, rows) in _data)
|
||||
{
|
||||
foreach (var row in rows.Where(r => !r.ContainsKey(".id") && path is not ("/system/resource" or "/system/identity" or "/system/routerboard" or "/ip/dns" or "/system/package/update")))
|
||||
{
|
||||
row[".id"] = $"*D{_nextId++}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Matches(RouterOsCredentials credentials) =>
|
||||
@@ -110,6 +119,11 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
|
||||
public Task ExecuteAsync(string path, IReadOnlyDictionary<string, string>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Normalize(path) == "/system/package/apply-changes")
|
||||
{
|
||||
return RunAsync(path, parameters, cancellationToken);
|
||||
}
|
||||
|
||||
// Simuliert RouterOS "make-static": markiert die betroffene Lease als nicht mehr dynamisch.
|
||||
// Vereinfachung gegenüber echtem RouterOS, das dem Eintrag dabei eine neue ID zuweist — die ID
|
||||
// bleibt hier bewusst gleich, das ändert nichts am Verhalten für Aufrufer (LanScanService fragt
|
||||
@@ -179,6 +193,33 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
];
|
||||
case "/execute":
|
||||
return [new Dictionary<string, string> { ["ret"] = "192.168.88.70" }];
|
||||
case "/system/package/enable" or "/system/package/uninstall" or "/system/package/disable":
|
||||
var package = _data["/system/package"].FirstOrDefault(p => p.GetValueOrDefault(".id") == parameters?.GetValueOrDefault(".id"));
|
||||
if (package is not null)
|
||||
{
|
||||
package["scheduled"] = path.EndsWith("enable", StringComparison.Ordinal) && package["available"] == "true" ? "scheduled for install"
|
||||
: path.EndsWith("uninstall", StringComparison.Ordinal) ? "scheduled for uninstall" : string.Empty;
|
||||
}
|
||||
|
||||
return [];
|
||||
case "/system/package/apply-changes":
|
||||
// Simulierter Neustart: Vorgemerktes wird installiert bzw. entfernt.
|
||||
foreach (var p in _data["/system/package"].ToList())
|
||||
{
|
||||
if (p["scheduled"].Contains("uninstall"))
|
||||
{
|
||||
p["available"] = "true";
|
||||
}
|
||||
else if (p["scheduled"].Contains("install"))
|
||||
{
|
||||
p["available"] = "false";
|
||||
p["disabled"] = "false";
|
||||
}
|
||||
|
||||
p["scheduled"] = string.Empty;
|
||||
}
|
||||
|
||||
return [];
|
||||
default:
|
||||
await ExecuteAsync(path, parameters, cancellationToken);
|
||||
return [];
|
||||
@@ -191,7 +232,7 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
|
||||
private static Dictionary<string, List<Dictionary<string, string>>> BuildInitialData(DemoDeviceProfile profile) => new()
|
||||
{
|
||||
["/system/resource"] = [new() { ["board-name"] = profile.BoardModel, ["version"] = "7.15.2 (stable)", ["architecture-name"] = profile.Architecture }],
|
||||
["/system/resource"] = [new() { ["board-name"] = profile.BoardModel, ["version"] = "7.15.2 (stable)", ["architecture-name"] = profile.Architecture, ["free-hdd-space"] = "8000000" }],
|
||||
["/system/identity"] = [new() { ["name"] = profile.IdentityName }],
|
||||
["/system/routerboard"] = [BuildRouterboardRow(profile)],
|
||||
["/interface"] =
|
||||
@@ -207,6 +248,13 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
[
|
||||
new() { ["installed-version"] = "7.15.2", ["latest-version"] = "7.16.1", ["status"] = "New version is available" },
|
||||
],
|
||||
["/system/package"] =
|
||||
[
|
||||
new() { [".id"] = "*P1", ["name"] = "routeros", ["version"] = "7.15.2", ["available"] = "false", ["disabled"] = "false", ["scheduled"] = "", ["size"] = "12000000" },
|
||||
new() { [".id"] = "*P2", ["name"] = "wifi-qcom-ac", ["version"] = "7.15.2", ["available"] = "false", ["disabled"] = "false", ["scheduled"] = "", ["size"] = "3000000" },
|
||||
new() { [".id"] = "*P3", ["name"] = "hotspot", ["version"] = "7.15.2", ["available"] = "true", ["disabled"] = "true", ["scheduled"] = "", ["size"] = "300000" },
|
||||
new() { [".id"] = "*P4", ["name"] = "container", ["version"] = "7.15.2", ["available"] = "true", ["disabled"] = "true", ["scheduled"] = "", ["size"] = "100000" },
|
||||
],
|
||||
|
||||
// Ab hier: Beispieldaten für die IST-Zustand-Analyse (bereits vorhandene Konfiguration eines
|
||||
// simulierten Bestandsgeräts), damit sich der neue Analyse-Schritt über den Testzugang
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using MikrotikInstaller.Core.Configuration;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Expert;
|
||||
|
||||
/// <summary>Eingaben für „Neues LAN anlegen“ (Mac v1.3.0): ein eigenes Netz mit DHCP auf einem einzelnen Anschluss.</summary>
|
||||
public sealed record NewLanSettings(
|
||||
string InterfaceName, string RouterAddressCidr, string PoolStart, string PoolEnd, string? LeaseTime, string? IsolateFromInterface);
|
||||
|
||||
/// <summary>
|
||||
/// Pläne der Experte-Werkzeuge als <see cref="ConfigurationAction"/>-Listen — dieselbe Form wie im
|
||||
/// Assistenten, damit die Oberfläche sie vorab als Klartext zeigen und mit Fortschritt ausführen kann.
|
||||
/// </summary>
|
||||
public static class ExpertToolPlans
|
||||
{
|
||||
/// <summary>„Netzwerke isolieren“ (Mac v1.3.0): Baustein aus dem VLAN-Schritt, inkl. Verwaltungs-Sperre und Verbindungs-Kappung.</summary>
|
||||
public static IReadOnlyList<ConfigurationAction> Isolate(string isolatedInterface, string otherInterface, string isolatedNetworkCidr) =>
|
||||
NetworkIsolation.BuildActions(new DuplicateRuleGuard(), isolatedInterface, otherInterface, isolatedNetworkCidr);
|
||||
|
||||
/// <summary>
|
||||
/// „Neues LAN anlegen“: nimmt den Anschluss ggf. aus seiner Bridge, räumt Port-Konflikte und legt Adresse,
|
||||
/// Pool, DHCP-Server und DHCP-Netzwerk an — optional isoliert vom angegebenen Netz.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ConfigurationAction> NewLan(NewLanSettings settings, IReadOnlyList<PortConflict> conflicts, string? currentBridge)
|
||||
{
|
||||
var iface = settings.InterfaceName;
|
||||
var routerIp = settings.RouterAddressCidr.Split('/')[0];
|
||||
var networkCidr = IpNetworkHelper.GetNetworkCidr(settings.RouterAddressCidr);
|
||||
var poolName = $"pool-{iface}";
|
||||
var actions = new List<ConfigurationAction>(PortConflicts.BuildReleaseActions(conflicts));
|
||||
|
||||
if (currentBridge is not null)
|
||||
{
|
||||
actions.Add(new ConfigurationAction(
|
||||
$"„{iface}“ aus der Bridge „{currentBridge}“ herausnehmen",
|
||||
async (client, ct) =>
|
||||
{
|
||||
var port = (await client.GetAsync("/interface/bridge/port", ct)).FirstOrDefault(p => p.GetValueOrDefault("interface") == iface);
|
||||
if (port?.GetValueOrDefault(".id") is { } id)
|
||||
{
|
||||
await client.RemoveAsync("/interface/bridge/port", id, ct);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
actions.Add(new ConfigurationAction($"IP-Adresse {settings.RouterAddressCidr} auf „{iface}“ einrichten",
|
||||
(client, ct) => client.AddAsync("/ip/address", new Dictionary<string, string> { ["address"] = settings.RouterAddressCidr, ["interface"] = iface }, ct)));
|
||||
actions.Add(new ConfigurationAction($"Adressbereich {settings.PoolStart}-{settings.PoolEnd} reservieren",
|
||||
(client, ct) => RouterOsUpsert.UpsertAsync(client, "/ip/pool", new Dictionary<string, string> { ["name"] = poolName },
|
||||
new Dictionary<string, string> { ["name"] = poolName, ["ranges"] = $"{settings.PoolStart}-{settings.PoolEnd}" }, cancellationToken: ct)));
|
||||
|
||||
var server = new Dictionary<string, string> { ["interface"] = iface, ["address-pool"] = poolName, ["disabled"] = "no" };
|
||||
if (!string.IsNullOrEmpty(settings.LeaseTime))
|
||||
{
|
||||
server["lease-time"] = settings.LeaseTime;
|
||||
}
|
||||
|
||||
actions.Add(new ConfigurationAction($"DHCP-Server auf „{iface}“ aktivieren",
|
||||
(client, ct) => RouterOsUpsert.UpsertAsync(client, "/ip/dhcp-server", new Dictionary<string, string> { ["interface"] = iface }, server,
|
||||
new Dictionary<string, string> { ["name"] = $"dhcp-{iface}" }, ct)));
|
||||
actions.Add(new ConfigurationAction($"Netzwerk-Einstellungen (Gateway/DNS {routerIp}) für „{iface}“ hinterlegen",
|
||||
(client, ct) => RouterOsUpsert.UpsertAsync(client, "/ip/dhcp-server/network", new Dictionary<string, string> { ["address"] = networkCidr },
|
||||
new Dictionary<string, string> { ["address"] = networkCidr, ["gateway"] = routerIp, ["dns-server"] = routerIp }, cancellationToken: ct)));
|
||||
|
||||
if (settings.IsolateFromInterface is { Length: > 0 } other)
|
||||
{
|
||||
actions.AddRange(Isolate(iface, other, networkCidr));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// „Port in Bridge einfügen“ (Mac v1.4.0, auch WLAN seit v1.11.3): der Rückweg zu „Neues LAN anlegen“.
|
||||
/// Räumt Adresse, DHCP-Client/-Server samt Pool und DHCP-Netzwerk sowie Isolationsregeln des Anschlusses
|
||||
/// und hängt ihn in die Bridge.
|
||||
/// </summary>
|
||||
public static async Task<IReadOnlyList<ConfigurationAction>> RejoinBridgeAsync(
|
||||
IRouterOsClient client, string port, string bridge, CancellationToken ct = default)
|
||||
{
|
||||
var conflicts = await PortConflicts.FindAsync(client, [port], ct);
|
||||
var actions = new List<ConfigurationAction>(PortConflicts.BuildReleaseActions(conflicts));
|
||||
|
||||
var addresses = conflicts.Where(c => c.Path == "/ip/address" && c.Address is not null).Select(c => c.Address!).ToList();
|
||||
var networks = await client.GetAsync("/ip/dhcp-server/network", ct);
|
||||
foreach (var network in networks.Where(n => n.GetValueOrDefault("address") is { } cidr && addresses.Any(a => IpNetworkHelper.Contains(cidr, a))))
|
||||
{
|
||||
actions.Add(new ConfigurationAction($"DHCP-Netzwerk {network["address"]} von „{port}“ entfernen",
|
||||
(c, token) => c.RemoveAsync("/ip/dhcp-server/network", network[".id"], token)));
|
||||
}
|
||||
|
||||
var servers = await client.GetAsync("/ip/dhcp-server", ct);
|
||||
var pools = await client.GetAsync("/ip/pool", ct);
|
||||
foreach (var server in servers.Where(s => s.GetValueOrDefault("interface") == port))
|
||||
{
|
||||
if (pools.FirstOrDefault(p => p.GetValueOrDefault("name") == server.GetValueOrDefault("address-pool")) is { } pool)
|
||||
{
|
||||
actions.Add(new ConfigurationAction($"Adressbereich „{pool["name"]}“ von „{port}“ entfernen",
|
||||
(c, token) => c.RemoveAsync("/ip/pool", pool[".id"], token)));
|
||||
}
|
||||
}
|
||||
|
||||
// Isolationsregeln: mit Kommentar „Isolation …“ oder ältere Drop-Regeln ohne Kommentar, die den Anschluss betreffen.
|
||||
var rules = await client.GetAsync("/ip/firewall/filter", ct);
|
||||
foreach (var rule in rules.Where(r => IsIsolationRuleFor(r, port)))
|
||||
{
|
||||
actions.Add(new ConfigurationAction($"Isolationsregel „{rule.GetValueOrDefault("comment") ?? rule.GetValueOrDefault("chain")}“ entfernen",
|
||||
(c, token) => c.RemoveAsync("/ip/firewall/filter", rule[".id"], token)));
|
||||
}
|
||||
|
||||
actions.Add(new ConfigurationAction($"„{port}“ in die Bridge „{bridge}“ einhängen",
|
||||
(c, token) => RouterOsUpsert.UpsertAsync(c, "/interface/bridge/port", new Dictionary<string, string> { ["interface"] = port },
|
||||
new Dictionary<string, string> { ["bridge"] = bridge, ["interface"] = port }, cancellationToken: token)));
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static bool IsIsolationRuleFor(IReadOnlyDictionary<string, string> rule, string port)
|
||||
{
|
||||
var touchesPort = rule.GetValueOrDefault("in-interface") == port || rule.GetValueOrDefault("out-interface") == port;
|
||||
var comment = rule.GetValueOrDefault("comment") ?? string.Empty;
|
||||
return touchesPort && rule.ContainsKey(".id")
|
||||
&& (comment.StartsWith("Isolation", StringComparison.Ordinal)
|
||||
|| (comment.Length == 0 && rule.GetValueOrDefault("action") == "drop" && rule.GetValueOrDefault("chain") == "forward"
|
||||
&& rule.ContainsKey("in-interface") && rule.ContainsKey("out-interface")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Expert;
|
||||
|
||||
/// <summary>Ein Feld eines kuratierten Menüs mit laienverständlicher Hilfe.</summary>
|
||||
/// <param name="Kind">text, bool, int, enum, pick, interfacePick, duration, dataVolume.</param>
|
||||
public sealed record FieldSchema(
|
||||
string Key, string Label, string Help, string Kind,
|
||||
IReadOnlyList<string>? Options = null, string? PickPath = null, string? PickField = null,
|
||||
string? Default = null, bool Required = false);
|
||||
|
||||
/// <param name="Singleton">Menü ohne Einträge, nur Einstellungen (z. B. /ip/dns) — Änderungen per "set" ohne ID.</param>
|
||||
/// <param name="FixedList">Feste Liste (z. B. /ip/service): bearbeiten ja, anlegen/löschen nein.</param>
|
||||
public sealed record MenuSchema(
|
||||
string Path, string Group, string Title, string Summary, string Explanation, string? Warning,
|
||||
IReadOnlyList<FieldSchema> Fields, IReadOnlyList<string> Columns, bool Singleton = false, bool FixedList = false);
|
||||
|
||||
/// <summary>
|
||||
/// Kuratierte RouterOS-Menüs für den Experte-Bereich (Issue #21/#62), übernommen aus dem Katalog der
|
||||
/// Mac-Version (RouterOSSchemaCatalog, per Skript nach menu-catalog.json übersetzt). Alles andere bleibt
|
||||
/// über „eigener Menüpfad“ generisch erreichbar.
|
||||
/// </summary>
|
||||
public static class MenuCatalog
|
||||
{
|
||||
private static readonly Lazy<IReadOnlyList<MenuSchema>> Menus = new(Load);
|
||||
|
||||
public static IReadOnlyList<MenuSchema> All => Menus.Value;
|
||||
|
||||
public static MenuSchema? Find(string path) =>
|
||||
All.FirstOrDefault(m => string.Equals(m.Path, NormalizePath(path), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>"ip firewall filter", "/ip/firewall/filter/" → "/ip/firewall/filter".</summary>
|
||||
public static string NormalizePath(string path) =>
|
||||
"/" + string.Join('/', path.Split([' ', '/'], StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private static IReadOnlyList<MenuSchema> Load()
|
||||
{
|
||||
using var stream = typeof(MenuCatalog).Assembly.GetManifestResourceStream("MikrotikInstaller.Core.Expert.menu-catalog.json")
|
||||
?? throw new InvalidOperationException("menu-catalog.json fehlt als eingebettete Ressource.");
|
||||
return JsonSerializer.Deserialize<List<MenuSchema>>(stream, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
}) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Regeln und Schreibzugriffe des Experte-Bereichs, unabhängig von der Oberfläche testbar.</summary>
|
||||
public static class ExpertMenus
|
||||
{
|
||||
/// <summary>
|
||||
/// Dynamische Einträge (von RouterOS selbst erzeugt), Standard-Einträge (z. B. Profil „default“) und
|
||||
/// feste Listen lehnt RouterOS beim Löschen ab — dort gar keinen Löschen-Knopf zeigen (Mac v1.6.0, Mac #40).
|
||||
/// </summary>
|
||||
public static bool CanDelete(MenuSchema? schema, IReadOnlyDictionary<string, string> row) =>
|
||||
schema is not { Singleton: true } and not { FixedList: true }
|
||||
&& !IsTrue(row, "dynamic") && !IsTrue(row, "default") && !IsTrue(row, "builtin")
|
||||
&& !(row.GetValueOrDefault("name")?.StartsWith("default", StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
&& row.ContainsKey(".id");
|
||||
|
||||
public static bool IsDisabled(IReadOnlyDictionary<string, string> row) => IsTrue(row, "disabled");
|
||||
|
||||
public static bool IsTrue(IReadOnlyDictionary<string, string> row, string key) =>
|
||||
row.GetValueOrDefault(key) is "true" or "yes";
|
||||
|
||||
/// <summary>Nur geänderte, nicht schreibgeschützte Felder — leer heißt: nichts zu speichern.</summary>
|
||||
public static Dictionary<string, string> Changes(
|
||||
IReadOnlyDictionary<string, string>? original, IReadOnlyDictionary<string, string> edited) =>
|
||||
edited
|
||||
.Where(kv => !kv.Key.StartsWith('.') && (original?.GetValueOrDefault(kv.Key) ?? string.Empty) != kv.Value)
|
||||
.Where(kv => original is not null || kv.Value.Length > 0)
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
/// <summary>Der Befehl, der gleich ausgeführt wird, in RouterOS-Schreibweise — nichts passiert unangekündigt.</summary>
|
||||
public static string Preview(string path, string verb, string? id, IReadOnlyDictionary<string, string> parameters)
|
||||
{
|
||||
var words = new List<string> { $"{MenuCatalog.NormalizePath(path).Replace('/', ' ').Trim()} {verb}".Insert(0, "/") };
|
||||
if (id is not null)
|
||||
{
|
||||
words.Add(id);
|
||||
}
|
||||
|
||||
words.AddRange(parameters.Select(kv => kv.Value.Contains(' ') || kv.Value.Length == 0 ? $"{kv.Key}=\"{kv.Value}\"" : $"{kv.Key}={kv.Value}"));
|
||||
return string.Join(' ', words);
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(
|
||||
IRouterOsClient client, MenuSchema? schema, string path, string? id, IReadOnlyDictionary<string, string> changes, CancellationToken ct = default)
|
||||
{
|
||||
if (schema is { Singleton: true })
|
||||
{
|
||||
await client.ExecuteAsync($"{MenuCatalog.NormalizePath(path)}/set", changes, ct);
|
||||
}
|
||||
else if (id is null)
|
||||
{
|
||||
await client.AddAsync(path, changes, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.SetAsync(path, id, changes, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Expert;
|
||||
|
||||
/// <summary>Ein Eintrag aus /system/package (Mac v1.11.0, live geprüft an CHR 7.24.5 und hAP lite).</summary>
|
||||
/// <param name="IsAvailable">Von MikroTik angeboten, nicht installiert.</param>
|
||||
/// <param name="Scheduled">RouterOS' Hinweis auf eine vorgemerkte Änderung, leer wenn nichts vorgemerkt ist.</param>
|
||||
public sealed partial record RouterPackage(string Id, string Name, bool IsAvailable, bool IsDisabled, string Scheduled, long SizeBytes, string Version)
|
||||
{
|
||||
public bool IsInstalled => !IsAvailable;
|
||||
|
||||
public bool IsScheduledForInstall => Scheduled.Contains("install") && !Scheduled.Contains("uninstall");
|
||||
|
||||
public bool IsScheduledForUninstall => Scheduled.Contains("uninstall");
|
||||
|
||||
/// <summary>Das Grundpaket — wird nie zum Entfernen angeboten (RouterOS würde es sogar vormerken).</summary>
|
||||
public bool IsSystemPackage => Name == "routeros" || Name.StartsWith("routeros-", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>WLAN-Treiber: Entfernen schaltet das WLAN des Routers ab.</summary>
|
||||
public bool IsWirelessDriver => Name == "wireless" || Name.StartsWith("wifi", StringComparison.Ordinal);
|
||||
|
||||
public string? Description => PackageService.Descriptions.GetValueOrDefault(Name);
|
||||
|
||||
/// <summary>Nur RouterOS' eigenes Namensmuster — alles andere könnte einen zweiten Befehl tragen.</summary>
|
||||
public static bool IsSafeName(string name) => SafeName().IsMatch(name);
|
||||
|
||||
[GeneratedRegex("^[a-z0-9-]+$")]
|
||||
private static partial Regex SafeName();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zusatzpakete installieren/entfernen (Experte → Pakete). Ablauf wie in der Mac-Version: Liste vom
|
||||
/// MikroTik-Server auffrischen, Änderungen per enable/uninstall vormerken, mit apply-changes in einem
|
||||
/// Rutsch herunterladen und neu starten. Wird eine Vormerkung abgelehnt, werden die bereits gesetzten
|
||||
/// zurückgenommen, damit beim nächsten Neustart nichts Halbgeplantes passiert.
|
||||
/// </summary>
|
||||
public static class PackageService
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> Descriptions = new Dictionary<string, string>
|
||||
{
|
||||
["routeros"] = "Das Betriebssystem des Routers selbst. Kann nicht entfernt werden.",
|
||||
["wireless"] = "Treiber und Werkzeuge für WLAN (bis 802.11ac) und 60-GHz-Funk.",
|
||||
["wifi-qcom"] = "Pflicht-Treiber für WLAN nach 802.11ax (Wi-Fi 6).",
|
||||
["wifi-qcom-ac"] = "Optionaler WLAN-Treiber für passende 802.11ac-Funkchips.",
|
||||
["hotspot"] = "Hotspot: Anmeldeseite (Login-Portal) für Gäste-WLAN oder Gäste-LAN.",
|
||||
["calea"] = "Datenerfassung für US-Behörden (Gesetz CALEA). Im Heimnetz nicht nötig.",
|
||||
["container"] = "Lässt Linux-Container direkt auf dem Router laufen.",
|
||||
["dude"] = "The Dude: Überwachung des ganzen Netzwerks mit Netzwerkkarte.",
|
||||
["gps"] = "Unterstützung für GPS-Empfänger.",
|
||||
["iot"] = "Internet der Dinge: MQTT, LoRa, Bluetooth, GPIO und Modbus (je nach Hardware).",
|
||||
["lora"] = "Platzhalter für alte LoRa-Einstellungen. LoRa steckt heute im Paket iot.",
|
||||
["lte"] = "Treiber für das eingebaute LTE-Modem, nur beim SXT LTE nötig.",
|
||||
["netinstall"] = "Netinstall-Server: spielt RouterOS über das Netzwerk auf andere MikroTik-Geräte.",
|
||||
["openflow"] = "OpenFlow: Steuerung durch einen externen SDN-Controller.",
|
||||
["rose-storage"] = "Rechenzentrums-Funktionen für Datenträger: RAID, rsync, iSCSI, NFS, SMB und mehr.",
|
||||
["tr069-client"] = "TR-069: Fernverwaltung durch einen Internetanbieter.",
|
||||
["ups"] = "Überwachung einer angeschlossenen APC-USV (unterbrechungsfreie Stromversorgung).",
|
||||
["user-manager"] = "User Manager: Benutzerverwaltung für Hotspot und andere Dienste.",
|
||||
["zerotier"] = "ZeroTier: virtuelles Netzwerk über das Internet.",
|
||||
["extra-nic"] = "Zusätzliche Netzwerkkarten-Treiber für Nicht-MikroTik-Hardware.",
|
||||
["switch-marvell"] = "Pflicht-Treiber für Switches der Serie CRS8xx.",
|
||||
["iot-bt-extra"] = "Unterstützung für USB-Bluetooth-Adapter (ab Bluetooth LE 4.0).",
|
||||
};
|
||||
|
||||
/// <summary>Wartezeit-Takt beim Abfragen von RouterOS-Zuständen; in Tests 0.</summary>
|
||||
public static TimeSpan PollDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>Paketliste plus freier Speicher. Leere Liste verfügbarer Pakete (z. B. nach einem Neustart) wird einmal aufgefrischt.</summary>
|
||||
public static async Task<(IReadOnlyList<RouterPackage> Packages, long? FreeBytes)> ListAsync(IRouterOsClient client, bool refresh = false, CancellationToken ct = default)
|
||||
{
|
||||
if (refresh)
|
||||
{
|
||||
await RefreshAvailableAsync(client, ct);
|
||||
}
|
||||
|
||||
var packages = await ReadAsync(client, ct);
|
||||
if (!refresh && !packages.Any(p => p.IsAvailable))
|
||||
{
|
||||
await RefreshAvailableAsync(client, ct);
|
||||
packages = await ReadAsync(client, ct);
|
||||
}
|
||||
|
||||
var resource = (await client.GetAsync("/system/resource", ct)).FirstOrDefault();
|
||||
long? free = long.TryParse(resource?.GetValueOrDefault("free-hdd-space"), out var bytes) ? bytes : null;
|
||||
return (packages, free);
|
||||
}
|
||||
|
||||
/// <summary>Merkt alle Änderungen vor und wendet sie gemeinsam an — ein Download, ein Neustart.</summary>
|
||||
public static async Task ApplyAsync(IRouterOsClient client, IReadOnlyList<RouterPackage> install, IReadOnlyList<RouterPackage> uninstall, CancellationToken ct = default)
|
||||
{
|
||||
if (install.Concat(uninstall).FirstOrDefault(p => !RouterPackage.IsSafeName(p.Name) || (p.IsSystemPackage && uninstall.Contains(p))) is { } refused)
|
||||
{
|
||||
throw new InvalidOperationException($"Das Paket „{refused.Name}“ kann nicht geändert werden.");
|
||||
}
|
||||
|
||||
if (install.Count > 0)
|
||||
{
|
||||
await RefreshAvailableAsync(client, ct);
|
||||
}
|
||||
|
||||
var scheduled = new List<(RouterPackage Package, string Undo)>();
|
||||
try
|
||||
{
|
||||
foreach (var package in install)
|
||||
{
|
||||
await client.RunAsync("/system/package/enable", new Dictionary<string, string> { [".id"] = package.Id }, ct);
|
||||
scheduled.Add((package, "disable"));
|
||||
}
|
||||
|
||||
foreach (var package in uninstall)
|
||||
{
|
||||
await client.RunAsync("/system/package/uninstall", new Dictionary<string, string> { [".id"] = package.Id }, ct);
|
||||
scheduled.Add((package, "enable"));
|
||||
}
|
||||
}
|
||||
catch (RouterOsException)
|
||||
{
|
||||
foreach (var (package, undo) in scheduled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.RunAsync($"/system/package/{undo}", new Dictionary<string, string> { [".id"] = package.Id }, ct);
|
||||
}
|
||||
catch (RouterOsException)
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await client.ExecuteAsync("/system/package/apply-changes", cancellationToken: ct);
|
||||
}
|
||||
catch (RouterOsException ex) when (ex.IsConnectionLost)
|
||||
{
|
||||
// Erwartet: Der Router lädt herunter und startet neu.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<RouterPackage>> ReadAsync(IRouterOsClient client, CancellationToken ct) =>
|
||||
(await client.GetAsync("/system/package", ct))
|
||||
.Where(r => r.GetValueOrDefault("name") is { } name && RouterPackage.IsSafeName(name))
|
||||
.Select(r => new RouterPackage(
|
||||
r.GetValueOrDefault(".id") ?? r["name"],
|
||||
r["name"],
|
||||
r.GetValueOrDefault("available") is "true" or "yes",
|
||||
r.GetValueOrDefault("disabled") is "true" or "yes",
|
||||
r.GetValueOrDefault("scheduled") ?? string.Empty,
|
||||
long.TryParse(r.GetValueOrDefault("size"), out var size) ? size : 0,
|
||||
r.GetValueOrDefault("version") ?? string.Empty))
|
||||
.OrderBy(p => p.IsAvailable)
|
||||
.ThenBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Fragt beim MikroTik-Server nach verfügbaren Paketen. Solange RouterOS „finding out latest version“
|
||||
/// meldet, ist die Liste unvollständig und apply-changes würde still ignoriert (Mac, live CHR 7.24.5).
|
||||
/// </summary>
|
||||
private static async Task RefreshAvailableAsync(IRouterOsClient client, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.ExecuteAsync("/system/package/update/check-for-updates", cancellationToken: ct);
|
||||
}
|
||||
catch (RouterOsException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ponytail: 60 Abfragen im Sekundentakt, danach trotzdem weiter; ein verpasstes Ende fällt beim Ergebnis auf.
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var status = (await client.GetAsync("/system/package/update", ct)).FirstOrDefault()?.GetValueOrDefault("status") ?? string.Empty;
|
||||
if (!status.Contains("finding out", StringComparison.OrdinalIgnoreCase) && !status.Contains("checking", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(PollDelay, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,11 @@
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Kuratierte Experte-Menüs, aus dem Katalog der Mac-Version übersetzt (siehe MenuCatalog). -->
|
||||
<EmbeddedResource Include="Expert\menu-catalog.json" LogicalName="MikrotikInstaller.Core.Expert.menu-catalog.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MikrotikInstaller.Core.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in new issue
Block a user