Build / build (push) Canceled after 0s
- Seitenleiste mit Verbindungskarte und frei anklickbaren Bereichen Start, Einrichten, Uebersicht, Geraete, Backup & Wartung, Experte; Hilfe & Begriffe und Einstellungen als Bereiche unten (vorher Fenster) - Ohne Verbindung: Verbinden-Seite, Geraetebereiche gesperrt - Einrichten: Assistent mit anklickbaren Schritten und eigener Zurueck/Weiter-Leiste (SetupWizardViewModel) - Start: Schnellzugriffe, Geraetedaten, RouterOS-Update - Bereichswahl-Schritt und SelectedArea entfallen; Plan wird bei jedem neuen Verbinden zurueckgesetzt (WizardSession.ResetPlan) - Fix: Entfernen eines bekannten Routers in der Seitenleiste band an ein nicht vorhandenes Kommando Closes #72 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
9.6 KiB
C#
296 lines
9.6 KiB
C#
using MikrotikInstaller.Core.Localization;
|
|
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using MikrotikInstaller.Core.Connectivity;
|
|
using MikrotikInstaller.Core.Expert;
|
|
|
|
namespace MikrotikInstaller.App.ViewModels.Steps;
|
|
|
|
/// <summary>Ein Eintrag in der Menüliste links: ein kuratiertes Menü oder ein Werkzeug.</summary>
|
|
public sealed record ExpertMenuItem(string Title, string Group, string? Path, MenuSchema? Schema, string? Tool = null);
|
|
|
|
/// <summary>Ein Listeneintrag eines Menüs.</summary>
|
|
public sealed class ExpertEntryViewModel(IReadOnlyDictionary<string, string> row, MenuSchema? schema)
|
|
{
|
|
public IReadOnlyDictionary<string, string> Row { get; } = row;
|
|
|
|
public string? Id => Row.GetValueOrDefault(".id");
|
|
|
|
/// <summary>Die wichtigsten Spalten des Menüs, sonst die ersten sinnvollen Felder.</summary>
|
|
public string Summary
|
|
{
|
|
get
|
|
{
|
|
var keys = schema?.Columns is { Count: > 0 } columns
|
|
? columns
|
|
: Row.Keys.Where(k => !k.StartsWith('.') && k is not ("disabled" or "dynamic" or "invalid")).Take(4).ToList();
|
|
var parts = keys.Select(k => Row.GetValueOrDefault(k)).Where(v => !string.IsNullOrEmpty(v));
|
|
var text = string.Join(" · ", parts);
|
|
return text.Length > 0 ? text : Id ?? "(Eintrag)";
|
|
}
|
|
}
|
|
|
|
public bool IsDisabled => ExpertMenus.IsDisabled(Row);
|
|
|
|
public bool IsDynamic => ExpertMenus.IsTrue(Row, "dynamic");
|
|
|
|
public bool CanDelete => ExpertMenus.CanDelete(schema, Row);
|
|
}
|
|
|
|
/// <summary>Ein Feld im Bearbeiten-Bereich, mit Hilfe aus dem Katalog.</summary>
|
|
public partial class ExpertFieldViewModel : ObservableObject
|
|
{
|
|
public ExpertFieldViewModel(string key, string label, string help, string kind, IReadOnlyList<string> options, string original)
|
|
{
|
|
Key = key;
|
|
Label = label;
|
|
Help = help;
|
|
Kind = kind;
|
|
Options = options;
|
|
Original = kind == "bool" ? NormalizeBool(original) : original;
|
|
_value = Original;
|
|
}
|
|
|
|
public string Key { get; }
|
|
|
|
public string Label { get; }
|
|
|
|
public string Help { get; }
|
|
|
|
public string Kind { get; }
|
|
|
|
public IReadOnlyList<string> Options { get; }
|
|
|
|
public string Original { get; }
|
|
|
|
public bool IsBool => Kind == "bool";
|
|
|
|
public bool HasOptions => !IsBool && Options.Count > 0;
|
|
|
|
public bool IsText => !IsBool && Options.Count == 0;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsChecked))]
|
|
private string _value;
|
|
|
|
public bool IsChecked
|
|
{
|
|
get => Value == "true";
|
|
set => Value = value ? "true" : "false";
|
|
}
|
|
|
|
/// <summary>RouterOS liefert "true"/"false", akzeptiert aber auch "yes"/"no" — einheitlich vergleichen.</summary>
|
|
private static string NormalizeBool(string value) => value switch
|
|
{
|
|
"yes" or "true" => "true",
|
|
"no" or "false" => "false",
|
|
_ => value,
|
|
};
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Experte-Bereich (Issue #21, Mac „Experte“-Tab): kuratierte RouterOS-Menüs mit Feldhilfen und ein
|
|
/// eigener Menüpfad für alles andere. Auflisten, bearbeiten, anlegen, löschen — jede Änderung zeigt vorher
|
|
/// den genauen Befehl, vor der ersten Änderung entsteht eine Sicherung. Nur mit „Professioneller Netzwerker“.
|
|
/// </summary>
|
|
public partial class ExpertStepViewModel : WizardStepViewModelBase
|
|
{
|
|
private readonly WizardSession _session;
|
|
|
|
public ExpertStepViewModel(WizardSession session)
|
|
: base(
|
|
title: L.T("Experte"),
|
|
description: L.T("Direkter Zugriff auf RouterOS-Menüs. Jede Änderung zeigt vorher den genauen Befehl; vor der ersten Änderung wird eine Sicherung erstellt."))
|
|
{
|
|
_session = session;
|
|
Menus = [.. ExpertTools.Items, .. MenuCatalog.All.Select(m => new ExpertMenuItem(m.Title, m.Group, m.Path, m))];
|
|
Tools = new ExpertTools(session);
|
|
}
|
|
|
|
|
|
public IReadOnlyList<ExpertMenuItem> Menus { get; }
|
|
|
|
/// <summary>Werkzeuge (Netzwerke isolieren, Neues LAN, Port in Bridge, Pakete).</summary>
|
|
public ExpertTools Tools { get; }
|
|
|
|
public ObservableCollection<ExpertEntryViewModel> Entries { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsMenuSelected))]
|
|
[NotifyPropertyChangedFor(nameof(IsToolSelected))]
|
|
[NotifyPropertyChangedFor(nameof(CanAdd))]
|
|
private ExpertMenuItem? _selectedMenu;
|
|
|
|
public bool IsMenuSelected => SelectedMenu is { Tool: null };
|
|
|
|
public bool IsToolSelected => SelectedMenu is { Tool: not null };
|
|
|
|
public bool CanAdd => SelectedMenu?.Schema is not { Singleton: true } and not { FixedList: true } && IsMenuSelected;
|
|
|
|
[ObservableProperty]
|
|
private string _customPath = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool _isLoading;
|
|
|
|
[ObservableProperty]
|
|
private string? _errorMessage;
|
|
|
|
[ObservableProperty]
|
|
private string? _statusMessage;
|
|
|
|
/// <summary>Der geöffnete Bearbeiten-Bereich, null wenn geschlossen (gemeinsam mit der Übersicht, Issue #24).</summary>
|
|
[ObservableProperty]
|
|
private ExpertEditorViewModel? _editor;
|
|
|
|
public override async Task OnActivatedAsync()
|
|
{
|
|
await Tools.ActivateAsync();
|
|
if (SelectedMenu is { Path: not null })
|
|
{
|
|
await LoadEntriesAsync();
|
|
}
|
|
}
|
|
|
|
partial void OnSelectedMenuChanged(ExpertMenuItem? value)
|
|
{
|
|
Tools.ActiveTool = value?.Tool;
|
|
Editor = null;
|
|
Entries.Clear();
|
|
ErrorMessage = null;
|
|
StatusMessage = null;
|
|
if (value is { Path: not null })
|
|
{
|
|
_ = LoadEntriesAsync();
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenCustomPath()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(CustomPath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var path = MenuCatalog.NormalizePath(CustomPath);
|
|
SelectedMenu = Menus.FirstOrDefault(m => m.Path == path)
|
|
?? new ExpertMenuItem(path, L.T("Eigener Menüpfad"), path, MenuCatalog.Find(path));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task Refresh() => LoadEntriesAsync();
|
|
|
|
private async Task LoadEntriesAsync()
|
|
{
|
|
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsLoading = true;
|
|
ErrorMessage = null;
|
|
Entries.Clear();
|
|
try
|
|
{
|
|
foreach (var row in await client.GetAsync(path))
|
|
{
|
|
Entries.Add(new ExpertEntryViewModel(row, SelectedMenu.Schema));
|
|
}
|
|
|
|
if (SelectedMenu.Schema is { Singleton: true } && Entries.Count > 0)
|
|
{
|
|
EditEntry(Entries[0]);
|
|
}
|
|
}
|
|
catch (RouterOsException ex)
|
|
{
|
|
ErrorMessage = DescribeLoadError(path, ex);
|
|
}
|
|
finally
|
|
{
|
|
IsLoading = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Fehlt ein Bereich, weil er ein eigenes Paket ist (z. B. Hotspot auf dem hAP lite), verständlich erklären (Mac v1.11.0).</summary>
|
|
private static string DescribeLoadError(string path, RouterOsException ex) =>
|
|
path.StartsWith("/ip/hotspot", StringComparison.OrdinalIgnoreCase)
|
|
? L.T("Hotspot ist auf diesem Router ein eigenes Zusatzpaket und noch nicht installiert. Unter „Werkzeuge → Pakete“ lässt sich das Paket „hotspot“ installieren.")
|
|
: ex.Message == L.T("Diese Einstellung wird von deinem Gerät nicht unterstützt.")
|
|
? L.F("Den Menüpfad „{0}“ gibt es auf diesem Router nicht (RouterOS-Version oder fehlendes Paket).", path)
|
|
: ex.Message;
|
|
|
|
[RelayCommand]
|
|
private void EditEntry(ExpertEntryViewModel entry) => OpenEditor(entry.Row);
|
|
|
|
[RelayCommand]
|
|
private void NewEntry() => OpenEditor(null);
|
|
|
|
private void OpenEditor(IReadOnlyDictionary<string, string>? row)
|
|
{
|
|
if (SelectedMenu?.Path is not { } path)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ExpertEditorViewModel? editor = null;
|
|
editor = new ExpertEditorViewModel(_session, path, SelectedMenu.Schema, row, Tools.InterfaceNames, async () =>
|
|
{
|
|
StatusMessage = L.F("Gespeichert: {0}", editor!.LastSavedCommand);
|
|
await LoadEntriesAsync();
|
|
});
|
|
editor.Closed += (_, _) =>
|
|
{
|
|
if (Editor == editor)
|
|
{
|
|
Editor = null;
|
|
}
|
|
};
|
|
Editor = editor;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task DeleteEntryAsync(ExpertEntryViewModel entry)
|
|
{
|
|
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path || entry.Id is not { } id || !entry.CanDelete)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var preview = ExpertMenus.Preview(path, "remove", id, new Dictionary<string, string>());
|
|
if (System.Windows.MessageBox.Show(L.F("Diesen Eintrag löschen?\n\n{0}\n\nBefehl: {1}", entry.Summary, preview), L.T("Eintrag löschen"),
|
|
System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.Yes)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsLoading = true;
|
|
ErrorMessage = null;
|
|
try
|
|
{
|
|
if (!_session.ExpertBackupDone)
|
|
{
|
|
StatusMessage = L.T("Sicherung vor der ersten Änderung wird erstellt …");
|
|
await _session.CreateSafetyBackupAsync(client);
|
|
_session.ExpertBackupDone = true;
|
|
}
|
|
|
|
await client.RemoveAsync(path, id);
|
|
StatusMessage = L.F("Gelöscht: {0}", preview);
|
|
}
|
|
catch (RouterOsException ex)
|
|
{
|
|
ErrorMessage = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
IsLoading = false;
|
|
}
|
|
|
|
await LoadEntriesAsync();
|
|
}
|
|
}
|